From 0c33bedbae2add9c4a4202cd8f994ad70ddd2b02 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Tue, 26 May 2026 02:44:18 +0000 Subject: [PATCH] Add Trusted-LAN LocalSend receiver on NOMAD --- docs/architecture/SERVICES_DIRECTORY.md | 8 +- docs/servers/NOMAD.md | 9 ++ localsend-nomad/.env.example | 9 ++ localsend-nomad/Dockerfile | 19 ++++ localsend-nomad/README.md | 71 ++++++++++++++ localsend-nomad/bin/prepare_nomad.sh | 33 +++++++ localsend-nomad/docker-compose.yaml | 35 +++++++ .../patches/localsend-cli-doris.patch | 92 +++++++++++++++++++ 8 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 localsend-nomad/.env.example create mode 100644 localsend-nomad/Dockerfile create mode 100644 localsend-nomad/README.md create mode 100644 localsend-nomad/bin/prepare_nomad.sh create mode 100644 localsend-nomad/docker-compose.yaml create mode 100644 localsend-nomad/patches/localsend-cli-doris.patch diff --git a/docs/architecture/SERVICES_DIRECTORY.md b/docs/architecture/SERVICES_DIRECTORY.md index 8b5946d..b841caf 100644 --- a/docs/architecture/SERVICES_DIRECTORY.md +++ b/docs/architecture/SERVICES_DIRECTORY.md @@ -2,7 +2,7 @@ All homelab services, their hosts, ports, and current status. -*Last updated: 2026-05-21* +*Last updated: 2026-05-26* ## Shared Databases (PD) @@ -92,6 +92,12 @@ OpenClaw is no longer treated as an active PD service in the operator inventory. |---------|------|------|--------| | MeshCore to MQTT relay | N.O.M.A.D. (10.5.30.7) | outbound MQTT only | ✅ Running as Docker container `mctomqtt` (`mctomqtt:latest`); config bind-mounted from `/etc/mctomqtt` | +## Local file intake + +| Service | Host | Port | Status | +|---------|------|------|--------| +| LocalSend Trusted Inbox | N.O.M.A.D. (`10.5.1.16` on Trusted VLAN 51) | 53317/tcp+udp | ✅ Repo-tracked Docker stack at `/opt/localsend-nomad`; writes directly into `/home/fizzlepoof/private/inbox-secrets`; same-host checks from N.O.M.A.D. are unreliable because of macvlan isolation | + ## Mapping / Geo (PD) | Service | Port | Status | diff --git a/docs/servers/NOMAD.md b/docs/servers/NOMAD.md index c130b06..81d72c6 100644 --- a/docs/servers/NOMAD.md +++ b/docs/servers/NOMAD.md @@ -213,6 +213,15 @@ These are expected to be managed by Wings/Pelican rather than a local compose fi - The live `.env` is also backed up into the encrypted PD secrets repo as `env/technitium-nomad.env` - Same-host checks from NOMAD to `10.5.30.9` are unreliable because the resolver sits behind macvlan networking; verify from another LAN peer instead +### LocalSend Trusted inbox +- Live stack path: `/opt/localsend-nomad` +- Repo source path: `/home/fizzlepoof/repos/truenas-stacks/localsend-nomad` +- Trusted bind IP: `10.5.1.16` +- Role: headless LocalSend receiver for Trusted-LAN handoff into `/home/fizzlepoof/private/inbox-secrets` +- Runtime model: standalone Docker Compose stack on a Trusted-LAN macvlan (`enp5s0.51`, VLAN 51) while the host stays on Servers (`10.5.30.7`) +- LocalSend listener ports: `53317/tcp`, `53317/udp` +- Same-host checks from NOMAD to `10.5.1.16` are unreliable because the receiver sits behind macvlan networking; verify from another Trusted-LAN peer instead + ### MeshCore to MQTT relay - Install date: 2026-05-18 - Install root: `/opt/mctomqtt` diff --git a/localsend-nomad/.env.example b/localsend-nomad/.env.example new file mode 100644 index 0000000..1a6db26 --- /dev/null +++ b/localsend-nomad/.env.example @@ -0,0 +1,9 @@ +TZ=America/Chicago +TRUSTED_PARENT_INTERFACE=enp5s0.51 +TRUSTED_SUBNET=10.5.1.0/24 +TRUSTED_GATEWAY=10.5.1.1 +LOCALSEND_BIND_IP=10.5.1.16 +LOCALSEND_DEVICE_NAME="Doris Trusted Inbox" +LOCALSEND_CLI_REF=54702513990eeb763e1615f274d5365442e3e393 +APPDATA_LOCALSEND_ROOT=/opt/localsend-nomad/data +INBOX_DIR=/home/fizzlepoof/private/inbox-secrets diff --git a/localsend-nomad/Dockerfile b/localsend-nomad/Dockerfile new file mode 100644 index 0000000..fd5bb78 --- /dev/null +++ b/localsend-nomad/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.25-alpine AS build + +ARG LOCALSEND_CLI_REF=54702513990eeb763e1615f274d5365442e3e393 + +RUN apk add --no-cache git patch +WORKDIR /src +RUN git clone https://github.com/0w0mewo/localsend-cli.git . \ + && git checkout "$LOCALSEND_CLI_REF" +COPY patches/localsend-cli-doris.patch /tmp/localsend-cli-doris.patch +RUN git apply /tmp/localsend-cli-doris.patch \ + && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/localsend . + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -u 1000 doris +WORKDIR /app +COPY --from=build /out/localsend /usr/local/bin/localsend +USER doris +ENTRYPOINT ["/usr/local/bin/localsend"] diff --git a/localsend-nomad/README.md b/localsend-nomad/README.md new file mode 100644 index 0000000..23c16bb --- /dev/null +++ b/localsend-nomad/README.md @@ -0,0 +1,71 @@ +# LocalSend trusted-lane receiver on N.O.M.A.D. + +Repo-tracked Docker stack that runs a headless LocalSend receiver on N.O.M.A.D. with its own Trusted-LAN IP. + +- Repo stack path: `/home/fizzlepoof/repos/truenas-stacks/localsend-nomad` +- Live stack path: `/opt/localsend-nomad` +- Trusted bind IP: `10.5.1.16` +- Trusted VLAN: `51` +- LocalSend ports: `53317/tcp`, `53317/udp` +- Inbox path on host: `/home/fizzlepoof/private/inbox-secrets` +- Advertised device name: `Doris Trusted Inbox` + +## Why this exists + +N.O.M.A.D. lives on the `Servers` VLAN (`10.5.30.7`), but some operator handoff flows need a LocalSend receiver directly reachable from the `Trusted` LAN (`10.5.1.0/24`). This stack gives LocalSend its own Trusted-LAN address without moving the host itself off the Servers network. + +## Network model + +- Docker uses a `macvlan` network with parent `enp5s0.51`. +- `enp5s0.51` is the Trusted VLAN subinterface on N.O.M.A.D.'s primary NIC. +- Docker will create the VLAN subinterface automatically when the macvlan network is created. +- Same-host testing from N.O.M.A.D. to `10.5.1.16` is unreliable because macvlan isolates the host from the container's address on the same parent interface. +- Verify reachability from another Trusted-LAN peer instead, such as PD on `10.5.1.6`. + +## LocalSend build provenance + +The image is built locally from upstream `0w0mewo/localsend-cli` at: + +- commit: `54702513990eeb763e1615f274d5365442e3e393` + +Then it applies `patches/localsend-cli-doris.patch`, which keeps the receiver compatible with the LocalSend endpoint behavior already working in the existing Doris receiver flow. + +## Files + +- `docker-compose.yaml` — live stack definition +- `Dockerfile` — reproducible CLI build from upstream + patch +- `.env.example` — deployment variables +- `bin/prepare_nomad.sh` — creates runtime dirs, validates config, optional `--up` +- `patches/localsend-cli-doris.patch` — repo-tracked patch applied during image build + +## Deploy / refresh on NOMAD + +```bash +cd /opt/localsend-nomad +cp .env.example .env +./bin/prepare_nomad.sh --up +``` + +## Verification + +From N.O.M.A.D.: + +```bash +docker compose --env-file .env ps +sudo docker logs --tail 50 localsend-trusted +``` + +From another Trusted-LAN host: + +```bash +ping 10.5.1.16 +nc -vz 10.5.1.16 53317 +``` + +Then confirm `Doris Trusted Inbox` appears as a LocalSend target from a Trusted-LAN device. + +## Notes + +- The container writes directly into `/home/fizzlepoof/private/inbox-secrets` on the host so the existing autosort / intake flow can keep using the same inbox path. +- This stack does not publish host ports; the dedicated Trusted-LAN IP is the service endpoint. +- If the upstream CLI behavior changes later, rebuild from a reviewed newer commit and update the patch or drop it if upstream no longer needs the workaround. diff --git a/localsend-nomad/bin/prepare_nomad.sh b/localsend-nomad/bin/prepare_nomad.sh new file mode 100644 index 0000000..754d9f1 --- /dev/null +++ b/localsend-nomad/bin/prepare_nomad.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="${ENV_FILE:-$STACK_DIR/.env}" +DO_UP="${1:-}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE — copy .env.example to .env and fill in real values first." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +if [[ -z "${APPDATA_LOCALSEND_ROOT:-}" || -z "${INBOX_DIR:-}" ]]; then + echo "APPDATA_LOCALSEND_ROOT and INBOX_DIR must be set in .env" >&2 + exit 1 +fi + +mkdir -p \ + "$APPDATA_LOCALSEND_ROOT/state" \ + "$INBOX_DIR" + +docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" config >/dev/null + +if [[ "$DO_UP" == "--up" ]]; then + docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" up -d --build + docker compose --env-file "$ENV_FILE" -f "$STACK_DIR/docker-compose.yaml" ps +fi diff --git a/localsend-nomad/docker-compose.yaml b/localsend-nomad/docker-compose.yaml new file mode 100644 index 0000000..1b913ae --- /dev/null +++ b/localsend-nomad/docker-compose.yaml @@ -0,0 +1,35 @@ +services: + localsend: + build: + context: . + args: + LOCALSEND_CLI_REF: ${LOCALSEND_CLI_REF} + image: localsend-nomad:local + container_name: localsend-trusted + hostname: localsend-trusted + restart: unless-stopped + environment: + TZ: ${TZ} + networks: + trusted_macvlan: + ipv4_address: ${LOCALSEND_BIND_IP} + working_dir: /app/state + volumes: + - ${APPDATA_LOCALSEND_ROOT}/state:/app/state + - ${INBOX_DIR}:/data/inbox + command: + - recv + - --devname + - ${LOCALSEND_DEVICE_NAME} + - --dir + - /data/inbox + +networks: + trusted_macvlan: + driver: macvlan + driver_opts: + parent: ${TRUSTED_PARENT_INTERFACE} + ipam: + config: + - subnet: ${TRUSTED_SUBNET} + gateway: ${TRUSTED_GATEWAY} diff --git a/localsend-nomad/patches/localsend-cli-doris.patch b/localsend-nomad/patches/localsend-cli-doris.patch new file mode 100644 index 0000000..276dda0 --- /dev/null +++ b/localsend-nomad/patches/localsend-cli-doris.patch @@ -0,0 +1,92 @@ +diff --git a/internal/localsend/localsend.go b/internal/localsend/localsend.go +index e30e6f5..c0c2b8e 100644 +--- a/internal/localsend/localsend.go ++++ b/internal/localsend/localsend.go +@@ -2,6 +2,7 @@ package localsend + + import ( + "encoding/json" ++ "fmt" + "net" + + "github.com/0w0mewo/localsend-cli/internal/localsend/constants" +@@ -13,41 +14,53 @@ import ( + func GetDeviceInfo(ip string, https bool) (models.DeviceInfo, error) { + remoteAddr := net.JoinHostPort(ip, "53317") + +- agent := fiber.AcquireAgent() +- defer fiber.ReleaseAgent(agent) +- + scheme := "http" + if https { + scheme = "https" + } + +- req := agent.Request() +- req.URI().SetScheme(scheme) +- req.URI().SetHost(remoteAddr) +- req.URI().SetPath(constants.InfoPath) +- req.Header.SetMethod(fiber.MethodGet) +- err := agent.Parse() +- if err != nil { +- return models.DeviceInfo{}, err +- } ++ paths := []string{constants.InfoPath, "/api/localsend/v2/info", constants.InfoPathLegacy} ++ var lastErr error + +- status, b, errs := agent.InsecureSkipVerify().Bytes() +- if len(errs) != 0 { +- return models.DeviceInfo{}, errs[0] +- } +- err = constants.ParseError(status) +- if err != nil { +- return models.DeviceInfo{}, err +- } ++ for _, path := range paths { ++ agent := fiber.AcquireAgent() ++ req := agent.Request() ++ req.URI().SetScheme(scheme) ++ req.URI().SetHost(remoteAddr) ++ req.URI().SetPath(path) ++ req.Header.SetMethod(fiber.MethodGet) ++ err := agent.Parse() ++ if err != nil { ++ fiber.ReleaseAgent(agent) ++ return models.DeviceInfo{}, err ++ } + +- var res models.DeviceInfo +- err = json.Unmarshal(b, &res) +- if err != nil { +- return models.DeviceInfo{}, err ++ status, b, errs := agent.InsecureSkipVerify().Bytes() ++ fiber.ReleaseAgent(agent) ++ if len(errs) != 0 { ++ lastErr = errs[0] ++ continue ++ } ++ err = constants.ParseError(status) ++ if err != nil { ++ lastErr = err ++ continue ++ } ++ ++ var res models.DeviceInfo ++ err = json.Unmarshal(b, &res) ++ if err != nil { ++ lastErr = err ++ continue ++ } ++ res.IP = ip ++ return res, nil + } +- res.IP = ip + +- return res, nil ++ if lastErr == nil { ++ lastErr = fmt.Errorf("device info not found on known endpoints") ++ } ++ return models.DeviceInfo{}, lastErr + } + + func NewFileSender(useDownloadAPI ...bool) send.FileSender {