Compare commits

..
Author SHA1 Message Date
ssmithx 4bbd70281b chore(apps): bump podsteadr-mediamtx to 1.20.0
Released 2026-08-05. Fixes land directly on this app's feature surface:
HLS muxer recomputes AAC PTS in MPEG-TS segments (iOS playback precision,
relevant since the app's config sets hlsVariant: mpegts), fixes a
goroutine leak during HLS part rotation, fixes a Chrome WebRTC "packet
lost" false-positive and non-deterministic WebRTC track ordering (WHIP
ingest), and fixes OBS multitrack RTMP URL parsing. No breaking config
changes; new opt-in features (native forwarding, MoQ draft support)
don't affect this config (moq: no already). Matches the same bump on
the podsteadr repo's docker-compose.yml.
2026-08-08 00:18:02 +00:00
ssmithx 395e26b524 feat(apps): package podsteadr as a full Archipelago app (3-container manifest set)
Adds apps/podsteadr (main Fastify+Vue app, container.build from the podsteadr
repo), apps/podsteadr-mediamtx (RTMP/WHIP ingest, HLS, recording), and
apps/podsteadr-blossom (BUD-02 media blobs), wired together on a dedicated
podsteadr-net bridge network per the multi-container pattern documented in
docs/app-developer-guide.md (indeedhub's api/relay/minio/redis/postgres
siblings). All podsteadr ports are auth: none with a rationale, since it's a
public podcast/livestream server whose RSS feeds, HLS playback, and blob
reads must stay reachable by third-party clients with no Archipelago session
— the app already gates its own sensitive routes with NIP-98 and per-stream
secret keys.

Also updates apps/PORTS.md, apps/README.md, and bumps the reviewed
unauthenticated-port count in core/container/src/manifest.rs's
unauthenticated_ports_are_all_accounted_for test (25 -> 31) to acknowledge
the six new auth:none ports. Regenerated catalog-derived files
(core/archipelago/src/fips/app_ports.rs,
neode-ui/src/views/appSession/generatedAppSessionConfig.ts) via
scripts/generate-app-catalog.py.

All three manifests pass scripts/validate-app-manifest.sh and
`cargo test -p archipelago-container manifest`.
2026-08-08 00:18:02 +00:00
14 changed files with 724 additions and 97 deletions
+1
View File
@@ -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)
+1
View File
@@ -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)
+125
View File
@@ -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
+164
View File
@@ -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
+44
View File
@@ -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"]
+85
View File
@@ -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.
+35
View File
@@ -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"
+143
View File
@@ -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
+57
View File
@@ -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",
+37 -3
View File
@@ -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,
];
-13
View File
@@ -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`,
+17 -9
View File
@@ -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",