Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbd70281b | ||
|
|
395e26b524 |
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
| podsteadr | 8095, 1935, 8889, 8189, 8890, 8098 | TCP/UDP | Web UI/API/RSS, RTMP ingest, WebRTC/WHIP ingest, WebRTC ICE (udp), HLS playback, Blossom blobs | 18095, 11935, 18889, 18189, 18890, 18098 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
||||
- **did-wallet** — Web5 DID Wallet
|
||||
|
||||
### Self-Hosted Services
|
||||
- **podsteadr** — Nostr-native podcast publishing and livestreaming (RTMP/WebRTC ingest, HLS, RSS, Blossom media)
|
||||
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
|
||||
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
app:
|
||||
id: podsteadr-blossom
|
||||
name: podsteadr Blossom
|
||||
version: "4"
|
||||
description: Blossom (BUD-02) sha256-addressed media blob server backing podsteadr's episode uploads and covers.
|
||||
category: media
|
||||
|
||||
# Hyphenated name matches the podsteadr repo's docker-compose container_name
|
||||
# (podsteadr-blossom); alias `blossom` is the short hostname podsteadr's
|
||||
# server reaches it by (BLOSSOM_URL_INTERNAL=http://blossom:3000).
|
||||
container_name: podsteadr-blossom
|
||||
|
||||
container:
|
||||
image: ghcr.io/hzrd149/blossom-server:4
|
||||
pull_policy: if-not-present
|
||||
network: podsteadr-net
|
||||
network_aliases: [blossom]
|
||||
# Image runs as container-root (no USER directive) writing to a
|
||||
# bind-mounted /app/data — CHOWN/DAC_OVERRIDE cover the fresh-bind-dir
|
||||
# ownership gap the same way apps/botfights and apps/immich document.
|
||||
# Unverified against a real install; check first-boot logs.
|
||||
data_uid: "0:0"
|
||||
|
||||
dependencies:
|
||||
- storage: 20Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8098
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Media blobs (episode audio/video, covers) must be publicly fetchable
|
||||
by podcast clients as RSS enclosure URLs — that's the entire purpose
|
||||
of this port. Uploads are separately gated by blossom's own BUD-02
|
||||
signed-nostr-event auth (upload.requireAuth below), not a node
|
||||
session; reads are intentionally public per the config's own header
|
||||
comment.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr-blossom/data
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr-blossom/config/config.yml
|
||||
target: /app/config.yml
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/podsteadr-blossom/config/config.yml
|
||||
overwrite: true
|
||||
content: |
|
||||
# blossom-server (v4.x) configuration for podsteadr.
|
||||
# Uploads require a signed nostr auth event (BUD-02, kind 24242);
|
||||
# reads are public so podcast apps can fetch enclosures.
|
||||
#
|
||||
# NOTE (blossom-server 4.4.1 gotcha, do not rediscover): `rules:` MUST
|
||||
# be nested under `storage:` — a top-level `rules:` key is silently
|
||||
# ignored, the ruleset ends up empty, and every upload fails 401
|
||||
# "Server dose not accept video/mp4 blobs" (typo is theirs). The
|
||||
# GitHub master branch is a Deno rewrite with a different schema
|
||||
# (storage.rules, BUD-11, range support); the `:4` image is the older
|
||||
# node/koa codebase this config targets.
|
||||
|
||||
publicDomain: ""
|
||||
|
||||
databasePath: data/sqlite.db
|
||||
|
||||
dashboard:
|
||||
enabled: false
|
||||
|
||||
discovery:
|
||||
nostr:
|
||||
enabled: false
|
||||
relays: []
|
||||
upstream:
|
||||
enabled: false
|
||||
domains: []
|
||||
|
||||
storage:
|
||||
backend: local
|
||||
local:
|
||||
dir: ./data/blobs
|
||||
removeWhenNoOwners: false
|
||||
# "expiration" is time since a blob was last accessed — unaccessed
|
||||
# blobs get pruned after this. Podcast media should effectively
|
||||
# never expire, so keep this long.
|
||||
rules:
|
||||
- type: "*"
|
||||
expiration: 10 years
|
||||
|
||||
upload:
|
||||
enabled: true
|
||||
requireAuth: true
|
||||
requirePubkeyInRule: false
|
||||
|
||||
list:
|
||||
requireAuth: false
|
||||
allowListOthers: true
|
||||
|
||||
tor:
|
||||
enabled: false
|
||||
proxy: ""
|
||||
|
||||
health_check:
|
||||
# No documented health endpoint; TCP liveness on the app port.
|
||||
type: tcp
|
||||
endpoint: localhost:3000
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,164 @@
|
||||
app:
|
||||
id: podsteadr-mediamtx
|
||||
name: podsteadr MediaMTX
|
||||
version: "1.20.0"
|
||||
description: MediaMTX ingest/output backend for podsteadr — RTMP + WebRTC/WHIP ingest, HLS playback, stream recording.
|
||||
category: media
|
||||
|
||||
# Hyphenated name matches the podsteadr repo's docker-compose container_name
|
||||
# (podsteadr-mediamtx); alias `mediamtx` is the short hostname podsteadr's
|
||||
# server reaches it by (MEDIAMTX_API_URL=http://mediamtx:9997) and the one
|
||||
# baked into mediamtx.yml's authHTTPAddress callback below.
|
||||
container_name: podsteadr-mediamtx
|
||||
|
||||
container:
|
||||
image: docker.io/bluenviron/mediamtx:1.20.0
|
||||
pull_policy: if-not-present
|
||||
network: podsteadr-net
|
||||
network_aliases: [mediamtx]
|
||||
derived_env:
|
||||
# Browsers need a reachable ICE host candidate for WebRTC/WHIP; without
|
||||
# this, the offer only advertises container-internal addresses and
|
||||
# publish/playback negotiation fails for anyone off-host.
|
||||
- key: MTX_WEBRTCADDITIONALHOSTS
|
||||
template: "{{HOST_MDNS}}"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
# Stock mediamtx image runs as container-root (no USER directive) but
|
||||
# only ever writes to the bind-mounted /recordings — CHOWN/DAC_OVERRIDE
|
||||
# cover the fresh-bind-dir-ownership gap the same way apps/botfights and
|
||||
# apps/immich document (root uid inside the container does not
|
||||
# automatically bypass DAC checks once cap-drop ALL applies). Unverified
|
||||
# against a real install; check first-boot logs on initial deploy.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 1935
|
||||
container: 1935
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
RTMP ingest (OBS). Not HTTP, so the node's session gate has no login
|
||||
page to serve here; publish auth is delegated to podsteadr's own
|
||||
HTTP auth webhook (authHTTPAddress below), which checks a per-stream
|
||||
secret key never exposed in this port mapping.
|
||||
- host: 8889
|
||||
container: 8889
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
WebRTC/WHIP ingest — browsers publish directly with a per-stream
|
||||
bearer secret checked by podsteadr's auth webhook, the same
|
||||
protocol-level auth as the RTMP port above.
|
||||
- host: 8189
|
||||
container: 8189
|
||||
protocol: udp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
WebRTC ICE/UDP media transport. Raw UDP has no HTTP session concept
|
||||
for the gate to enforce.
|
||||
- host: 8890
|
||||
container: 8888
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Public HLS playback URL, handed out to viewers and podcast/livestream
|
||||
clients outside the node (zap.stream, third-party players). A login
|
||||
page here would break every external viewer; playback is read-only.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
# Shared with apps/podsteadr (mounted read-only there) so the app can
|
||||
# list and remux finished recordings for one-click episode publishing.
|
||||
source: /var/lib/archipelago/podsteadr/recordings
|
||||
target: /recordings
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml
|
||||
target: /mediamtx.yml
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml
|
||||
overwrite: true
|
||||
content: |
|
||||
# MediaMTX configuration for podsteadr.
|
||||
# Ingest: RTMP (OBS) + WebRTC/WHIP (browser). Output: HLS. Publish auth is
|
||||
# delegated to podsteadr via HTTP; stream status is polled from the API.
|
||||
|
||||
logLevel: info
|
||||
|
||||
api: yes
|
||||
apiAddress: :9997
|
||||
|
||||
# ---- authentication ------------------------------------------------------
|
||||
authMethod: http
|
||||
authHTTPAddress: http://podsteadr-app:8095/api/mediamtx/auth
|
||||
authHTTPExclude:
|
||||
- action: api
|
||||
- action: metrics
|
||||
- action: pprof
|
||||
|
||||
# ---- protocols -----------------------------------------------------------
|
||||
rtsp: no
|
||||
srt: no
|
||||
moq: no
|
||||
|
||||
rtmp: yes
|
||||
rtmpAddress: :1935
|
||||
|
||||
hls: yes
|
||||
hlsAddress: :8888
|
||||
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
|
||||
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
|
||||
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
|
||||
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
|
||||
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
|
||||
# instead of intermittent muxer crashes / viewer buffering.
|
||||
hlsVariant: mpegts
|
||||
hlsAlwaysRemux: yes
|
||||
hlsAllowOrigins: ["*"]
|
||||
|
||||
webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcAllowOrigins: ["*"]
|
||||
|
||||
# ---- recording -----------------------------------------------------------
|
||||
pathDefaults:
|
||||
record: yes
|
||||
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
||||
recordFormat: fmp4
|
||||
recordPartDuration: 1s
|
||||
recordSegmentDuration: 1h
|
||||
recordDeleteAfter: 168h
|
||||
|
||||
paths:
|
||||
# Streams live at live/<streamId>; publish requires the stream secret,
|
||||
# which podsteadr checks in the auth webhook.
|
||||
"~^live/[A-Za-z0-9]+$": {}
|
||||
|
||||
health_check:
|
||||
# Stock mediamtx image has no shell, so an in-container HTTP probe of the
|
||||
# API isn't meaningfully cheaper than TCP; RTMP liveness is enough (same
|
||||
# polling-not-hooks rationale as podsteadr's own status poller, which
|
||||
# exists precisely because runOn*-style shell hooks aren't available on
|
||||
# this image).
|
||||
type: tcp
|
||||
endpoint: localhost:1935
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,44 @@
|
||||
# Vendored copy of the podsteadr repo's own Dockerfile (source lives outside
|
||||
# this tree — http://146.59.87.168:3000/ssmithx/podsteadr). Re-sync by hand if
|
||||
# the upstream Dockerfile changes; build with build-from-prototype.sh, which
|
||||
# passes the podsteadr repo root as build context (this Dockerfile expects
|
||||
# frontend/ and server/ subdirectories at the context root, not this apps/
|
||||
# directory).
|
||||
#
|
||||
# ---- frontend ----
|
||||
FROM node:22-bookworm-slim AS frontend-build
|
||||
WORKDIR /build/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---- server ----
|
||||
FROM node:22-bookworm-slim AS server-build
|
||||
WORKDIR /build/server
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci
|
||||
COPY server/ ./
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
# ---- runtime ----
|
||||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=server-build /build/server/node_modules ./node_modules
|
||||
COPY --from=server-build /build/server/package.json ./package.json
|
||||
COPY --from=server-build /build/server/dist ./dist
|
||||
COPY --from=frontend-build /build/frontend/dist ./public
|
||||
# Named volumes inherit ownership from the image path: keep /data writable by node
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
USER node
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8095 \
|
||||
DATA_DIR=/data \
|
||||
STATIC_DIR=/app/public
|
||||
EXPOSE 8095
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8095/api/health || exit 1
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,85 @@
|
||||
# podsteadr — Nostr-native Podcasting & Livestreaming
|
||||
|
||||
Self-hosted, nostr-native podcast publishing and livestreaming. Log in with a
|
||||
NIP-07 nostr identity (no passwords, no email), upload an mp4 to publish an
|
||||
RSS 2.0 feed with Podcasting 2.0 lightning payment info, or go live via OBS
|
||||
(RTMP) or the browser (WebRTC/WHIP) — the stream is announced on nostr as a
|
||||
NIP-53 live event and viewers watch over HLS.
|
||||
|
||||
This is a three-container stack:
|
||||
|
||||
| App | Manifest | Role |
|
||||
|---|---|---|
|
||||
| `podsteadr` | `apps/podsteadr/manifest.yml` | Fastify API + built Vue UI + RSS feeds |
|
||||
| `podsteadr-mediamtx` | `apps/podsteadr-mediamtx/manifest.yml` | RTMP/WHIP ingest, HLS output, recording |
|
||||
| `podsteadr-blossom` | `apps/podsteadr-blossom/manifest.yml` | BUD-02 sha256-addressed media blobs |
|
||||
|
||||
All three join a dedicated `podsteadr-net` bridge network and resolve each
|
||||
other by short DNS aliases (`podsteadr-app`, `mediamtx`, `blossom`).
|
||||
|
||||
## Building the Image
|
||||
|
||||
The app image is built from the **podsteadr** repo, source of truth at
|
||||
`http://146.59.87.168:3000/ssmithx/podsteadr`.
|
||||
|
||||
### Option 1: Use the build script
|
||||
|
||||
```bash
|
||||
# From archy repo root
|
||||
./apps/podsteadr/build-from-prototype.sh
|
||||
```
|
||||
|
||||
### Option 2: Build from source directory
|
||||
|
||||
```bash
|
||||
cd ~/podsteadr
|
||||
podman build -t localhost/podsteadr:1.0.0 -f ~/archy/apps/podsteadr/Dockerfile .
|
||||
```
|
||||
|
||||
### Publishing to the shared registry
|
||||
|
||||
```bash
|
||||
./apps/podsteadr/push-to-registry.sh 1.0.0
|
||||
```
|
||||
|
||||
Then update `apps/podsteadr/manifest.yml`'s `container.image` to the pushed
|
||||
tag so other nodes pull instead of building locally.
|
||||
|
||||
## Ports
|
||||
|
||||
See `apps/PORTS.md`. Summary: 8095 (web UI/API/RSS), 1935 (RTMP), 8889
|
||||
(WebRTC/WHIP), 8189/udp (WebRTC ICE), 8890 (HLS), 8098 (Blossom).
|
||||
|
||||
All of podsteadr's ports are `auth: none` — this is a public podcast/livestream
|
||||
server, not a private personal app; RSS feeds, HLS playback, and blob reads
|
||||
must stay reachable by third-party clients with no Archipelago session, and
|
||||
the app enforces its own NIP-98 signed-request auth for sensitive routes and
|
||||
per-stream secret keys for RTMP/WHIP publish. See the `auth_rationale` on each
|
||||
port mapping.
|
||||
|
||||
## Nostr Identity
|
||||
|
||||
podsteadr's frontend vendors a copy of Archipelago's `nostr-provider.js` shim
|
||||
and references it directly from `index.html` (its Fastify server isn't the
|
||||
nginx-served SPA shape the platform auto-patches — see "Nostr Signer Bridge"
|
||||
in `docs/app-developer-guide.md`). `apps/podsteadr/manifest.yml` declares a
|
||||
`post_install` hook that re-copies the canonical
|
||||
`/opt/archipelago/web-ui/nostr-provider.js` over the vendored copy on every
|
||||
install/reinstall, so it doesn't go stale across OTA releases.
|
||||
|
||||
## Data
|
||||
|
||||
- `/var/lib/archipelago/podsteadr` — SQLite DB, server's own nostr key,
|
||||
covers, and (read-only here) shared stream recordings.
|
||||
- `/var/lib/archipelago/podsteadr/recordings` — stream recordings (writable
|
||||
by `podsteadr-mediamtx`, read-only for `podsteadr`), 7-day retention.
|
||||
- `/var/lib/archipelago/podsteadr-blossom/data` — media blobs.
|
||||
|
||||
## Known gotchas
|
||||
|
||||
See the podsteadr repo's `docs/STATUS.md` for the full list (blossom v4
|
||||
config `rules:` nesting, no HTTP range support in blossom 4.x, split-horizon
|
||||
blossom URL, MediaMTX has no shell so status is polled not hooked, standard
|
||||
vs. low-latency HLS). The blossom and mediamtx config files embedded in
|
||||
`apps/podsteadr-blossom/manifest.yml` / `apps/podsteadr-mediamtx/manifest.yml`
|
||||
already carry the load-bearing ones inline as comments.
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Build the podsteadr container image from the podsteadr repo.
|
||||
# Usage: ./build-from-prototype.sh [path-to-podsteadr-repo]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_REPO="$HOME/podsteadr"
|
||||
REPO_DIR="${1:-$DEFAULT_REPO}"
|
||||
IMAGE_TAG="localhost/podsteadr:1.0.0"
|
||||
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
echo "podsteadr repo not found at: $REPO_DIR"
|
||||
echo " Set path: $0 /path/to/podsteadr"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REPO_DIR/server/package.json" ] || [ ! -f "$REPO_DIR/frontend/package.json" ]; then
|
||||
echo "No server/package.json or frontend/package.json found in $REPO_DIR — is this the right directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine container runtime
|
||||
RUNTIME="podman"
|
||||
if ! command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME="docker"
|
||||
fi
|
||||
|
||||
echo "Building podsteadr from $REPO_DIR using $SCRIPT_DIR/Dockerfile"
|
||||
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$REPO_DIR"
|
||||
|
||||
echo "Built $IMAGE_TAG"
|
||||
echo ""
|
||||
echo "You can now install podsteadr from the App Store in Archipelago."
|
||||
echo "Or run directly: $RUNTIME run -d --name podsteadr-app -p 8095:8095 $IMAGE_TAG"
|
||||
@@ -0,0 +1,143 @@
|
||||
app:
|
||||
id: podsteadr
|
||||
name: podsteadr
|
||||
version: "1.0.0"
|
||||
description: Self-hosted, nostr-native podcast publishing and livestreaming. Log in with Nostr, upload episodes or go live via OBS/WebRTC, publish to RSS with Podcasting 2.0 lightning payments.
|
||||
category: media
|
||||
|
||||
# Container/DNS-alias name deliberately NOT "podsteadr" — on a host whose own
|
||||
# hostname happens to be "podsteadr", the host's own /etc/hosts self-hostname
|
||||
# entry (127.0.1.1, e.g. from cloud-init) shadows the container network's DNS
|
||||
# alias for other containers looking up "podsteadr", and mediamtx's auth-webhook
|
||||
# callback resolves to the host's loopback instead of this container — every
|
||||
# RTMP publish gets rejected with "connection refused" (observed on
|
||||
# podsteadr.atobitcoin.io, 2026-07-30; see docker-compose.yml in the podsteadr
|
||||
# repo for the original writeup). Carried forward unchanged into the manifest.
|
||||
container_name: podsteadr-app
|
||||
|
||||
container:
|
||||
# Built locally from the podsteadr repo (source lives outside this tree —
|
||||
# see apps/podsteadr/README.md + build-from-prototype.sh), same pattern as
|
||||
# apps/indeedhub. Not yet pushed to the shared registry; push-to-registry.sh
|
||||
# is there for when fleet-wide install is needed.
|
||||
image: localhost/podsteadr:1.0.0
|
||||
pull_policy: if-not-present
|
||||
network: podsteadr-net
|
||||
network_aliases: [podsteadr-app]
|
||||
derived_env:
|
||||
- key: PUBLIC_URL
|
||||
template: "http://{{HOST_MDNS}}:8095"
|
||||
- key: MEDIAMTX_RTMP_PUBLIC
|
||||
template: "rtmp://{{HOST_MDNS}}:1935"
|
||||
- key: MEDIAMTX_WHIP_PUBLIC
|
||||
template: "http://{{HOST_MDNS}}:8889"
|
||||
- key: MEDIAMTX_HLS_PUBLIC
|
||||
template: "http://{{HOST_MDNS}}:8890"
|
||||
- key: BLOSSOM_URL_DEFAULT
|
||||
template: "http://{{HOST_MDNS}}:8098"
|
||||
# node:22-bookworm-slim's built-in `node` user is uid:gid 1000:1000. The
|
||||
# image's own Dockerfile chowns /data to node:node, but that only affects
|
||||
# the image layer — the actual runtime mount is the bind volume below, so
|
||||
# the host directory needs the same ownership or the read-only-root,
|
||||
# non-root `node` process can't open the SQLite DB (unverified against a
|
||||
# real node install; flagging per this repo's convention of documenting
|
||||
# bind-mount ownership assumptions, e.g. apps/botfights/manifest.yml).
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: podsteadr-mediamtx
|
||||
- app_id: podsteadr-blossom
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8095
|
||||
container: 8095
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
podsteadr is a public podcast/livestream server: RSS feeds and the
|
||||
marketplace/catalog API must stay fetchable by third-party podcast
|
||||
clients, crawlers, and other podsteadr instances with no Archipelago
|
||||
session, and the app already gates its own sensitive routes with
|
||||
NIP-98 signed-request auth (see server/src/plugins/nostr-auth.ts in
|
||||
the podsteadr repo). Putting the node's session gate in front would
|
||||
block every external RSS/API consumer without adding real protection.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr
|
||||
target: /data
|
||||
options: [rw]
|
||||
# Shares podsteadr-mediamtx's recordings directory (rw there, ro here) so
|
||||
# the app can list/remux finished recordings for one-click episode
|
||||
# publishing without granting it write access to live segments.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/podsteadr/recordings
|
||||
target: /recordings
|
||||
options: [ro]
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8095
|
||||
- DATA_DIR=/data
|
||||
- RECORDINGS_DIR=/recordings
|
||||
- MEDIAMTX_API_URL=http://mediamtx:9997
|
||||
- BLOSSOM_URL_INTERNAL=http://blossom:3000
|
||||
- NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||
- CASHU_MINT_URL_DEFAULT=https://mint.minibits.cash/Bitcoin
|
||||
|
||||
# podsteadr's Fastify server (fastify-static) isn't the nginx-served SPA
|
||||
# shape the platform auto-patches for NIP-07 injection (see "Nostr Signer
|
||||
# Bridge" in docs/app-developer-guide.md) — its frontend already
|
||||
# self-references /nostr-provider.js from index.html and vendors a copy at
|
||||
# build time (podsteadr commit 133558d). That vendored copy goes stale
|
||||
# across archy OTA releases, so re-copy the canonical host script over it
|
||||
# on every install/reinstall instead of trusting the baked-in one.
|
||||
hooks:
|
||||
post_install:
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: /app/public/nostr-provider.js
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8095
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Podcast dashboard, upload/live wizard, and stream management
|
||||
type: ui
|
||||
port: 8095
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: podsteadr
|
||||
icon: /assets/img/app-icons/podsteadr.png
|
||||
repo: http://146.59.87.168:3000/ssmithx/podsteadr
|
||||
license: MIT
|
||||
tags:
|
||||
- nostr
|
||||
- podcast
|
||||
- livestream
|
||||
- media
|
||||
- rss
|
||||
- lightning
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Build and push the podsteadr container image to a registry.
|
||||
# Usage: ./push-to-registry.sh [version]
|
||||
#
|
||||
# Environment variables:
|
||||
# REGISTRY - Registry host (default: 146.59.87.168:3000, same as indeedhub/botfights)
|
||||
# NAMESPACE - Registry namespace (default: lfg2025)
|
||||
# RUNTIME - Container runtime (default: podman)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="${PODSTEADR_REPO:-$HOME/podsteadr}"
|
||||
VERSION="${1:-1.0.0}"
|
||||
REGISTRY="${REGISTRY:-146.59.87.168:3000}"
|
||||
NAMESPACE="${NAMESPACE:-lfg2025}"
|
||||
IMAGE_NAME="podsteadr"
|
||||
RUNTIME="${RUNTIME:-podman}"
|
||||
|
||||
FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}"
|
||||
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
echo "podsteadr repo not found at: $REPO_DIR"
|
||||
echo "Set PODSTEADR_REPO=/path/to/podsteadr"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== podsteadr Container Registry Push ==="
|
||||
echo "Source: $REPO_DIR"
|
||||
echo "Image: $FULL_TAG"
|
||||
echo "Runtime: $RUNTIME"
|
||||
echo ""
|
||||
|
||||
echo "[1/3] Building image..."
|
||||
$RUNTIME build --platform linux/amd64 \
|
||||
-t "$FULL_TAG" \
|
||||
-t "localhost/${IMAGE_NAME}:${VERSION}" \
|
||||
-f "$SCRIPT_DIR/Dockerfile" \
|
||||
"$REPO_DIR"
|
||||
|
||||
echo "[2/3] Pushing to registry..."
|
||||
if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "Not logged in to $REGISTRY."
|
||||
echo "Run: $RUNTIME login $REGISTRY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$RUNTIME push "$FULL_TAG"
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Done!"
|
||||
echo ""
|
||||
echo "Image pushed: $FULL_TAG"
|
||||
echo ""
|
||||
echo "Update apps/podsteadr/manifest.yml's container.image to $FULL_TAG so"
|
||||
echo "nodes pull it instead of building locally."
|
||||
@@ -405,17 +405,9 @@ impl RpcHandler {
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let device_type = svc.shared_state().status.read().await.device_type;
|
||||
// Resource transfer is a native RNS transfer over LoRa — it needs an
|
||||
// actual radio route to this contact, not just a Reticulum device on
|
||||
// our end. A federation-only peer with no radio twin fits the size
|
||||
// and device-type checks but has no dest_prefix to send to; without
|
||||
// this check the send falls into send_content_resource and fails
|
||||
// with "Peer is federation-only (no radio twin)" (picture-send,
|
||||
// 2026-08-07) instead of falling back to the federation path below.
|
||||
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
|
||||
&& device_type == crate::mesh::types::DeviceType::Reticulum
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX
|
||||
&& svc.has_radio_route(contact_id).await;
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
|
||||
|
||||
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
|
||||
anyhow::bail!(
|
||||
@@ -500,58 +492,15 @@ impl RpcHandler {
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// Federation-only peers have no radio twin for
|
||||
// send_typed_wire's LoRa dest-prefix resolution — route over
|
||||
// Tor federation instead, mirroring mesh.send-content's onion
|
||||
// lookup, or the send fails with "Peer is federation-only (no
|
||||
// radio twin)" (picture-send from a federation-only contact,
|
||||
// 2026-08-07).
|
||||
let federation_onion = {
|
||||
let state = svc.shared_state();
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
|
||||
};
|
||||
let federation_onion = match federation_onion {
|
||||
Some((Some(pubkey_hex), did)) => {
|
||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
nodes
|
||||
.iter()
|
||||
.find(|n| n.pubkey == pubkey_hex)
|
||||
.map(|n| n.onion.clone())
|
||||
.or_else(|| {
|
||||
did.as_ref().and_then(|d| {
|
||||
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(onion) = federation_onion {
|
||||
svc.send_typed_wire_via_federation(
|
||||
contact_id,
|
||||
&onion,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -641,16 +590,6 @@ impl RpcHandler {
|
||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
|
||||
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
|
||||
// A Reticulum device on our end doesn't mean THIS peer is radio
|
||||
// reachable — a federation-only contact (no radio twin) has no dest
|
||||
// prefix for a resource transfer, even though it's small enough and
|
||||
// our device type qualifies. Without this check the frontend was
|
||||
// steered into mesh.send-content-inline's resource-transfer path,
|
||||
// which fails with "Peer is federation-only (no radio twin)"
|
||||
// (picture-send, 2026-08-07); the tier below now defers to the
|
||||
// has_tor branches for such peers, which route via mesh.send-content
|
||||
// (federation) instead.
|
||||
let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await;
|
||||
let (tier, reason) = if size <= MESH_AUTO_MAX {
|
||||
("auto-mesh", "Small enough to send inline over mesh")
|
||||
} else if size <= MESH_HARD_MAX {
|
||||
@@ -659,7 +598,7 @@ impl RpcHandler {
|
||||
} else {
|
||||
("auto-mesh", "No Tor path — sending inline over mesh")
|
||||
}
|
||||
} else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
|
||||
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
|
||||
(
|
||||
"resource-mesh",
|
||||
"Sending directly over LoRa via a Reticulum resource transfer",
|
||||
|
||||
@@ -6,7 +6,41 @@
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
|
||||
18081, 18083, 23000, 32838, 50002,
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8088,
|
||||
8089,
|
||||
8090,
|
||||
8095,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8240,
|
||||
8334,
|
||||
8336,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
|
||||
@@ -1206,19 +1206,6 @@ impl MeshService {
|
||||
Ok(dest_prefix)
|
||||
}
|
||||
|
||||
/// True if `contact_id` is reachable over the mesh radio right now — the
|
||||
/// same peer/twin resolution `peer_dest_prefix` performs, exposed as a
|
||||
/// cheap bool so RPC handlers can gate radio-only transports (LXMF
|
||||
/// native image, Reticulum resource transfer) without duplicating the
|
||||
/// twin-resolution logic. A federation-only contact_id with no matching
|
||||
/// radio twin returns false here — offering "resource-mesh" or native
|
||||
/// image to such a peer sends it straight into `peer_dest_prefix`'s
|
||||
/// "federation-only (no radio twin)" error (picture-send from a
|
||||
/// federation-only contact, 2026-08-07).
|
||||
pub async fn has_radio_route(&self, contact_id: u32) -> bool {
|
||||
self.peer_dest_prefix(contact_id).await.is_ok()
|
||||
}
|
||||
|
||||
/// Split an oversized wire payload into MC-framed base64 chunks and send
|
||||
/// each via the mesh device. Matches the receive-side reassembly in
|
||||
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
|
||||
|
||||
@@ -1717,17 +1717,25 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
||||
// deliberate, rationale-carrying exemption; the release-gate test
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
// 31 as of the podsteadr app-package round: the prior 25 (bitcoin p2p
|
||||
// (8333 ×2), core-lightning 9736/9835, electrumx 50001, fedimint
|
||||
// 8173/8174, fedimint-gateway 8176/9737, gitea ssh 2222,
|
||||
// lightning-stack 8091/9738/10010, lnd 9735/10009/18080, netbird
|
||||
// 3478/8086/8087, pine TLS 10381 + the three voice ports
|
||||
// (10200/10300/10400 — the disclosed known gap), router SSDP/mDNS
|
||||
// 1900/5353) plus 6 new ones: podsteadr 8095 (web UI/API/RSS —
|
||||
// third-party podcast clients and other podsteadr instances must
|
||||
// fetch feeds/marketplace data with no node session; the app gates
|
||||
// its own sensitive routes with NIP-98), podsteadr-blossom 8098
|
||||
// (public blob reads for RSS enclosures; uploads are BUD-02
|
||||
// signed-auth gated by blossom itself), podsteadr-mediamtx
|
||||
// 1935/8189/8889/8890 (RTMP/ICE/WHIP ingest + HLS playback — none of
|
||||
// these are HTTP-session-shaped, and publish is protocol-gated by a
|
||||
// per-stream secret checked via podsteadr's own auth webhook). Every
|
||||
// one is a deliberate, rationale-carrying exemption.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
25,
|
||||
31,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"nostr-rs-relay": 18081,
|
||||
"photoprism": 2342,
|
||||
"pine": 10380,
|
||||
"podsteadr": 8095,
|
||||
"portainer": 9000,
|
||||
"router": 8084,
|
||||
"searxng": 8888,
|
||||
@@ -87,6 +88,9 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
|
||||
"pine-piper": "Pine Piper (TTS)",
|
||||
"pine-whisper": "Pine Whisper (STT)",
|
||||
"podsteadr": "podsteadr",
|
||||
"podsteadr-blossom": "podsteadr Blossom",
|
||||
"podsteadr-mediamtx": "podsteadr MediaMTX",
|
||||
"portainer": "Portainer",
|
||||
"router": "Mesh Router",
|
||||
"searxng": "SearXNG",
|
||||
|
||||
Reference in New Issue
Block a user