Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab106264e0 | ||
|
|
7f52e0e891 | ||
|
|
04ba5457fa | ||
|
|
a26a77dd0a | ||
|
|
9377a94cba | ||
|
|
5770ecae49 | ||
|
|
2065446393 | ||
|
|
786e06a7a3 | ||
|
|
172f31d7c5 | ||
|
|
44e41b3dd8 | ||
|
|
f9d23ad267 | ||
|
|
582bc407be | ||
|
|
dbb41f2fd3 | ||
|
|
737e7c9bb1 | ||
|
|
c4b4cb92c9 | ||
|
|
4dff9cbab6 |
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
context: ./drb-edge-node
|
||||
file: ./drb-edge-node/Dockerfile
|
||||
platforms: linux/arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
context: ./icecast
|
||||
file: ./icecast/Dockerfile
|
||||
platforms: linux/arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
context: ./op25-container
|
||||
file: ./op25-container/Dockerfile
|
||||
platforms: linux/arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
context: ./secondary-sdr-container
|
||||
file: ./secondary-sdr-container/Dockerfile
|
||||
platforms: linux/arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:${{ steps.meta.outputs.VERSION }}
|
||||
|
||||
@@ -67,12 +67,16 @@ jobs:
|
||||
TAG="src-$(git rev-parse HEAD:${{ matrix.context }})"
|
||||
echo "IMAGE=$IMAGE" >> $GITHUB_OUTPUT
|
||||
echo "TAG=$TAG" >> $GITHUB_OUTPUT
|
||||
if docker buildx imagetools inspect "$IMAGE:$TAG" >/dev/null 2>&1; then
|
||||
# Present only counts if BOTH platforms are in it: src- tags pushed
|
||||
# before node-26#28 are arm64-only, and an amd64 node can't pull them.
|
||||
RAW="$(docker buildx imagetools inspect --raw "$IMAGE:$TAG" 2>/dev/null | tr -d ' \n' || true)"
|
||||
if printf '%s' "$RAW" | grep -q '"architecture":"amd64"' \
|
||||
&& printf '%s' "$RAW" | grep -q '"architecture":"arm64"'; then
|
||||
echo "EXISTS=true" >> $GITHUB_OUTPUT
|
||||
echo "$IMAGE:$TAG already published — nothing to do"
|
||||
echo "$IMAGE:$TAG already published for amd64 + arm64 — nothing to do"
|
||||
else
|
||||
echo "EXISTS=false" >> $GITHUB_OUTPUT
|
||||
echo "$IMAGE:$TAG missing — building it"
|
||||
echo "$IMAGE:$TAG missing or not multi-arch — building it"
|
||||
fi
|
||||
|
||||
- if: steps.meta.outputs.EXISTS != 'true'
|
||||
@@ -80,8 +84,8 @@ jobs:
|
||||
with:
|
||||
context: ./${{ matrix.context }}
|
||||
file: ./${{ matrix.context }}/Dockerfile
|
||||
# arm64 only, same as the build-*.yml workflows (node-26#28).
|
||||
platforms: linux/arm64
|
||||
# amd64 + arm64, same as the build-*.yml workflows (node-26#28).
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.IMAGE }}:${{ steps.meta.outputs.TAG }}
|
||||
cache-from: type=registry,ref=${{ steps.meta.outputs.IMAGE }}:buildcache
|
||||
|
||||
@@ -142,13 +142,17 @@ this repo at the `v1` tag, enrols with C2, pulls the prebuilt images and starts:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \
|
||||
| sudo bash -s -- --token DRB-xxxx --node-id node-003 \
|
||||
| sudo bash -s -- --node-id node-003 \
|
||||
--c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
|
||||
```
|
||||
|
||||
Mint the `--token` at **Settings → Nodes** in the web app (the panel prints the
|
||||
whole command). Run `install.sh --help` for every flag; each also has a
|
||||
`DRB_*` env var. `--build` compiles op25 on the Pi (~1h) instead of pulling.
|
||||
It prompts (hidden input) for an enrollment token, minted at **Settings → Nodes**
|
||||
in the web app. The token never goes on the command line — argv lands in shell
|
||||
history and `ps` (node-26#18). Unattended installs use `--token-file PATH` (mode
|
||||
0600) or an exported `DRB_ENROLLMENT_TOKEN` (pass it through with
|
||||
`sudo --preserve-env=DRB_ENROLLMENT_TOKEN`; plain `sudo` drops it); the old `--token` flag still works
|
||||
but warns. Run `install.sh --help` for every flag; each also has a `DRB_*` env
|
||||
var. `--build` compiles op25 on the Pi (~1h) instead of pulling.
|
||||
|
||||
### Local dev / manual
|
||||
|
||||
|
||||
@@ -74,6 +74,43 @@ class _Voice:
|
||||
code: Optional[str] = None
|
||||
|
||||
|
||||
def _join_refusal(guild, channel) -> Optional[tuple[str, str]]:
|
||||
"""Why Discord would refuse this bot's voice join, or None.
|
||||
|
||||
Discord does not answer a join the bot lacks permission for — the gateway
|
||||
just never sends the voice-server update, discord.py times out after 30s,
|
||||
and the admin page used to read "(TimeoutError: )". Checking first turns
|
||||
that into an instruction. Best effort: if the bot's own member or the
|
||||
permission data is not cached, let the join go ahead.
|
||||
"""
|
||||
me = getattr(guild, "me", None)
|
||||
permissions_for = getattr(channel, "permissions_for", None)
|
||||
if me is None or permissions_for is None:
|
||||
return None
|
||||
try:
|
||||
perms = permissions_for(me)
|
||||
except Exception:
|
||||
return None
|
||||
missing = [label for attr, label in (("view_channel", "View Channel"), ("connect", "Connect"))
|
||||
if not getattr(perms, attr, True)]
|
||||
if getattr(channel, "type", None) == discord.ChannelType.voice and not getattr(perms, "speak", True):
|
||||
missing.append("Speak")
|
||||
if missing:
|
||||
return ("missing_permissions",
|
||||
f"The radio bot lacks {', '.join(missing)} on that voice channel — "
|
||||
"allow it in the channel's permission settings in Discord.")
|
||||
limit = getattr(channel, "user_limit", 0) or 0
|
||||
# The bot's own lingering voice state (a dropped connection) is not a
|
||||
# slot it needs; counting it would block the rejoin of a full channel.
|
||||
me_id = getattr(me, "id", None)
|
||||
members = [m for m in (getattr(channel, "members", None) or [])
|
||||
if me_id is None or getattr(m, "id", None) != me_id]
|
||||
if limit and len(members) >= limit and not getattr(perms, "move_members", False):
|
||||
return ("channel_full", f"That voice channel is full ({len(members)}/{limit}) — "
|
||||
"raise its user limit or free a slot.")
|
||||
return None
|
||||
|
||||
|
||||
def _default_client_factory():
|
||||
intents = discord.Intents.none()
|
||||
intents.guilds = True
|
||||
@@ -220,6 +257,10 @@ class RadioManager:
|
||||
v.error = self._session_errors.get(d["token_id"], "The radio bot could not log in to Discord.")
|
||||
continue
|
||||
await self._ensure_voice(sess, v, d)
|
||||
# 4) presence follows voice: "Listening to <system>" only while a
|
||||
# voice connection for that bot is actually up.
|
||||
for sess in list(self._sessions.values()):
|
||||
await self._sync_presence(sess, target)
|
||||
|
||||
async def _ensure_session(self, token_id: str) -> Optional[_Session]:
|
||||
sess = self._sessions.get(token_id)
|
||||
@@ -275,7 +316,12 @@ class RadioManager:
|
||||
if not vc.is_playing():
|
||||
self._play(v)
|
||||
v.state, v.error, v.code = "connected", None, None
|
||||
await self._set_presence(sess, d.get("system_name"))
|
||||
return
|
||||
refusal = _join_refusal(guild, channel)
|
||||
if refusal:
|
||||
if v.error != refusal[1]: # once per change, not every watchdog pass
|
||||
logger.error(f"Stream {v.stream_id}: {refusal[1]}")
|
||||
v.state, v.code, v.error = "error", refusal[0], refusal[1]
|
||||
return
|
||||
v.state = "connecting"
|
||||
try:
|
||||
@@ -287,11 +333,23 @@ class RadioManager:
|
||||
if existing is not None:
|
||||
await existing.disconnect(force=True)
|
||||
v.vc = await channel.connect()
|
||||
except asyncio.TimeoutError:
|
||||
# discord.py's TimeoutError has no message, so the old text read
|
||||
# "(TimeoutError: )" — useless on the admin page.
|
||||
v.vc = None
|
||||
v.state, v.code = "error", "connect_timeout"
|
||||
# Permissions were checked above, so this is usually the voice
|
||||
# handshake itself (e.g. a pre-2.7 discord.py refused with 4017 for
|
||||
# lacking DAVE E2EE, then retried until the 30s timeout) or UDP egress.
|
||||
v.error = ("Discord never completed the voice join (timed out). Permissions look fine, so "
|
||||
"check the node's discord.py voice log (DAVE close 4017, UDP blocked).")
|
||||
logger.error(f"Stream {v.stream_id}: voice connect timed out")
|
||||
return
|
||||
except Exception as e:
|
||||
v.vc = None
|
||||
v.state, v.code = "error", "connect_failed"
|
||||
v.error = f"Could not connect to the voice channel ({type(e).__name__}: {str(e)[:120]})."
|
||||
logger.error(f"Stream {v.stream_id}: voice connect failed: {e}")
|
||||
logger.error(f"Stream {v.stream_id}: voice connect failed: {type(e).__name__}: {e}")
|
||||
return
|
||||
# Bounded wait for the shared PulseAudio socket (op25 container creates it).
|
||||
if not await pulse.wait_until_ready():
|
||||
@@ -300,15 +358,27 @@ class RadioManager:
|
||||
return
|
||||
self._play(v, pulse_checked=True)
|
||||
v.state, v.error, v.code = "connected", None, None
|
||||
await self._set_presence(sess, d.get("system_name"))
|
||||
logger.info(f"Stream {v.stream_id} connected (bot {v.token_id})")
|
||||
|
||||
async def _set_presence(self, sess: _Session, name: Optional[str]) -> None:
|
||||
if not name or sess.presence == name:
|
||||
async def _sync_presence(self, sess: _Session, target: dict[str, dict]) -> None:
|
||||
"""Show "Listening to <system>" only while this bot is really in voice.
|
||||
|
||||
It used to be set once on the first successful join and never cleared,
|
||||
so a bot whose voice later dropped (or kept failing to rejoin) still
|
||||
looked healthy in Discord's member list.
|
||||
"""
|
||||
name = None
|
||||
for sid, v in sorted(self._voices.items()):
|
||||
if (v.token_id == sess.token_id and v.state == "connected"
|
||||
and v.vc is not None and v.vc.is_connected()):
|
||||
name = (target.get(sid) or {}).get("system_name") or name
|
||||
if name:
|
||||
break
|
||||
if sess.presence == name:
|
||||
return
|
||||
try:
|
||||
await sess.client.change_presence(
|
||||
activity=discord.Activity(type=discord.ActivityType.listening, name=name))
|
||||
activity = discord.Activity(type=discord.ActivityType.listening, name=name) if name else None
|
||||
await sess.client.change_presence(activity=activity)
|
||||
sess.presence = name
|
||||
except Exception as e:
|
||||
logger.warning(f"Presence update failed: {e}")
|
||||
|
||||
@@ -50,7 +50,7 @@ async def fetch_api_key() -> Optional[str]:
|
||||
if r.status_code == 401:
|
||||
logger.error(
|
||||
"Key pickup: C2 rejected the saved pickup secret (401) — it was rotated by a re-enroll elsewhere. "
|
||||
"Re-run install.sh with --token to recover."
|
||||
"Re-run install.sh and give it a fresh enrollment token when it asks (node-26#18)."
|
||||
)
|
||||
return None
|
||||
if r.status_code == 404:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from collections import deque
|
||||
from typing import Optional, Callable, Awaitable, Dict, Any
|
||||
@@ -8,6 +9,22 @@ from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import credentials, key_pickup
|
||||
|
||||
|
||||
def disk_report(path: str) -> Dict[str, Optional[int]]:
|
||||
"""Free/total bytes of the filesystem holding `path` (node-26#41).
|
||||
|
||||
/recordings is a bind mount of the host checkout, so this is the host disk
|
||||
the recorder writes to and Docker's images/build cache live on. A full disk
|
||||
stopped node-002 recording for 17 days while it still showed Online. Nulls
|
||||
on error, so C2 overwrites a stale reading instead of keeping it.
|
||||
"""
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
except OSError:
|
||||
return {"disk_free_bytes": None, "disk_total_bytes": None}
|
||||
return {"disk_free_bytes": usage.free, "disk_total_bytes": usage.total}
|
||||
|
||||
|
||||
CommandCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
ConfigCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
ApiKeyCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
@@ -372,6 +389,7 @@ class MQTTManager:
|
||||
count = devices.get("count") or None
|
||||
if count is not None:
|
||||
payload["sdr_count"] = count
|
||||
payload.update(disk_report(settings.recordings_path))
|
||||
self._publish(self._t_checkin, payload, qos=1)
|
||||
|
||||
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
|
||||
|
||||
@@ -3,7 +3,9 @@ uvicorn[standard]
|
||||
pydantic-settings
|
||||
paho-mqtt>=2.0.0
|
||||
httpx
|
||||
discord.py[voice]
|
||||
# >=2.7: Discord requires DAVE (E2EE) voice since 2026-03-01; 2.7 is the first
|
||||
# discord.py with it (the [voice] extra pulls `davey`). Older releases cannot join voice.
|
||||
discord.py[voice]>=2.7
|
||||
PyNaCl
|
||||
python-multipart
|
||||
pytest
|
||||
|
||||
@@ -47,8 +47,18 @@ class FakeVC:
|
||||
class FakeChannel:
|
||||
def __init__(self, guild, cid, kind=discord.ChannelType.voice):
|
||||
self.guild, self.id, self.type = guild, cid, kind
|
||||
self.perms = discord.Permissions(view_channel=True, connect=True, speak=True)
|
||||
self.user_limit = 0
|
||||
self.members = []
|
||||
self.connect_error = None
|
||||
|
||||
def permissions_for(self, member):
|
||||
assert member is self.guild.me
|
||||
return self.perms
|
||||
|
||||
async def connect(self):
|
||||
if self.connect_error:
|
||||
raise self.connect_error
|
||||
vc = FakeVC(self)
|
||||
self.guild.voice_client = vc
|
||||
return vc
|
||||
@@ -57,6 +67,7 @@ class FakeChannel:
|
||||
class FakeGuild:
|
||||
def __init__(self, gid, channel_ids):
|
||||
self.id = gid
|
||||
self.me = object()
|
||||
self.voice_client = None
|
||||
self.channels = {c: FakeChannel(self, c) for c in channel_ids}
|
||||
|
||||
@@ -149,6 +160,7 @@ async def test_apply_connects_and_reports(env):
|
||||
assert env.mgr.is_connected
|
||||
vc = env.guilds[1].voice_client
|
||||
assert vc.plays == 1 and vc.channel.id == 11
|
||||
assert FakeClient.instances[0].presence.name == "County"
|
||||
|
||||
|
||||
async def test_one_session_serves_two_guilds(env):
|
||||
@@ -258,3 +270,64 @@ async def test_no_message_content_intent_on_real_client():
|
||||
assert client.intents.voice_states and client.intents.guilds
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def _presence(client):
|
||||
activity = getattr(client, "presence", None)
|
||||
return activity.name if activity else None
|
||||
|
||||
|
||||
async def test_missing_connect_permission_is_named_not_a_timeout(env):
|
||||
"""Discord ignores a join the bot may not make; say which permission is missing."""
|
||||
env.guilds[1].channels[11].perms = discord.Permissions(view_channel=True, speak=True)
|
||||
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
|
||||
st = env.published[-1]["streams"]["1:11"]
|
||||
assert st["state"] == "error" and st["code"] == "missing_permissions"
|
||||
assert "Connect" in st["error"] and "View Channel" not in st["error"]
|
||||
assert env.guilds[1].voice_client is None
|
||||
assert _presence(FakeClient.instances[0]) is None # not "Listening to …" while out of voice
|
||||
|
||||
|
||||
async def test_full_channel_is_reported(env):
|
||||
ch = env.guilds[1].channels[11]
|
||||
ch.user_limit, ch.members = 2, [object(), object()]
|
||||
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
|
||||
st = env.published[-1]["streams"]["1:11"]
|
||||
assert st["code"] == "channel_full" and "2/2" in st["error"]
|
||||
|
||||
|
||||
async def test_bots_own_lingering_voice_state_does_not_fill_the_channel(env):
|
||||
"""A dropped connection can leave the bot listed in the channel; that is
|
||||
not a slot it needs to rejoin."""
|
||||
guild = env.guilds[1]
|
||||
guild.me = type("Me", (), {"id": 999})()
|
||||
ch = guild.channels[11]
|
||||
ch.user_limit, ch.members = 2, [object(), guild.me]
|
||||
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
|
||||
assert env.published[-1]["streams"]["1:11"]["state"] == "connected"
|
||||
|
||||
|
||||
async def test_connect_timeout_has_a_readable_error(env):
|
||||
env.guilds[1].channels[11].connect_error = asyncio.TimeoutError()
|
||||
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
|
||||
st = env.published[-1]["streams"]["1:11"]
|
||||
assert st["state"] == "error" and st["code"] == "connect_timeout"
|
||||
assert "timed out" in st["error"] and "TimeoutError: )" not in st["error"]
|
||||
|
||||
|
||||
async def test_presence_is_cleared_when_voice_is_lost_and_rejoin_fails(env):
|
||||
"""The owner saw "Listening to <system>" on a bot that was not in voice:
|
||||
presence was set once on join and never cleared."""
|
||||
await env.mgr.apply_desired(_desired(1, (1, 11, "t1")))
|
||||
client = FakeClient.instances[0]
|
||||
assert _presence(client) == "County"
|
||||
env.guilds[1].voice_client.connected = False
|
||||
env.guilds[1].voice_client = None
|
||||
env.guilds[1].channels[11].connect_error = RuntimeError("voice handshake failed")
|
||||
await env.mgr.tick()
|
||||
st = env.mgr.snapshot()["streams"]["1:11"]
|
||||
assert st["state"] == "error" and st["code"] == "connect_failed"
|
||||
assert _presence(client) is None
|
||||
env.guilds[1].channels[11].connect_error = None
|
||||
await env.mgr.tick()
|
||||
assert _presence(client) == "County"
|
||||
|
||||
@@ -151,6 +151,17 @@ def test_mqtt_sentinel_triggers_update_and_is_consumed(env):
|
||||
assert "evil" not in calls and "rm -rf" not in calls
|
||||
|
||||
|
||||
def test_update_prunes_build_cache_and_stale_images_after_up(env):
|
||||
# node-26#41: the build cache was never pruned and filled node-002's disk.
|
||||
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "2026-10-04T12:00:00+00:00"}))
|
||||
assert _run(env).returncode == 0
|
||||
lines = _calls(env).splitlines()
|
||||
up = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose up -d"))
|
||||
builder = next(i for i, ln in enumerate(lines) if ln.startswith("docker builder prune -af --filter until="))
|
||||
images = next(i for i, ln in enumerate(lines) if ln.startswith("docker image prune -af --filter until="))
|
||||
assert up < builder and up < images # only after the new containers are running
|
||||
|
||||
|
||||
def test_c2_response_is_never_used_as_a_ref_or_command(env):
|
||||
(env["stub"] / "response").write_text(json.dumps({"update_requested_at": "$(touch /tmp/pwned); main"}))
|
||||
r = _run(env)
|
||||
|
||||
@@ -140,3 +140,16 @@ def test_build_client_does_not_touch_offline_buffer(monkeypatch):
|
||||
|
||||
assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})]
|
||||
mqtt_manager._offline_buffer.clear()
|
||||
|
||||
|
||||
def test_disk_report_reads_free_and_total(tmp_path):
|
||||
from app.internal.mqtt_manager import disk_report
|
||||
rep = disk_report(str(tmp_path))
|
||||
assert rep["disk_total_bytes"] > 0
|
||||
assert 0 <= rep["disk_free_bytes"] <= rep["disk_total_bytes"]
|
||||
|
||||
|
||||
def test_disk_report_nulls_when_path_unreadable(tmp_path):
|
||||
# node-26#41: nulls (not omitted keys) so C2 never keeps a stale reading.
|
||||
from app.internal.mqtt_manager import disk_report
|
||||
assert disk_report(str(tmp_path / "missing")) == {"disk_free_bytes": None, "disk_total_bytes": None}
|
||||
|
||||
+58
-22
@@ -1,11 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# DRB edge node — one-shot bootstrap for a clean Raspberry Pi OS (arm64).
|
||||
# DRB edge node — one-shot bootstrap for a clean Raspberry Pi OS (arm64) or Debian (amd64).
|
||||
#
|
||||
# curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \
|
||||
# | sudo bash -s -- --token DRB-xxxx --node-id node-003 \
|
||||
# | sudo bash -s -- --node-id node-003 \
|
||||
# --c2-url https://api.<domain> --mqtt-broker mqtt.<domain>
|
||||
#
|
||||
# It prompts (hidden input) for the enrollment token. The token is never
|
||||
# taken from the command line by design (node-26#18, CISO condition from
|
||||
# server-26#61): argv lands in shell history and in `ps`. Unattended installs
|
||||
# use --token-file PATH (a 0600 file) or an exported DRB_ENROLLMENT_TOKEN.
|
||||
#
|
||||
# (fetch install.sh from the same tag it installs; use raw/branch/main with
|
||||
# --track-main for the owner's own always-latest nodes)
|
||||
#
|
||||
@@ -81,6 +86,7 @@ MQTT_BROKER="${DRB_MQTT_BROKER:-}"
|
||||
MQTT_PORT="${DRB_MQTT_PORT:-8883}"
|
||||
MQTT_TLS="${DRB_MQTT_TLS:-true}"
|
||||
ENROLLMENT_TOKEN="${DRB_ENROLLMENT_TOKEN:-}"
|
||||
unset DRB_ENROLLMENT_TOKEN # read once; don't leak it into every child's env
|
||||
DASHBOARD_USER="${DRB_DASHBOARD_USERNAME:-admin}"
|
||||
DASHBOARD_PASS="${DRB_DASHBOARD_PASSWORD:-}"
|
||||
REGISTRY="${DRB_IMAGE_REGISTRY:-git.vpn.cusano.net}" # D1 (public host)
|
||||
@@ -111,7 +117,10 @@ usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: install.sh [options]
|
||||
|
||||
--token TOKEN Enrollment token (Settings -> Nodes -> New token)
|
||||
(enrollment token) Prompted for on the terminal, input hidden. Mint one at
|
||||
Settings -> Nodes -> New token.
|
||||
--token-file PATH read the enrollment token from PATH (unattended installs;
|
||||
keep it mode 0600 and delete it afterwards)
|
||||
--node-id ID Unique node id, e.g. node-003
|
||||
--name NAME Display name (default: node id)
|
||||
--lat N --lon N Decimal degrees for the map
|
||||
@@ -135,13 +144,19 @@ Usage: install.sh [options]
|
||||
|
||||
Every option also has an env var: DRB_NODE_ID, DRB_C2_URL, DRB_ENROLLMENT_TOKEN,
|
||||
DRB_MQTT_BROKER, DRB_REF, DRB_INSTALL_DIR, DRB_REGISTRY_USER/PASS, ...
|
||||
Secrets are read from the environment or prompted on the TTY, never from a pipe.
|
||||
Secrets are read from a file, the environment or a TTY prompt, never from a
|
||||
pipe. Never put the token on a command line: `--token X` and
|
||||
`sudo DRB_ENROLLMENT_TOKEN=X bash` both expose it in shell history and `ps`.
|
||||
An exported DRB_ENROLLMENT_TOKEN only reaches the installer through sudo with
|
||||
`sudo --preserve-env=DRB_ENROLLMENT_TOKEN bash` (plain sudo drops it).
|
||||
(`--token` still works for old copied commands but is deprecated, see node-26#18.)
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--token) ENROLLMENT_TOKEN="$2"; shift 2 ;;
|
||||
--token) ENROLLMENT_TOKEN="$2"; TOKEN_ON_ARGV=1; shift 2 ;;
|
||||
--token-file) TOKEN_FILE="$2"; shift 2 ;;
|
||||
--node-id) NODE_ID="$2"; shift 2 ;;
|
||||
--name) NODE_NAME="$2"; shift 2 ;;
|
||||
--lat) NODE_LAT="$2"; shift 2 ;;
|
||||
@@ -165,6 +180,18 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${TOKEN_ON_ARGV:-0}" = 1 ]; then
|
||||
warn "--token is deprecated: the token is now in your shell history and visible in"
|
||||
warn "\`ps\` for this whole install (node-26#18). Omit it to be prompted, or use --token-file."
|
||||
fi
|
||||
if [ -n "${TOKEN_FILE:-}" ]; then
|
||||
[ -r "$TOKEN_FILE" ] || die "--token-file: cannot read $TOKEN_FILE"
|
||||
_mode="$(stat -c %a "$TOKEN_FILE" 2>/dev/null || echo 600)"
|
||||
(( 8#$_mode & 8#044 )) && warn "--token-file $TOKEN_FILE is readable by other users — chmod 600 it"
|
||||
ENROLLMENT_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
|
||||
[ -n "$ENROLLMENT_TOKEN" ] || die "--token-file: $TOKEN_FILE is empty"
|
||||
fi
|
||||
|
||||
[ "$ENROLL_WAIT" = forever ] || [[ "$ENROLL_WAIT" =~ ^[0-9]+$ ]] \
|
||||
|| die "--wait-approval takes a number of seconds (or omit it to wait until approved)"
|
||||
|
||||
@@ -184,6 +211,14 @@ ask_secret() {
|
||||
read -rsp "$__p: " __r </dev/tty; echo >/dev/tty
|
||||
printf -v "$__v" '%s' "$__r"
|
||||
}
|
||||
# Make sure ENROLLMENT_TOKEN is set: already given, else prompt on the TTY.
|
||||
# Returns 1 if there is still no token (non-interactive, or Enter at the prompt).
|
||||
need_token() {
|
||||
[ -n "$ENROLLMENT_TOKEN" ] || ask_secret ENROLLMENT_TOKEN "Enrollment token (input hidden)"
|
||||
[ -n "$ENROLLMENT_TOKEN" ]
|
||||
}
|
||||
NO_TOKEN_HELP="mint one at Settings -> Nodes and re-run the install command; it prompts for
|
||||
the token (unattended: --token-file PATH, or an exported DRB_ENROLLMENT_TOKEN)"
|
||||
genpw() { head -c 24 /dev/urandom | base64 | tr -d '\n=' ; }
|
||||
|
||||
# ── 1. Preflight ────────────────────────────────────────────────────────────
|
||||
@@ -193,8 +228,8 @@ command -v apt-get >/dev/null || die "apt-get not found — this script targets
|
||||
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
case "$ARCH" in
|
||||
arm64|aarch64) ok "arch $ARCH" ;;
|
||||
*) warn "arch is $ARCH — CI only builds linux/arm64 images. Prebuilt pull will fail; use --build." ;;
|
||||
arm64|aarch64|amd64|x86_64) ok "arch $ARCH" ;;
|
||||
*) warn "arch is $ARCH — CI only builds linux/amd64 + linux/arm64 images. Prebuilt pull will fail; use --build." ;;
|
||||
esac
|
||||
ok "running as root"
|
||||
|
||||
@@ -433,8 +468,8 @@ wait_for_key() { # wait_for_key PICKUP_SECRET
|
||||
while :; do
|
||||
case "$CRED_HTTP" in
|
||||
200) [ -n "$(cred_field '.api_key')" ] && { trap - INT TERM; return 0; } ;;
|
||||
401) die "C2 rejected the saved pickup secret (401) while waiting — re-run with --token DRB-… to re-enroll." ;;
|
||||
404) die "C2 no longer knows node '$NODE_ID' (404) — it was deleted. Re-run with --token DRB-… to enroll again." ;;
|
||||
401) die "C2 rejected the saved pickup secret (401) while waiting — re-run the installer to re-enroll (it asks for a fresh enrollment token)." ;;
|
||||
404) die "C2 no longer knows node '$NODE_ID' (404) — it was deleted. Re-run the installer to enroll again (it asks for an enrollment token)." ;;
|
||||
*) : ;; # 000/5xx: transient, keep waiting
|
||||
esac
|
||||
if [ "$_shown" = 0 ]; then
|
||||
@@ -452,8 +487,7 @@ wait_for_key() { # wait_for_key PICKUP_SECRET
|
||||
|
||||
do_fresh_enroll() {
|
||||
say "Enrolling '$NODE_ID' with $C2_URL"
|
||||
[ -n "$ENROLLMENT_TOKEN" ] || ask_secret ENROLLMENT_TOKEN "Enrollment token"
|
||||
[ -n "$ENROLLMENT_TOKEN" ] || die "no enrollment token — mint one at Settings -> Nodes, then re-run with --token"
|
||||
need_token || die "no enrollment token — $NO_TOKEN_HELP"
|
||||
|
||||
local BODY RESP PICKUP STATUS KEY
|
||||
BODY="$(jq -nc --arg id "$NODE_ID" --arg n "${NODE_NAME:-$NODE_ID}" \
|
||||
@@ -476,7 +510,7 @@ do_fresh_enroll() {
|
||||
or have an admin Reissue key (Settings -> Nodes) and write the key into
|
||||
$CREDS by hand (see node-26#6)." ;;
|
||||
*401*) die "enroll refused (401): enrollment token missing/invalid/revoked —
|
||||
mint a fresh one at Settings -> Nodes and re-run with --token." ;;
|
||||
$NO_TOKEN_HELP." ;;
|
||||
*429*) die "enroll refused (429): rate limited. Wait ~1 minute, then re-run." ;;
|
||||
*) die "enroll failed: $RESP" ;;
|
||||
esac
|
||||
@@ -539,24 +573,26 @@ elif [ -s "$PICKUP_FILE" ]; then
|
||||
fi
|
||||
;;
|
||||
401)
|
||||
if [ -n "$ENROLLMENT_TOKEN" ]; then
|
||||
warn "saved pickup secret rejected (401) — likely rotated by a re-enroll elsewhere. Re-enrolling with --token."
|
||||
warn "saved pickup secret rejected (401) — likely rotated by a re-enroll elsewhere."
|
||||
if need_token; then
|
||||
warn "re-enrolling with the enrollment token."
|
||||
rm -f "$PICKUP_FILE"
|
||||
do_fresh_enroll
|
||||
else
|
||||
die "saved pickup secret is stale (401) and no --token was given. Re-run with
|
||||
--token DRB-… to re-enroll (only works while the node is still pending), or
|
||||
have an admin Reissue key for an approved node and write $CREDS by hand."
|
||||
die "saved pickup secret is stale (401) and no enrollment token was given.
|
||||
Re-enrolling only works while the node is still pending: $NO_TOKEN_HELP.
|
||||
For an approved node, have an admin Reissue key and write $CREDS by hand."
|
||||
fi
|
||||
;;
|
||||
404)
|
||||
if [ -n "$ENROLLMENT_TOKEN" ]; then
|
||||
warn "C2 does not know node '$NODE_ID' (404) — deleted server-side or never fully enrolled. Re-enrolling with --token."
|
||||
warn "C2 does not know node '$NODE_ID' (404) — deleted server-side or never fully enrolled."
|
||||
if need_token; then
|
||||
warn "re-enrolling with the enrollment token."
|
||||
rm -f "$PICKUP_FILE"
|
||||
do_fresh_enroll
|
||||
else
|
||||
die "C2 does not know node '$NODE_ID' (404) and no --token was given.
|
||||
Re-run with --token DRB-… to enroll it again."
|
||||
die "C2 does not know node '$NODE_ID' (404) and no enrollment token was given:
|
||||
$NO_TOKEN_HELP."
|
||||
fi
|
||||
;;
|
||||
000)
|
||||
@@ -640,6 +676,6 @@ if [ -z "$(jq -r '.api_key // empty' "$CREDS" 2>/dev/null)" ]; then
|
||||
if [ -s "$PICKUP_FILE" ]; then
|
||||
printf "${Y}This node has no api_key yet. Once an admin approves it at %s/settings/nodes\nthe node fetches the key and comes online by itself (no re-run needed).${N}\n\n" "$APP_URL"
|
||||
else
|
||||
printf "${Y}This node has no api_key and no saved pickup secret, so a plain re-run cannot\nfix it. Re-run with --token DRB-… to enroll; or, if the node is already\napproved, have an admin Reissue key and write it into\n%s by hand.${N}\n\n" "$CREDS"
|
||||
printf "${Y}This node has no api_key and no saved pickup secret. Re-run the installer\nand give it an enrollment token when it asks; or, if the node is already\napproved, have an admin Reissue key and write it into\n%s by hand.${N}\n\n" "$CREDS"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -51,6 +51,12 @@ main() {
|
||||
flock -n 9 || { log "another update is already running"; return 0; }
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
# This runs as root, but a pre-install.sh checkout (e.g. /home/pi/node-26)
|
||||
# is owned by the login user, and git >= 2.35.2 then refuses every command
|
||||
# with "dubious ownership" — which surfaced as origin '' != REPO_URL. Trust
|
||||
# exactly the configured INSTALL_DIR, for this process only (env config is
|
||||
# command-line scope, which safe.directory honours; no global config change).
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$INSTALL_DIR"
|
||||
envget() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true; }
|
||||
local NODE_ID C2_URL
|
||||
NODE_ID="$(envget NODE_ID)"; C2_URL="$(envget C2_URL)"; C2_URL="${C2_URL%/}"
|
||||
@@ -219,6 +225,14 @@ do_update() {
|
||||
done || true
|
||||
fi
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
|
||||
# node-26#41: neither of the above ever freed BuildKit cache or untagged-
|
||||
# but-unused images, so a BUILD=1 node grew ~40 GB of build cache plus
|
||||
# 16 GB of stale images until the root disk hit 100% and recording silently
|
||||
# stopped (node-002, 17 days). Both prunes skip anything a container uses;
|
||||
# the age filters keep the cache warm for the next build.
|
||||
docker builder prune -af --filter until=72h >/dev/null 2>&1 || true
|
||||
docker image prune -af --filter until=168h >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user