diff --git a/.daemonless/config.yaml b/.daemonless/config.yaml index 588a0f5..5165595 100644 --- a/.daemonless/config.yaml +++ b/.daemonless/config.yaml @@ -9,7 +9,7 @@ build: - tag: latest containerfile: Containerfile default: true - tag_desc: "**daemonless Appliance**. Managed Caddy reverse proxy with a web UI, built from source." + tag_desc: "**daemonless Appliance**. Managed Caddy reverse proxy with a web UI. Caddy built from source; the admin from daemonless/reverse-proxy-src releases." cit: mode: health diff --git a/.gitignore b/.gitignore index 128a594..69d21b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1 @@ sbom-results -admin/reverse-proxy -admin/ui/node_modules/ -admin/ui/dist/* -!admin/ui/dist/.gitkeep diff --git a/Containerfile b/Containerfile index 63ba003..7366834 100644 --- a/Containerfile +++ b/Containerfile @@ -6,8 +6,8 @@ # reverse-proxy: daemonless appliance, one container on base-rcd. Two units: # custom Caddy (DNS modules compiled in) runs as root to bind 80/443; the -# admin (Go, embedded UI) runs as bsd and drives Caddy over unix sockets in -# /var/run/reverse-proxy. +# admin (Go, embedded UI, from daemonless/reverse-proxy-src releases) runs as +# bsd and drives Caddy over unix sockets in /var/run/reverse-proxy. ARG BASE_VERSION=15.1 ARG FREEBSD_ARCH=amd64 @@ -35,38 +35,25 @@ RUN v="${CADDY_VERSION:-$(fetch -v -o /dev/null https://github.com/caddyserver/c --output /out/caddy && \ /out/caddy version -FROM ghcr.io/daemonless/base:${BASE_VERSION} AS ui-amd64 -RUN pkg update && pkg install -y node24 npm-node24 && pkg clean -ay && rm -rf /var/cache/pkg/* /var/db/pkg/repos/* -COPY admin/ui/package.json admin/ui/package-lock.json /src/ui/ -RUN cd /src/ui && npm ci --no-fund --no-audit -COPY admin/ui/ /src/ui/ -RUN cd /src/ui && npm run build && /src/ui/source-hash > /src/ui/source.sha256 - -# The UI's build tools (rolldown, tailwind, lightningcss) have no FreeBSD -# arm64 binaries, and the UI is the same on every arch: take it from the -# amd64 image, which CI publishes before the arm64 build starts. The source -# hash stops an older amd64 image's UI from shipping here. -FROM ghcr.io/daemonless/base:${BASE_VERSION} AS ui-aarch64 -COPY admin/ui/ /src/ui/ -COPY --from=ghcr.io/daemonless/reverse-proxy:latest-amd64 /usr/local/share/reverse-proxy/ui/ /src/ui/dist/ -COPY --from=ghcr.io/daemonless/reverse-proxy:latest-amd64 /usr/local/share/reverse-proxy/ui-source.sha256 /src/ui/amd64.sha256 -RUN h="$(/src/ui/source-hash)" && \ - { [ "$h" = "$(cat /src/ui/amd64.sha256)" ] || { echo "latest-amd64 has a different UI than this commit: build amd64 first" >&2; exit 1; }; } && \ - cp /src/ui/amd64.sha256 /src/ui/source.sha256 - -FROM ui-${FREEBSD_ARCH} AS ui-build - -FROM ghcr.io/daemonless/base:${BASE_VERSION} AS admin-build -ARG APP_VER=0.2.0 -RUN pkg update && pkg install -y go && pkg clean -ay && rm -rf /var/cache/pkg/* /var/db/pkg/repos/* -COPY admin/ /src/ -COPY --from=ui-build /src/ui/dist/ /src/ui/dist/ -RUN cd /src && CGO_ENABLED=0 go build -trimpath \ - -ldflags "-s -w -X main.version=${APP_VER}" \ - -o /out/reverse-proxy ./cmd/reverse-proxy +# The admin (Go, UI embedded) is released from daemonless/reverse-proxy-src. +# Empty: the newest stable release, read from GitHub's releases/latest +# redirect (no pre-releases; the API rate-limits shared CI runners). Set it +# to pin one (0.3.0). +FROM ghcr.io/daemonless/base:${BASE_VERSION} AS admin-fetch +ARG RP_VERSION= +RUN v="${RP_VERSION:-$(fetch -v -o /dev/null https://github.com/daemonless/reverse-proxy-src/releases/latest 2>&1 | grep -o 'releases/tag/v[^ ]*' | head -1 | cut -d/ -f3 | sed 's/^v//')}" && \ + test -n "$v" && \ + case "$(uname -m)" in amd64) a=amd64 ;; arm64|aarch64) a=arm64 ;; *) echo "no admin build for $(uname -m)" >&2; exit 1 ;; esac && \ + f="reverse-proxy_${v}_freebsd_${a}.tar.gz" && \ + u="https://github.com/daemonless/reverse-proxy-src/releases/download/v${v}" && \ + mkdir -p /out && cd /out && \ + fetch -q "$u/$f" "$u/SHA256SUMS" && \ + want="$(grep " $f\$" SHA256SUMS | cut -d' ' -f1)" && \ + test -n "$want" && test "$want" = "$(sha256 -q "$f")" && \ + tar -xzf "$f" --strip-components 1 && rm -f "$f" SHA256SUMS && \ + echo "$v" > /out/version && ./reverse-proxy dns-providers >/dev/null FROM ghcr.io/daemonless/base-rcd:${BASE_VERSION} -ARG APP_VER=0.2.0 ARG FREEBSD_ARCH=amd64 ARG PACKAGES="ca_root_nss" @@ -82,7 +69,9 @@ LABEL org.opencontainers.image.title="Daemonless Reverse Proxy" \ io.daemonless.volumes="/config,/data" \ io.daemonless.healthcheck-url="https://localhost:8443/healthz" \ io.daemonless.arch="${FREEBSD_ARCH}" \ - io.daemonless.packages="${PACKAGES}" + io.daemonless.packages="${PACKAGES}" \ + io.daemonless.upstream-url="https://api.github.com/repos/daemonless/reverse-proxy-src/releases/latest" \ + io.daemonless.upstream-jq=".tag_name | ltrimstr(\"v\")" RUN pkg update && \ pkg install -y ${PACKAGES} && \ @@ -90,11 +79,13 @@ RUN pkg update && \ rm -rf /var/cache/pkg/* /var/db/pkg/repos/* COPY --from=caddy-build /out/caddy /usr/local/bin/caddy -COPY --from=admin-build /out/reverse-proxy /usr/local/bin/reverse-proxy -# Embedded in the binary; kept as files for the arm64 build (above). -COPY --from=ui-build /src/ui/dist/ /usr/local/share/reverse-proxy/ui/ -COPY --from=ui-build /src/ui/source.sha256 /usr/local/share/reverse-proxy/ui-source.sha256 -RUN mkdir -p /app /config /data && echo "${APP_VER}" > /app/version +COPY --from=admin-fetch /out/reverse-proxy /usr/local/bin/reverse-proxy +COPY --from=admin-fetch /out/version /app/version +# Every DNS provider the UI offers is compiled into Caddy, and the reverse. +RUN reverse-proxy dns-providers | sort > /tmp/offered && \ + caddy list-modules | sed -n 's/^dns\.providers\.//p' | sort > /tmp/built && \ + diff -u /tmp/offered /tmp/built && rm -f /tmp/offered /tmp/built && \ + mkdir -p /config /data COPY root/ / diff --git a/Containerfile.j2 b/Containerfile.j2 index fdbe80d..b1503cf 100644 --- a/Containerfile.j2 +++ b/Containerfile.j2 @@ -1,7 +1,7 @@ # reverse-proxy: daemonless appliance, one container on base-rcd. Two units: # custom Caddy (DNS modules compiled in) runs as root to bind 80/443; the -# admin (Go, embedded UI) runs as bsd and drives Caddy over unix sockets in -# /var/run/reverse-proxy. +# admin (Go, embedded UI, from daemonless/reverse-proxy-src releases) runs as +# bsd and drives Caddy over unix sockets in /var/run/reverse-proxy. ARG BASE_VERSION=15.1 ARG FREEBSD_ARCH=amd64 @@ -29,38 +29,25 @@ RUN v="${CADDY_VERSION:-$(fetch -v -o /dev/null https://github.com/caddyserver/c --output /out/caddy && \ /out/caddy version -FROM ghcr.io/daemonless/base:${BASE_VERSION} AS ui-amd64 -RUN pkg update && pkg install -y node24 npm-node24 && pkg clean -ay && rm -rf /var/cache/pkg/* /var/db/pkg/repos/* -COPY admin/ui/package.json admin/ui/package-lock.json /src/ui/ -RUN cd /src/ui && npm ci --no-fund --no-audit -COPY admin/ui/ /src/ui/ -RUN cd /src/ui && npm run build && /src/ui/source-hash > /src/ui/source.sha256 - -# The UI's build tools (rolldown, tailwind, lightningcss) have no FreeBSD -# arm64 binaries, and the UI is the same on every arch: take it from the -# amd64 image, which CI publishes before the arm64 build starts. The source -# hash stops an older amd64 image's UI from shipping here. -FROM ghcr.io/daemonless/base:${BASE_VERSION} AS ui-aarch64 -COPY admin/ui/ /src/ui/ -COPY --from=ghcr.io/daemonless/reverse-proxy:latest-amd64 /usr/local/share/reverse-proxy/ui/ /src/ui/dist/ -COPY --from=ghcr.io/daemonless/reverse-proxy:latest-amd64 /usr/local/share/reverse-proxy/ui-source.sha256 /src/ui/amd64.sha256 -RUN h="$(/src/ui/source-hash)" && \ - { [ "$h" = "$(cat /src/ui/amd64.sha256)" ] || { echo "latest-amd64 has a different UI than this commit: build amd64 first" >&2; exit 1; }; } && \ - cp /src/ui/amd64.sha256 /src/ui/source.sha256 - -FROM ui-${FREEBSD_ARCH} AS ui-build - -FROM ghcr.io/daemonless/base:${BASE_VERSION} AS admin-build -ARG APP_VER=0.2.0 -RUN pkg update && pkg install -y go && pkg clean -ay && rm -rf /var/cache/pkg/* /var/db/pkg/repos/* -COPY admin/ /src/ -COPY --from=ui-build /src/ui/dist/ /src/ui/dist/ -RUN cd /src && CGO_ENABLED=0 go build -trimpath \ - -ldflags "-s -w -X main.version=${APP_VER}" \ - -o /out/reverse-proxy ./cmd/reverse-proxy +# The admin (Go, UI embedded) is released from daemonless/reverse-proxy-src. +# Empty: the newest stable release, read from GitHub's releases/latest +# redirect (no pre-releases; the API rate-limits shared CI runners). Set it +# to pin one (0.3.0). +FROM ghcr.io/daemonless/base:${BASE_VERSION} AS admin-fetch +ARG RP_VERSION= +RUN v="${RP_VERSION:-$(fetch -v -o /dev/null https://github.com/daemonless/reverse-proxy-src/releases/latest 2>&1 | grep -o 'releases/tag/v[^ ]*' | head -1 | cut -d/ -f3 | sed 's/^v//')}" && \ + test -n "$v" && \ + case "$(uname -m)" in amd64) a=amd64 ;; arm64|aarch64) a=arm64 ;; *) echo "no admin build for $(uname -m)" >&2; exit 1 ;; esac && \ + f="reverse-proxy_${v}_freebsd_${a}.tar.gz" && \ + u="https://github.com/daemonless/reverse-proxy-src/releases/download/v${v}" && \ + mkdir -p /out && cd /out && \ + fetch -q "$u/$f" "$u/SHA256SUMS" && \ + want="$(grep " $f\$" SHA256SUMS | cut -d' ' -f1)" && \ + test -n "$want" && test "$want" = "$(sha256 -q "$f")" && \ + tar -xzf "$f" --strip-components 1 && rm -f "$f" SHA256SUMS && \ + echo "$v" > /out/version && ./reverse-proxy dns-providers >/dev/null FROM ghcr.io/daemonless/base-rcd:${BASE_VERSION} -ARG APP_VER=0.2.0 ARG FREEBSD_ARCH=amd64 ARG PACKAGES="ca_root_nss" @@ -76,7 +63,9 @@ LABEL org.opencontainers.image.title="Daemonless Reverse Proxy" \ io.daemonless.volumes="/config,/data" \ io.daemonless.healthcheck-url="https://localhost:8443/healthz" \ io.daemonless.arch="${FREEBSD_ARCH}" \ - io.daemonless.packages="${PACKAGES}" + io.daemonless.packages="${PACKAGES}" \ + io.daemonless.upstream-url="https://api.github.com/repos/daemonless/reverse-proxy-src/releases/latest" \ + io.daemonless.upstream-jq=".tag_name | ltrimstr(\"v\")" RUN pkg update && \ pkg install -y ${PACKAGES} && \ @@ -84,11 +73,13 @@ RUN pkg update && \ rm -rf /var/cache/pkg/* /var/db/pkg/repos/* COPY --from=caddy-build /out/caddy /usr/local/bin/caddy -COPY --from=admin-build /out/reverse-proxy /usr/local/bin/reverse-proxy -# Embedded in the binary; kept as files for the arm64 build (above). -COPY --from=ui-build /src/ui/dist/ /usr/local/share/reverse-proxy/ui/ -COPY --from=ui-build /src/ui/source.sha256 /usr/local/share/reverse-proxy/ui-source.sha256 -RUN mkdir -p /app /config /data && echo "${APP_VER}" > /app/version +COPY --from=admin-fetch /out/reverse-proxy /usr/local/bin/reverse-proxy +COPY --from=admin-fetch /out/version /app/version +# Every DNS provider the UI offers is compiled into Caddy, and the reverse. +RUN reverse-proxy dns-providers | sort > /tmp/offered && \ + caddy list-modules | sed -n 's/^dns\.providers\.//p' | sort > /tmp/built && \ + diff -u /tmp/offered /tmp/built && rm -f /tmp/offered /tmp/built && \ + mkdir -p /config /data COPY root/ / diff --git a/Containerfile.linux b/Containerfile.linux index 9580772..7f09bd6 100644 --- a/Containerfile.linux +++ b/Containerfile.linux @@ -1,10 +1,10 @@ # reverse-proxy on Linux, built only by dbuild's shared GitHub workflow # (build-linux job): the same Caddy with the same DNS modules, the same -# admin and UI, on Alpine with s6. Caddy runs as root to bind 80/443; the -# admin runs as bsd (PUID/PGID), as on FreeBSD. Keep APP_VER with -# Containerfile.j2's: the manifest adds this image to a tag only when the -# versions agree. -ARG APP_VER=0.2.0 +# admin release, on Alpine with s6. Caddy runs as root to bind 80/443; the +# admin runs as bsd (PUID/PGID), as on FreeBSD. The admin is released from +# daemonless/reverse-proxy-src; keep RP_VERSION with Containerfile.j2's: the +# manifest adds this image to a tag only when the versions agree. +ARG RP_VERSION= ARG CADDY_VERSION= FROM golang:1.26-alpine AS caddy-build @@ -27,22 +27,21 @@ RUN v="${CADDY_VERSION:-$(curl -fsSI https://github.com/caddyserver/caddy/releas --output /out/caddy && \ /out/caddy version -FROM node:24-alpine AS ui-build -COPY admin/ui/package.json admin/ui/package-lock.json /src/ui/ -RUN cd /src/ui && npm ci --no-fund --no-audit -COPY admin/ui/ /src/ui/ -RUN cd /src/ui && npm run build - -FROM golang:1.26-alpine AS admin-build -ARG APP_VER -COPY admin/ /src/ -COPY --from=ui-build /src/ui/dist/ /src/ui/dist/ -RUN cd /src && CGO_ENABLED=0 go build -trimpath \ - -ldflags "-s -w -X main.version=${APP_VER}" \ - -o /out/reverse-proxy ./cmd/reverse-proxy +FROM alpine:3.24 AS admin-fetch +ARG RP_VERSION +RUN apk add --no-cache curl && \ + v="${RP_VERSION:-$(curl -fsSI https://github.com/daemonless/reverse-proxy-src/releases/latest | grep -io 'releases/tag/v[^[:space:]]*' | head -1 | cut -d/ -f3 | sed 's/^v//')}" && \ + test -n "$v" && \ + case "$(uname -m)" in x86_64) a=amd64 ;; aarch64) a=arm64 ;; *) echo "no admin build for $(uname -m)" >&2; exit 1 ;; esac && \ + f="reverse-proxy_${v}_linux_${a}.tar.gz" && \ + u="https://github.com/daemonless/reverse-proxy-src/releases/download/v${v}" && \ + mkdir -p /out && cd /out && \ + curl -fsSLO "$u/$f" -fsSLO "$u/SHA256SUMS" && \ + grep " $f\$" SHA256SUMS | sha256sum -c - && \ + tar -xzf "$f" --strip-components 1 && rm -f "$f" SHA256SUMS && \ + echo "$v" > /out/version && ./reverse-proxy dns-providers >/dev/null FROM alpine:3.24 -ARG APP_VER LABEL org.opencontainers.image.title="Daemonless Reverse Proxy" \ org.opencontainers.image.description="Friendly HTTPS names for the services on your network: a managed Caddy reverse proxy with a web UI, secure by default." \ org.opencontainers.image.source="https://github.com/daemonless/reverse-proxy" \ @@ -55,10 +54,14 @@ LABEL org.opencontainers.image.title="Daemonless Reverse Proxy" \ io.daemonless.volumes="/config,/data" RUN apk add --no-cache s6 ca-certificates tzdata curl && \ addgroup -g 1000 bsd && adduser -D -H -u 1000 -G bsd -s /sbin/nologin bsd && \ - mkdir -p /app /config /data && echo "${APP_VER}" > /app/version + mkdir -p /app /config /data COPY --from=caddy-build /out/caddy /usr/local/bin/caddy -COPY --from=admin-build /out/reverse-proxy /usr/local/bin/reverse-proxy -COPY --from=ui-build /src/ui/dist/ /usr/local/share/reverse-proxy/ui/ +COPY --from=admin-fetch /out/reverse-proxy /usr/local/bin/reverse-proxy +COPY --from=admin-fetch /out/version /app/version +# Every DNS provider the UI offers is compiled into Caddy, and the reverse. +RUN reverse-proxy dns-providers | sort > /tmp/offered && \ + caddy list-modules | sed -n 's/^dns\.providers\.//p' | sort > /tmp/built && \ + diff -u /tmp/offered /tmp/built && rm -f /tmp/offered /tmp/built COPY root/etc/reverse-proxy/bootstrap.json /etc/reverse-proxy/bootstrap.json COPY root-linux/ / RUN chmod +x /init /healthz /usr/local/bin/s6-ready-when /etc/cont-init.d/* /etc/services.d/*/run diff --git a/README.md b/README.md index 2bc0944..3c31042 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ address you use today. It is [Caddy](https://caddyserver.com) underneath, manage - **Scriptable.** A key per app or script manages names and nothing else; there is an OpenAPI reference and a command line inside the container. - **Easy to keep.** One encrypted backup file holds everything; restore it on a fresh install. Delete a name by mistake and Undo brings it back. -Native FreeBSD (amd64 and arm64), built from source. The web UI runs unprivileged; only Caddy is root, to bind ports 80 and 443. +Native FreeBSD (amd64 and arm64). Caddy is built from source; the admin and web UI come from [daemonless/reverse-proxy-src](https://github.com/daemonless/reverse-proxy-src) releases. The web UI runs unprivileged; only Caddy is root, to bind ports 80 and 443. | | | |---|---| @@ -44,7 +44,7 @@ Native FreeBSD (amd64 and arm64), built from source. The web UI runs unprivilege ## Version Tags | Tag | Description | Best For | | :--- | :--- | :--- | -| `latest` | **daemonless Appliance**. Managed Caddy reverse proxy with a web UI, built from source. | Most users — recommended. | +| `latest` | **daemonless Appliance**. Managed Caddy reverse proxy with a web UI. Caddy built from source; the admin from daemonless/reverse-proxy-src releases. | Most users — recommended. | ## Prerequisites Before deploying, ensure your host environment is ready. See the [Quick Start Guide](https://daemonless.io/guides/quick-start) for host setup instructions. @@ -53,37 +53,111 @@ Before deploying, ensure your host environment is ready. See the [Quick Start Gu ### Podman Compose -```yaml +**Names from container labels.** Containers on this machine with io.daemonless.proxy labels get names here. The proxy reads podman's socket; the web UI never does. + +#### Off (default) + +Save as `compose.yaml`: + +```yaml { data-zip-bundle="reverse-proxy-podman" data-zip-filename="compose.yaml" } +name: reverse-proxy + services: reverse-proxy: - image: "ghcr.io/daemonless/reverse-proxy:latest" + image: ghcr.io/daemonless/reverse-proxy:latest container_name: reverse-proxy + # "always", not "unless-stopped": FreeBSD's podman rc.d only auto-starts + # containers with restart-policy=always at boot (needs podman_enable=YES). + restart: always + # SELinux (Fedora, RHEL): lets the proxy use its folders and podman's + # socket. Ignored where there is no SELinux. + security_opt: + - label=disable + environment: - - PUID=1000 # User ID for the admin process (Caddy stays root to bind 80 and 443) - - PGID=1000 # Group ID for the admin process - - TZ=UTC # Timezone for the container - - RP_PAIRING_KEY= # Admin only. A key (32+ random characters) an app that installed the proxy, such as fjord, uses to manage hosts; registered once as a connected app - - RP_PAIRING_NAME=fjord # Admin only. The connected app's name for RP_PAIRING_KEY. Default: Paired app - - RP_PAIRING_ID= # Admin only. The installing app's permanent id, so several of them are told apart. Optional. - - RP_SETUP_TOKEN= # Admin only. First-run setup token chosen by the installing app, so it can open setup for you. Default: a new one in the log - - RP_PAIRING_URL= # Admin only. Where the installing app is opened, shown as a way back to it. Optional. - # - RP_ADMIN_URL= # Where you open the admin UI, e.g. https://192.168.1.5:8443. Optional: the proxy remembers the address you sign in at; this makes the first-run link exact before anyone has. - # - RP_TRUST_PORT= # Host port that reaches the HTTP trust page if you publish it on something other than 80. Optional. - # - RP_DEBUG= # Set to 1 for Caddy's full info-level log. Off by default so the log stays readable. + - PUID=1000 + - PGID=1000 + - TZ=UTC + # - RP_ADMIN_URL=https://192.168.1.5:8443 + # Set by an app that installs the proxy (fjord); empty otherwise. + - RP_PAIRING_KEY= + - RP_PAIRING_NAME=fjord + - RP_PAIRING_ID= + - RP_SETUP_TOKEN= + - RP_PAIRING_URL= + volumes: - - "/containers/reverse-proxy:/config" - - "/containers/reverse-proxy/data:/data" + - /path/to/containers/reverse-proxy:/config + - /path/to/containers/reverse-proxy/data:/data + # Names from container labels: point RP_PODMAN_DIR at the host's + # /var/run/podman (the directory). Empty directory: off. + - ${RP_PODMAN_DIR:-/path/to/containers/reverse-proxy/podman}:/var/run/podman:ro + ports: - - "80:80" - - "443:443" - - "8443:8443" - # always (not unless-stopped) so FreeBSD's podman rc.d auto-starts it at boot + - 80:80 + - 443:443 + - 8443:8443 +``` + +Then run `podman-compose up -d`. + +#### On: the proxy reads podman's socket + +**1.** Save as `.env` and fill in what is empty: + +```env { data-zip-bundle="reverse-proxy-podman-container_labels-on" data-zip-filename=".env" } +# Names from container labels: On: the proxy reads podman's socket +RP_PODMAN_DIR=/var/run/podman +``` + +**2.** Save as `compose.yaml`: + +```yaml { data-zip-bundle="reverse-proxy-podman-container_labels-on" data-zip-filename="compose.yaml" } +name: reverse-proxy + +services: + reverse-proxy: + image: ghcr.io/daemonless/reverse-proxy:latest + container_name: reverse-proxy + # "always", not "unless-stopped": FreeBSD's podman rc.d only auto-starts + # containers with restart-policy=always at boot (needs podman_enable=YES). restart: always + # SELinux (Fedora, RHEL): lets the proxy use its folders and podman's + # socket. Ignored where there is no SELinux. + security_opt: + - label=disable + + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + # - RP_ADMIN_URL=https://192.168.1.5:8443 + # Set by an app that installs the proxy (fjord); empty otherwise. + - RP_PAIRING_KEY= + - RP_PAIRING_NAME=fjord + - RP_PAIRING_ID= + - RP_SETUP_TOKEN= + - RP_PAIRING_URL= + + volumes: + - /path/to/containers/reverse-proxy:/config + - /path/to/containers/reverse-proxy/data:/data + # Names from container labels: point RP_PODMAN_DIR at the host's + # /var/run/podman (the directory). Empty directory: off. + - ${RP_PODMAN_DIR:-/path/to/containers/reverse-proxy/podman}:/var/run/podman:ro + + ports: + - 80:80 + - 443:443 + - 8443:8443 ``` -Save as `compose.yaml`, then run `podman-compose up -d`. +Then run `podman-compose up -d`. ### AppJail Director + +#### Off (default) + **.env**: ``` @@ -130,11 +204,14 @@ services: volumes: - reverse-proxy: /config - reverse-proxy_data: /data + - reverse-proxy_podman}: -/path/to/containers/reverse-proxy/podman} volumes: reverse-proxy: device: '/containers/reverse-proxy' reverse-proxy_data: device: '/containers/reverse-proxy/data' + reverse-proxy_podman}: + device: '/containers/reverse-proxy/podman}' ``` **Makejail**: @@ -151,39 +228,78 @@ OPTION from=ghcr.io/daemonless/reverse-proxy:${tag} Save the files above, then run `appjail-director up`. +#### On: the proxy reads podman's socket -> [!WARNING] -> Exposing ports in AppJail means that your service can be reached from remote hosts. If that is not your intention, do not expose the ports and communicate with the service using the jail's IPv4 address or hostname assigned by the virtual network. -> -> To avoid exposing ports, just remove the `expose` option in your `appjail-director.yml` or from your command-line arguments. +**.env**: -### Bastille +``` +# .env -> [!WARNING] -> Bastille's OCI support is **experimental**. It requires `buildah` and shares the host network stack (`inherit`). Mount volumes with `--volume HOST JAIL`; without it, image-declared volumes are stored under `${bastille_volumesdir}/${jail}`. +DIRECTOR_PROJECT=reverse-proxy +PUID=1000 +PGID=1000 +TZ=UTC +RP_PAIRING_KEY= +RP_PAIRING_NAME=fjord +RP_PAIRING_ID= +RP_SETUP_TOKEN= +RP_PAIRING_URL= +RP_PODMAN_DIR=/var/run/podman +``` + +**appjail-director.yml**: ```yaml +# appjail-director.yml + +options: + - virtualnet: ': default' + - nat: services: reverse-proxy: - name: reverse-proxy - image: "ghcr.io/daemonless/reverse-proxy:latest" - network: - - mode: host - environment: - - PUID=1000 - - PGID=1000 - - TZ=UTC - - RP_PAIRING_KEY= - - RP_PAIRING_NAME=fjord - - RP_PAIRING_ID= - - RP_SETUP_TOKEN= - - RP_PAIRING_URL= + name: reverse_proxy + options: + - container: 'args:--pull' + - expose: '80:80 proto:tcp' + - expose: '443:443 proto:tcp' + - expose: '8443:8443 proto:tcp' + oci: + user: root + environment: + - PUID: !ENV '${PUID}' + - PGID: !ENV '${PGID}' + - TZ: !ENV '${TZ}' + - RP_PAIRING_KEY: !ENV '${RP_PAIRING_KEY}' + - RP_PAIRING_NAME: !ENV '${RP_PAIRING_NAME}' + - RP_PAIRING_ID: !ENV '${RP_PAIRING_ID}' + - RP_SETUP_TOKEN: !ENV '${RP_SETUP_TOKEN}' + - RP_PAIRING_URL: !ENV '${RP_PAIRING_URL}' volumes: - - "/containers/reverse-proxy:/config" - - "/containers/reverse-proxy/data:/data" + - reverse-proxy: /config + - reverse-proxy_data: /data + - reverse-proxy_podman}: -/path/to/containers/reverse-proxy/podman} +volumes: + reverse-proxy: + device: '/containers/reverse-proxy' + reverse-proxy_data: + device: '/containers/reverse-proxy/data' + reverse-proxy_podman}: + device: '/containers/reverse-proxy/podman}' ``` -Save as `bastille-compose.yml`, then run `bastille up`. +**Makejail**: + +``` +# Makejail + +ARG tag=latest + +OPTION container=boot +OPTION overwrite=force +OPTION from=ghcr.io/daemonless/reverse-proxy:${tag} +``` + +Save the files above, then run `appjail-director up`. Open `http://:80` from another machine (the server's own browser can't use a published port). ## Parameters @@ -210,6 +326,7 @@ Open `http://:80` from another machine (the server's own browser can't u |------|-------------| | `/config` | Admin state and secrets | | `/data` | Certificates, ACME account and the internal CA | +| `-/path/to/containers/reverse-proxy/podman}` | | ### Ports @@ -292,6 +409,42 @@ Finish setup button opens setup already verified. After that, fjord lists the apps that have no name yet and names the ones you pick, and keeps each name pointing at its app when the app's address changes. +### Names from container labels + +A container on the same machine can ask for its name with labels, the +way Traefik users expect, without fjord or a key. Turn it on by giving +the proxy podman's socket: the `RP_PODMAN_DIR` line under `volumes` +mounts the host's `/var/run/podman` (the directory, so the socket +survives a `podman_service` restart). In fjord it is the "Name +containers by their labels" switch. The proxy's root unit reads the +socket; the web UI never does. `:ro` does not limit a socket: whoever +holds it can run podman, so it goes only to a proxy you trust with that. +Settings can turn names from labels off while the socket is mounted. + +| Label | Means | +|---|---| +| `io.daemonless.proxy=true` | Name it after the container (`radarr` becomes `radarr.`). Enough for a daemonless image: the port is the image's own. | +| `io.daemonless.proxy.name=movies` | A different name. | +| `io.daemonless.proxy.port=7878` | The port, when the image declares none or several. | +| `io.daemonless.proxy.names=films,cinema` | More names for the same container. | +| `io.daemonless.proxy.public=true` | Open it to the internet (with a public ending). | +| `io.daemonless.proxy.https=true` | The container itself speaks HTTPS; its own certificate is accepted. | +| `io.daemonless.proxy.own-origin=true` | The service expects its own address in Origin and Referer (UniFi). | + +The proxy reaches the container on a network they share (the default +`podman` bridge: no port needs publishing), else through a published +port on the host, and says which on the Names page. The labels own the +name, port and access, shown read-only; trusted networks, own address, +the icon and the uptime check are edited in the proxy and survive a +`podman-compose up`. A stopped or removed container (fjord's Stop and +`compose down` remove it) keeps its name for 7 days and shows as down; +a recreated one (new address, or a new name in its labels) is followed. +After 7 days gone the name goes, restorably for a day. A label the +proxy cannot honor, a name another container or a hand-made name +already has, or a container it cannot reach is listed on the Names +page with the reason, never guessed at. Settings → Connected apps shows +whether podman is being read. + ### Your own app An app that manages services, like a jail or VM manager, can do what diff --git a/admin/cmd/reverse-proxy/hosts.go b/admin/cmd/reverse-proxy/hosts.go deleted file mode 100644 index 2ff513e..0000000 --- a/admin/cmd/reverse-proxy/hosts.go +++ /dev/null @@ -1,326 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "flag" - "fmt" - "io" - "net" - "net/http" - "os" - "strconv" - "strings" - "text/tabwriter" - "time" -) - -// The hosts command: the v1 hosts API from inside the container, over the -// admin's unix socket (no key needed there). Same calls as openapi.json. - -const hostsUsage = `Usage: - reverse-proxy hosts list hosts - reverse-proxy hosts add NAME ADDRESS:PORT add one (NAME is the label: radarr) - --https the service itself speaks HTTPS (detected when left out) - --internet open to the internet, not just the home network - reverse-proxy hosts remove NAME remove one (restorable for 24 hours) - reverse-proxy hosts restore NAME bring a removed one back -` - -// socketClient talks HTTP to the admin over its unix socket. -func socketClient(sock string) *http.Client { - return &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - var d net.Dialer - return d.DialContext(ctx, "unix", sock) - }}, Timeout: 30 * time.Second} -} - -type apiHost struct { - ID string `json:"id"` - Name string `json:"name"` - ExtraNames []string `json:"extraNames,omitempty"` - Names []string `json:"names"` - URL string `json:"url"` - Access string `json:"access"` - Ref string `json:"ref,omitempty"` - DeletedAt *string `json:"deletedAt,omitempty"` - Target struct { - Address string `json:"address"` - Port int `json:"port"` - HTTPS bool `json:"https"` - AcceptUntrusted bool `json:"acceptUntrusted"` - } `json:"target"` -} - -type hostsCLI struct { - c *http.Client - out io.Writer - err io.Writer -} - -func hostsCmd(sock string, args []string) int { - h := &hostsCLI{c: socketClient(sock), out: os.Stdout, err: os.Stderr} - if len(args) == 0 { - return h.list() - } - switch args[0] { - case "list", "ls": - return h.list() - case "add": - return h.add(args[1:]) - case "remove", "rm": - return h.remove(args[1:]) - case "restore": - return h.restore(args[1:]) - case "-h", "--help", "help": - fmt.Fprint(h.out, hostsUsage) - return 0 - } - fmt.Fprintf(h.err, "reverse-proxy hosts: unknown command %q\n\n%s", args[0], hostsUsage) - return 2 -} - -// call makes one API request and decodes a JSON object; an error status -// becomes a message in the proxy's own words. -func (h *hostsCLI) call(method, path string, body any, out any) error { - var rd io.Reader - if body != nil { - b, _ := json.Marshal(body) - rd = bytes.NewReader(b) - } - req, _ := http.NewRequest(method, "http://admin"+path, rd) - req.Header.Set("Content-Type", "application/json") - resp, err := h.c.Do(req) - if err != nil { - return fmt.Errorf("cannot reach the admin: %v (is the container running?)", err) - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 300 { - var e struct { - Error, Field, Detail string - } - json.Unmarshal(raw, &e) - switch { - case e.Error == "": - return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(raw))) - case e.Detail != "": - return fmt.Errorf("%s (%s)", e.Error, e.Detail) - case e.Field != "": - return fmt.Errorf("%s: %s", e.Field, e.Error) - } - return fmt.Errorf("%s", e.Error) - } - if out != nil { - return json.Unmarshal(raw, out) - } - return nil -} - -func (h *hostsCLI) fail(err error) int { - fmt.Fprintf(h.err, "reverse-proxy hosts: %v\n", err) - return 1 -} - -// domain is the ending, or an error that says to finish setup first. -func (h *hostsCLI) domain() (string, error) { - var st struct { - Domain string `json:"domain"` - AdminURL string `json:"adminUrl"` - Setup struct { - Complete bool `json:"complete"` - } `json:"setup"` - } - if err := h.call("GET", "/api/v1/status", nil, &st); err != nil { - return "", err - } - if !st.Setup.Complete || st.Domain == "" { - where := st.AdminURL - if where == "" { - where = adminPlace - } - return "", fmt.Errorf("the proxy is not set up yet; open %s first", where) - } - return st.Domain, nil -} - -func (h *hostsCLI) hosts() ([]apiHost, error) { - var res struct { - Hosts []apiHost `json:"hosts"` - } - err := h.call("GET", "/api/v1/hosts?include=removed", nil, &res) - return res.Hosts, err -} - -// find matches a label (radarr) or a full name (radarr.apps.lan); deleted -// says which kind of host is wanted. -func find(hosts []apiHost, name string, deleted bool) *apiHost { - name = strings.ToLower(strings.TrimSpace(name)) - for i := range hosts { - x := &hosts[i] - if (x.DeletedAt != nil) != deleted { - continue - } - if x.Name == name { - return x - } - for _, n := range x.Names { - if n == name { - return x - } - } - } - return nil -} - -func accessWords(a string) string { - if a == "internet" { - return "open to the internet" - } - return "home network only" -} - -func target(x apiHost) string { - t := net.JoinHostPort(x.Target.Address, strconv.Itoa(x.Target.Port)) - if x.Target.HTTPS { - return "https://" + t - } - return t -} - -func (h *hostsCLI) list() int { - if _, err := h.domain(); err != nil { - return h.fail(err) - } - hosts, err := h.hosts() - if err != nil { - return h.fail(err) - } - if len(hosts) == 0 { - fmt.Fprintln(h.out, "No hosts yet. Add one:\n\n reverse-proxy hosts add radarr 192.168.1.20:7878") - return 0 - } - w := tabwriter.NewWriter(h.out, 0, 8, 2, ' ', 0) - fmt.Fprintln(w, "NAME\tTARGET\tACCESS\t") - for _, x := range hosts { - note := "" - if x.DeletedAt != nil { - note = "removed; restore with: reverse-proxy hosts restore " + x.Name - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", x.Names[0], target(x), accessWords(x.Access), note) - } - w.Flush() - return 0 -} - -func (h *hostsCLI) add(args []string) int { - fs := flag.NewFlagSet("hosts add", flag.ContinueOnError) - fs.SetOutput(h.err) - https := fs.Bool("https", false, "the service itself speaks HTTPS") - internet := fs.Bool("internet", false, "open to the internet") - // Flags may follow the positional arguments (hosts add radarr host:port --https). - var pos []string - rest := args - for len(rest) > 0 { - if err := fs.Parse(rest); err != nil { - return 2 - } - rest = fs.Args() - if len(rest) > 0 { - pos = append(pos, rest[0]) - rest = rest[1:] - } - } - if len(pos) != 2 { - fmt.Fprint(h.err, hostsUsage) - return 2 - } - name := pos[0] - addr, portStr, err := net.SplitHostPort(pos[1]) - if err != nil { - return h.fail(fmt.Errorf("give the service as ADDRESS:PORT, like 192.168.1.20:7878")) - } - port, err := strconv.Atoi(portStr) - if err != nil { - return h.fail(fmt.Errorf("%q is not a port", portStr)) - } - if _, err := h.domain(); err != nil { - return h.fail(err) - } - - // Detect what is there, as the UI does: HTTPS and its certificate. - var probe struct { - Reachable bool `json:"reachable"` - HTTPS bool `json:"https"` - UntrustedCert bool `json:"untrustedCert"` - } - h.call("GET", "/api/v1/probe?address="+addr+"&port="+portStr, nil, &probe) - - in := map[string]any{"name": name, "access": "home"} - if *internet { - in["access"] = "internet" - } - t := map[string]any{"address": addr, "port": port, "https": *https || probe.HTTPS, "acceptUntrusted": probe.HTTPS && probe.UntrustedCert} - in["target"] = t - var made apiHost - if err := h.call("POST", "/api/v1/hosts", in, &made); err != nil { - return h.fail(err) - } - fmt.Fprintf(h.out, "%s -> %s, %s.\n", made.URL, target(made), accessWords(made.Access)) - switch { - case !probe.Reachable: - fmt.Fprintf(h.out, "Nothing answered at %s:%d just now; the name is served and will work once the service is up.\n", addr, port) - case probe.HTTPS && probe.UntrustedCert: - fmt.Fprintln(h.out, "The service uses a self-signed certificate; the proxy accepts it.") - } - return 0 -} - -func (h *hostsCLI) remove(args []string) int { - if len(args) != 1 { - fmt.Fprint(h.err, hostsUsage) - return 2 - } - hosts, err := h.hosts() - if err != nil { - return h.fail(err) - } - x := find(hosts, args[0], false) - if x == nil { - return h.fail(fmt.Errorf("no host called %q; reverse-proxy hosts lists them", args[0])) - } - var res struct { - UndoUntil time.Time `json:"undoUntil"` - } - if err := h.call("DELETE", "/api/v1/hosts/"+x.ID, nil, &res); err != nil { - return h.fail(err) - } - fmt.Fprintf(h.out, "Removed %s. Until %s you can bring it back:\n\n reverse-proxy hosts restore %s\n", x.Names[0], res.UndoUntil.Local().Format("15:04 on Jan 2"), x.Name) - return 0 -} - -func (h *hostsCLI) restore(args []string) int { - if len(args) != 1 { - fmt.Fprint(h.err, hostsUsage) - return 2 - } - hosts, err := h.hosts() - if err != nil { - return h.fail(err) - } - x := find(hosts, args[0], true) - if x == nil { - if find(hosts, args[0], false) != nil { - fmt.Fprintf(h.out, "%s is already live.\n", args[0]) - return 0 - } - return h.fail(fmt.Errorf("no removed host called %q", args[0])) - } - var back apiHost - if err := h.call("POST", "/api/v1/hosts/"+x.ID+"/restore", nil, &back); err != nil { - return h.fail(err) - } - fmt.Fprintf(h.out, "%s is back -> %s, %s.\n", back.URL, target(back), accessWords(back.Access)) - return 0 -} diff --git a/admin/cmd/reverse-proxy/listen.go b/admin/cmd/reverse-proxy/listen.go deleted file mode 100644 index 8d2ef65..0000000 --- a/admin/cmd/reverse-proxy/listen.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "slices" - "strings" - "sync" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/app" - "github.com/daemonless/reverse-proxy/admin/internal/netif" -) - -// answerOn turns the chosen networks into addresses at every render, and -// renders again when they change (a new DHCP lease, a network coming up -// after the proxy did). -type answerOn struct { - env string // RP_LISTEN: wins over the saved choice - mu sync.Mutex - want []string // the choice the last render saw - last []string // and what it bound; nil is every network -} - -// render is the addresses to bind (nil: every network) and the subnets -// shut out, for the choice saved or RP_LISTEN. -func (a *answerOn) render(saved []string) ([]string, []string) { - want := saved - if a.env != "" { - want = strings.FieldsFunc(a.env, func(r rune) bool { return r == ',' || r == ' ' }) - } - networks := netif.List() - got := netif.Addresses(networks, want) - a.mu.Lock() - defer a.mu.Unlock() - if !equal(want, a.want) || !equal(got, a.last) { - on := strings.Join(want, ", ") - switch { - case got == nil: - log.Printf("[admin] answering on every network") - case len(got) == 0: - log.Printf("[admin] answering on %s, which this container has no address on yet: only inside the container until it does. `reverse-proxy listen all` answers on every network again", on) - default: - log.Printf("[admin] answering on %s only (%s)", on, strings.Join(got, ", ")) - } - } - a.want, a.last = want, got - return got, netif.Shut(networks, want) -} - -// watch renders again when the chosen networks' addresses change. -func (a *answerOn) watch(ctx context.Context, m *app.Manager) { - t := time.NewTicker(10 * time.Second) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - } - a.mu.Lock() - want, last := a.want, a.last - a.mu.Unlock() - if len(want) == 0 || equal(netif.Addresses(netif.List(), want), last) { - continue - } - if err := m.Reload(); err != nil { - log.Printf("[admin] the addresses on %s changed; loading the configuration failed: %v", strings.Join(want, ", "), err) - } - } -} - -func equal(a, b []string) bool { - if (a == nil) != (b == nil) || len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -// listenCmd is `reverse-proxy listen [all | ...]`: show or change -// the networks the proxy answers on, over the admin's socket. -func listenCmd(sock string, args []string) int { - c := socketClient(sock) - if len(args) == 0 { - resp, err := c.Get("http://admin/api/v1/settings") - if err != nil { - fmt.Fprintf(os.Stderr, "cannot reach the admin at %s: %v\n", sock, err) - return 1 - } - defer resp.Body.Close() - return printListen(resp) - } - want := args - if len(args) == 1 && args[0] == "all" { - want = []string{} - } - body, _ := json.Marshal(map[string][]string{"listen": want}) - resp, err := c.Post("http://admin/internal/listen", "application/json", bytes.NewReader(body)) - if err != nil { - fmt.Fprintf(os.Stderr, "cannot reach the admin at %s: %v\n", sock, err) - return 1 - } - defer resp.Body.Close() - return printListen(resp) -} - -func printListen(resp *http.Response) int { - data, _ := io.ReadAll(resp.Body) - var out struct { - Error string `json:"error"` - Listen []string `json:"listen"` - SetBy string `json:"listenSetBy"` - Networks []netif.Network `json:"networks"` - } - if json.Unmarshal(data, &out) != nil || resp.StatusCode != http.StatusOK { - fmt.Fprintf(os.Stderr, "%s\n", strings.TrimSpace(cmpStr(out.Error, resp.Status+" "+string(data)))) - return 1 - } - if len(out.Listen) == 0 { - fmt.Println("Answers on every network:") - } else { - fmt.Printf("Answers on %s only", strings.Join(out.Listen, " and ")) - if out.SetBy != "" { - fmt.Printf(" (set by %s)", out.SetBy) - } - fmt.Println(". Its networks:") - } - for _, n := range out.Networks { - mark := " " - if slices.Contains(out.Listen, n.Network) { - mark = "*" - } - fmt.Printf(" %s %-6s %-18s %s\n", mark, n.Interface, n.Network, n.Address) - } - return 0 -} - -func cmpStr(a, b string) string { - if a != "" { - return a - } - return b -} diff --git a/admin/cmd/reverse-proxy/main.go b/admin/cmd/reverse-proxy/main.go deleted file mode 100644 index 9ca8bcb..0000000 --- a/admin/cmd/reverse-proxy/main.go +++ /dev/null @@ -1,330 +0,0 @@ -// reverse-proxy admin: renders the Caddy configuration from state, loads it -// over Caddy's admin socket and serves the UI and API on a unix socket that -// Caddy fronts on :8443. -package main - -import ( - "cmp" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "log" - "net/http" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/app" - "github.com/daemonless/reverse-proxy/admin/internal/auth" - "github.com/daemonless/reverse-proxy/admin/internal/ca" - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/certs" - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/server" - "github.com/daemonless/reverse-proxy/admin/internal/state" - "github.com/daemonless/reverse-proxy/admin/internal/uptime" -) - -var version = "dev" - -func main() { - stateDir := flag.String("state", "/config", "state directory") - caddySock := flag.String("caddy-socket", "/var/run/reverse-proxy/caddy.sock", "Caddy admin socket") - listen := flag.String("listen", "/var/run/reverse-proxy/admin.sock", "unix socket to serve the UI and API on") - probeSock := flag.String("probe-socket", "/var/run/reverse-proxy/tls.sock", "unix socket Caddy serves the hosts' certificates on") - flag.Parse() - - log.SetFlags(0) - switch flag.Arg(0) { - case "reset-password": - os.Exit(resetPassword(*listen)) - case "hosts": - os.Exit(hostsCmd(*listen, flag.Args()[1:])) - case "listen": - os.Exit(listenCmd(*listen, flag.Args()[1:])) - case "": - default: - fmt.Fprintf(os.Stderr, "reverse-proxy: unknown command %q\n\nCommands:\n hosts list, add, remove or restore hosts\n listen answer on every network, or on some\n reset-password forget the password and print a new setup link\n", flag.Arg(0)) - os.Exit(2) - } - log.Printf("[admin] reverse-proxy %s", version) - // Typed in a shell while the container's admin runs: refuse here, before - // the state file is rewritten (as root) and the socket unlinked from - // under the running admin, which took the proxy down until a restart. - if server.AdminAlive(*listen) { - log.Fatalf("[admin] an admin is already running (it answers on %s). For the command line: reverse-proxy hosts, reverse-proxy reset-password", *listen) - } - - secrets := filepath.Join(*stateDir, "secrets") - if err := os.MkdirAll(secrets, 0o700); err != nil { - log.Fatalf("[admin] %v", err) - } - certPath, keyPath, err := caddy.EnsureSetupCert(secrets) - if err != nil { - log.Fatalf("[admin] setup certificate: %v", err) - } - - // Caddy (root) writes certificate events here for the admin to read. - issuanceLog := filepath.Join(*stateDir, "logs", "issuance.log") - if err := os.MkdirAll(filepath.Dir(issuanceLog), 0o755); err != nil { - log.Fatalf("[admin] %v", err) - } - - client := caddy.NewClient(*caddySock) - refused := &dns.Refusal{} - listenEnv := strings.TrimSpace(os.Getenv("RP_LISTEN")) - answering := &answerOn{env: listenEnv} - render := func(st *state.State) any { - in := caddy.Input{ - AdminSocket: *caddySock, - UISocket: *listen, - ProbeSocket: *probeSock, - CertPath: certPath, - KeyPath: keyPath, - LogLevel: "WARN", - IssuanceLog: issuanceLog, - State: st, - } - in.Listen, in.Shut = answering.render(st.Settings.Listen) - if os.Getenv("RP_DEBUG") != "" { - in.LogLevel = "INFO" - } - if root, err := ca.Load(secrets); err == nil && root.Domain == st.Settings.Domain { - in.RootCert, in.RootKey, in.RootID = root.CertPath, root.KeyPath, root.ID() - } - if c, err := dns.Load(secrets); err == nil && refused.Refused(c) == "" { - in.DNS = c - } - return caddy.Render(in) - } - mgr, err := app.New(state.NewStore(filepath.Join(*stateDir, "state.json")), client, render) - if err != nil { - log.Fatalf("[admin] state: %v", err) - } - // The root CA exists from the moment a domain does, so every - // certificate Caddy issues is under the restricted root. - mgr.Prepare = func(st *state.State) error { - if st.Settings.Domain == "" { - return nil - } - root, err := ca.Ensure(secrets, st.Settings.Domain) - if err != nil { - return err - } - if root.Created { - log.Printf("[admin] made the root certificate for %s; fingerprint %s", root.Domain, root.Fingerprint) - } - return nil - } - err = reloadWhenUp(mgr) - // Credentials Caddy won't load must not keep the proxy down (and the - // admin with it, where they would be fixed): without them it serves - // names on its own certificates and says what to fix. - var ce *app.CaddyError - if c, lerr := dns.Load(secrets); errors.As(err, &ce) && lerr == nil { - refused.Set(c, ce.Err) - if err = reloadWhenUp(mgr); err == nil { - log.Printf("[admin] Caddy refused the saved %s credentials (%v); using the proxy's own certificates until they are replaced", c.Provider, ce.Err) - } - } - if err != nil { - log.Fatalf("[admin] loading Caddy config: %v", err) - } - log.Printf("[admin] Caddy configuration loaded") - - // An app that installed the proxy (fjord) can pair with it up front: - // it chose the key and passes it in, so nobody copies one across. - if key := os.Getenv("RP_PAIRING_KEY"); key != "" { - label := cmp.Or(os.Getenv("RP_PAIRING_NAME"), "Paired app") - if added, err := server.RegisterPairingKey(mgr, key, label, os.Getenv("RP_PAIRING_URL"), os.Getenv("RP_PAIRING_ID")); err != nil { - log.Printf("[admin] RP_PAIRING_KEY not used: %v", err) - } else if added { - log.Printf("[admin] connected %s (from RP_PAIRING_KEY)", label) - } - } - - setupToken := "" - if mgr.State().Settings.AdminPasswordHash == "" { - // RP_SETUP_TOKEN: the installing app made the token and opens the - // setup with it. Only for first run; reset-password mints its own. - setupToken = os.Getenv("RP_SETUP_TOKEN") - if len(setupToken) < 16 { - setupToken = auth.NewSetupToken() - } - printSetupBanner(setupToken, server.AdminURL(mgr.State())) - } - - srv := server.New(version, client, mgr, setupToken, secrets) - srv.CaddyVersion = caddyVersion() - srv.ListenEnv = listenEnv - srv.IssuanceLog = issuanceLog - srv.Observe = func(ctx context.Context, name string) certs.Observation { - return certs.Observe(ctx, "unix", *probeSock, name) - } - if v := os.Getenv("RP_TRUST_PORT"); v != "" { - p, err := strconv.Atoi(v) - if err != nil || p < 1 || p > 65535 { - log.Fatalf("[admin] RP_TRUST_PORT=%q is not a port", v) - } - srv.TrustPort = p - } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) - defer stop() - srv.CheckProvider = server.CheckProvider - srv.DNSRefused = func() string { - c, err := dns.Load(secrets) - if err != nil { - return "" - } - return refused.Refused(c) - } - go certs.Watch(ctx, mgr, srv.Observe, client.Stop) - go srv.WatchToken(ctx, 24*time.Hour) - go keepLoaded(ctx, mgr, client) - go answering.watch(ctx, mgr) - // Every name's target, checked in the background; its history is not - // configuration and stays out of backups. - mon := uptime.Open(filepath.Join(*stateDir, "uptime.json")) - mon.Notify = srv.Alert - srv.Uptime = mon - go mon.Run(ctx, func() []uptime.Target { - st := mgr.State() - var out []uptime.Target - for _, h := range st.Hosts { - out = append(out, uptime.Target{ID: h.ID, Name: h.Names(st.Settings)[0], Address: h.Target.Address, Port: h.Target.Port, - Skip: h.DeletedAt != nil || h.Unwatched || st.Settings.UptimeOff}) - } - return out - }) - if err := srv.ServeUnix(ctx, *listen); err != nil { - log.Fatalf("[admin] %v", err) - } - // The history's last save, before the process ends. - select { - case <-mon.Done(): - case <-time.After(5 * time.Second): - } -} - -// reloadWhenUp waits for Caddy's socket, however long Caddy takes to start. -// A config Caddy rejects is a bug, not a timing issue, and fails at once. -func reloadWhenUp(m *app.Manager) error { - for attempt := 1; ; attempt++ { - err := m.Reload() - if err == nil { - return nil - } - var ce *app.CaddyError - if errors.As(err, &ce) && !isDialError(ce.Err) { - return err - } - // Caddy's socket appearing a moment later is normal and not - // worth a line unless it drags on. - if attempt == 5 || attempt%30 == 0 { - log.Printf("[admin] still waiting for Caddy (%v)", err) - } - time.Sleep(time.Second) - } -} - -// keepLoaded puts the configuration back when Caddy restarted on its own -// and came up with only the bootstrap config. -func keepLoaded(ctx context.Context, m *app.Manager, c *caddy.Client) { - t := time.NewTicker(5 * time.Second) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - } - if ok, err := c.Loaded(); err != nil || ok { - continue - } - if err := m.Reload(); err != nil { - log.Printf("[admin] Caddy restarted; loading the configuration failed: %v", err) - continue - } - log.Printf("[admin] Caddy restarted; configuration loaded again") - } -} - -func isDialError(err error) bool { - var se *os.SyscallError - return errors.As(err, &se) || errors.Is(err, os.ErrNotExist) -} - -// resetPassword is the `reverse-proxy reset-password` command: it asks the -// running admin, over its unix socket, to forget the password and print a -// new setup token. -func resetPassword(sock string) int { - c := socketClient(sock) - resp, err := c.Post("http://admin/internal/reset", "application/json", nil) - if err != nil { - fmt.Fprintf(os.Stderr, "cannot reach the admin at %s: %v\n", sock, err) - return 1 - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - var out struct { - SetupToken string `json:"setupToken"` - AdminURL string `json:"adminUrl"` - } - if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &out) != nil || out.SetupToken == "" { - fmt.Fprintf(os.Stderr, "reset failed: %s %s\n", resp.Status, body) - return 1 - } - fmt.Print(setupText("Password cleared. Set up again", out.AdminURL, out.SetupToken)) - return 0 -} - -// adminPlace is where the admin's pages are when nothing better is known: -// the container cannot see which host port its 8443 is published on. -const adminPlace = "https://:8443" - -// setupText is the hand-off to the setup page, for the first-run banner and -// reset-password alike. The link carries the token in the fragment, which -// browsers never send to the server, so it cannot land in any log. adminURL -// is RP_ADMIN_URL or the address the account last used; "" when neither. -func setupText(lead, adminURL, token string) string { - where := adminURL - tail := "" - if where == "" { - where = adminPlace - lead += " at port 8443 of this machine, over https" - tail = "RP_ADMIN_URL in the compose makes this link exact.\n" - } else { - lead += " here" - } - return fmt.Sprintf("%s:\n\n %s/#token=%s\n\nWithout the link, the page asks for the setup token: %s\n%s", lead, where, token, token, tail) -} - -// printSetupBanner tells the person how to start. -func printSetupBanner(token, adminURL string) { - log.Printf("[admin] ") - for _, line := range strings.Split(strings.TrimRight(setupText("First run. Set up", adminURL, token), "\n"), "\n") { - log.Printf("[admin] %s", line) - } - log.Printf("[admin] ") -} - -// caddyVersion asks the Caddy binary once; it is shown on the About page. -func caddyVersion() string { - out, err := exec.Command("/usr/local/bin/caddy", "version").Output() - if err != nil { - return "" - } - f := strings.Fields(string(out)) - if len(f) == 0 { - return "" - } - return f[0] -} diff --git a/admin/cmd/reverse-proxy/main_test.go b/admin/cmd/reverse-proxy/main_test.go deleted file mode 100644 index 4ba1647..0000000 --- a/admin/cmd/reverse-proxy/main_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestSetupText(t *testing.T) { - known := setupText("Password cleared. Set up again", "https://192.168.1.5:8444", "WXFV-FVUW-3JVG-FUE7") - t.Logf("\n%s", known) - if !strings.Contains(known, " https://192.168.1.5:8444/#token=WXFV-FVUW-3JVG-FUE7\n") || strings.Contains(known, "RP_ADMIN_URL") || strings.Contains(known, "<") { - t.Fatalf("known address:\n%s", known) - } - unknown := setupText("First run. Set up", "", "WXFV-FVUW-3JVG-FUE7") - t.Logf("\n%s", unknown) - if !strings.Contains(unknown, "port 8443 of this machine") || !strings.Contains(unknown, adminPlace+"/#token=") || !strings.Contains(unknown, "RP_ADMIN_URL") { - t.Fatalf("unknown address:\n%s", unknown) - } -} diff --git a/admin/go.mod b/admin/go.mod deleted file mode 100644 index 2426bb9..0000000 --- a/admin/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module github.com/daemonless/reverse-proxy/admin - -go 1.26.0 - -require ( - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - golang.org/x/crypto v0.57.0 -) - -require golang.org/x/sys v0.48.0 // indirect diff --git a/admin/go.sum b/admin/go.sum deleted file mode 100644 index 5773888..0000000 --- a/admin/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/admin/internal/app/manager.go b/admin/internal/app/manager.go deleted file mode 100644 index 3eeb918..0000000 --- a/admin/internal/app/manager.go +++ /dev/null @@ -1,138 +0,0 @@ -// Package app holds the admin's one write path: change state, render, load -// into Caddy, and only then persist. -package app - -import ( - "fmt" - "sync" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// Loader pushes a complete configuration into Caddy. -type Loader interface { - Load(cfg any) error -} - -// CaddyError is a configuration Caddy refused; the previous one stays live. -type CaddyError struct{ Err error } - -func (e *CaddyError) Error() string { return "caddy rejected the configuration: " + e.Err.Error() } -func (e *CaddyError) Unwrap() error { return e.Err } - -// Manager serializes every change to state and Caddy. -type Manager struct { - mu sync.Mutex - store *state.Store - caddy Loader - render func(*state.State) any - st *state.State - now func() time.Time - // Prepare, when set, runs before each render with the state about to - // be applied: the place to make files the config will reference (the - // root CA). It must be idempotent. - Prepare func(*state.State) error -} - -// New loads the state file. Nothing is pushed to Caddy until Reload. -func New(store *state.Store, caddy Loader, render func(*state.State) any) (*Manager, error) { - st, err := store.Load() - if err != nil { - return nil, err - } - return &Manager{store: store, caddy: caddy, render: render, st: st, now: time.Now}, nil -} - -// State returns a copy; callers never see the live object. -func (m *Manager) State() *state.State { - m.mu.Lock() - defer m.mu.Unlock() - return m.st.Clone() -} - -// Reload renders the current state and loads it (startup, or after Caddy -// restarted underneath us). -func (m *Manager) Reload() error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Prepare != nil { - if err := m.Prepare(m.st); err != nil { - return err - } - } - if err := m.caddy.Load(m.render(m.st)); err != nil { - return &CaddyError{err} - } - return nil -} - -// Apply runs mutate on a copy of the state, renders it, loads it into Caddy -// and, only if Caddy accepted it, persists and adopts it. A validation error -// from mutate is returned untouched so the API can show the field. -func (m *Manager) Apply(mutate func(*state.State) error) error { - m.mu.Lock() - defer m.mu.Unlock() - next := m.st.Clone() - next.Purge(m.now()) - if err := mutate(next); err != nil { - return err - } - if m.Prepare != nil { - if err := m.Prepare(next); err != nil { - return err - } - } - if err := m.caddy.Load(m.render(next)); err != nil { - return &CaddyError{err} - } - if err := m.store.Save(next); err != nil { - // Caddy already runs the new config; keep it in memory and report. - m.st = next - return fmt.Errorf("saving state: %w", err) - } - m.st = next - return nil -} - -// Note records a change that Caddy's config does not depend on (a key's -// last use): saved, never rendered or loaded. -func (m *Manager) Note(mutate func(*state.State)) error { - m.mu.Lock() - defer m.mu.Unlock() - next := m.st.Clone() - mutate(next) - if err := m.store.Save(next); err != nil { - return fmt.Errorf("saving state: %w", err) - } - m.st = next - return nil -} - -// Restore re-reads the state file (after a backup was written to disk), -// renders it and loads it into Caddy. On failure the previous in-memory -// state stays live. adapt, when set, fits the restored state to this -// machine first, and what it changes is saved. -func (m *Manager) Restore(adapt func(*state.State) bool) error { - m.mu.Lock() - defer m.mu.Unlock() - st, err := m.store.Load() - if err != nil { - return err - } - if adapt != nil && adapt(st) { - if err := m.store.Save(st); err != nil { - return fmt.Errorf("saving state: %w", err) - } - } - if m.Prepare != nil { - if err := m.Prepare(st); err != nil { - return err - } - } - if err := m.caddy.Load(m.render(st)); err != nil { - return &CaddyError{err} - } - m.st = st - return nil -} diff --git a/admin/internal/app/manager_test.go b/admin/internal/app/manager_test.go deleted file mode 100644 index 2ae5dbd..0000000 --- a/admin/internal/app/manager_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package app - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -type fakeLoader struct { - fail error - loads int - last any -} - -func (f *fakeLoader) Load(cfg any) error { - f.loads++ - f.last = cfg - return f.fail -} - -func render(st *state.State) any { return map[string]any{"domain": st.Settings.Domain} } - -func newManager(t *testing.T, l Loader) (*Manager, string) { - p := filepath.Join(t.TempDir(), "state.json") - m, err := New(state.NewStore(p), l, render) - if err != nil { - t.Fatal(err) - } - return m, p -} - -func TestApplyPersistsAfterCaddyAccepts(t *testing.T) { - l := &fakeLoader{} - m, p := newManager(t, l) - err := m.Apply(func(st *state.State) error { st.Settings.Domain = "x.lan"; return nil }) - if err != nil { - t.Fatal(err) - } - if l.loads != 1 || l.last.(map[string]any)["domain"] != "x.lan" { - t.Fatalf("caddy got %v", l.last) - } - raw, _ := os.ReadFile(p) - if !strings.Contains(string(raw), `"domain": "x.lan"`) { - t.Fatalf("state not persisted: %s", raw) - } - if m.State().Settings.Domain != "x.lan" { - t.Fatal("live state not adopted") - } -} - -func TestApplyRollsBackWhenCaddyRejects(t *testing.T) { - l := &fakeLoader{fail: errors.New("bad")} - m, p := newManager(t, l) - err := m.Apply(func(st *state.State) error { st.Settings.Domain = "x.lan"; return nil }) - var ce *CaddyError - if !errors.As(err, &ce) { - t.Fatalf("got %v, want CaddyError", err) - } - if m.State().Settings.Domain != "" { - t.Fatal("rejected change leaked into live state") - } - raw, _ := os.ReadFile(p) - if strings.Contains(string(raw), "x.lan") { - t.Fatal("rejected change was persisted") - } -} - -func TestApplyValidationErrorSkipsCaddy(t *testing.T) { - l := &fakeLoader{} - m, _ := newManager(t, l) - want := &state.FieldError{Field: "name", Msg: "nope"} - err := m.Apply(func(*state.State) error { return want }) - if err != want || l.loads != 0 { - t.Fatalf("err=%v loads=%d", err, l.loads) - } -} - -func TestStateIsACopy(t *testing.T) { - m, _ := newManager(t, &fakeLoader{}) - m.State().Settings.Domain = "leak" - if m.State().Settings.Domain != "" { - t.Fatal("State() exposed the live object") - } -} diff --git a/admin/internal/auth/auth_test.go b/admin/internal/auth/auth_test.go deleted file mode 100644 index ba682d6..0000000 --- a/admin/internal/auth/auth_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package auth - -import ( - "testing" - "time" -) - -func TestPasswordRoundTrip(t *testing.T) { - h, err := HashPassword("correct horse battery") - if err != nil { - t.Fatal(err) - } - if !VerifyPassword(h, "correct horse battery") { - t.Fatal("right password rejected") - } - if VerifyPassword(h, "wrong") || VerifyPassword("garbage", "x") || VerifyPassword("", "") { - t.Fatal("wrong password accepted") - } - if CheckPasswordStrength("") == nil || CheckPasswordStrength("short") != nil { - t.Fatal("strength rule wrong") - } -} - -func TestSetupTokenNormalization(t *testing.T) { - tok := NewSetupToken() - if len(tok) != 19 || tok[4] != '-' { - t.Fatalf("token shape %q", tok) - } - typed := " " + tok[:9] + " " + tok[10:] + " " - if !EqualTokens(NormalizeSetupToken(tok), NormalizeSetupToken(typed)) { - t.Fatalf("normalization failed: %q vs %q", tok, typed) - } - if EqualTokens(NormalizeSetupToken(tok), NormalizeSetupToken(NewSetupToken())) { - t.Fatal("two tokens compared equal") - } -} - -func TestSessions(t *testing.T) { - now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) - s := NewSessions() - s.now = func() time.Time { return now } - admin := s.Create(KindAdmin) - setup := s.Create(KindSetup) - if k, ok := s.Lookup(admin); !ok || k != KindAdmin { - t.Fatal("admin session missing") - } - now = now.Add(16 * time.Minute) - if _, ok := s.Lookup(setup); ok { - t.Fatal("setup session outlived its TTL") - } - if _, ok := s.Lookup(admin); !ok { - t.Fatal("admin session expired early") - } - now = now.Add(13 * time.Hour) - if _, ok := s.Lookup(admin); ok { - t.Fatal("idle admin session not expired") - } - a2 := s.Create(KindAdmin) - for i := 0; i < 8; i++ { // keep it active past the absolute limit - now = now.Add(23 * time.Hour) - s.Lookup(a2) - } - if _, ok := s.Lookup(a2); ok { - t.Fatal("absolute limit not enforced") - } - if _, ok := s.Lookup("nope"); ok { - t.Fatal("unknown id accepted") - } -} - -func TestSessionsPersist(t *testing.T) { - path := t.TempDir() + "/sessions.json" - a := NewPersistentSessions(path) - admin := a.Create(KindAdmin) - setup := a.Create(KindSetup) - b := NewPersistentSessions(path) - if k, ok := b.Lookup(admin); !ok || k != KindAdmin { - t.Fatal("admin session did not survive a restart") - } - if _, ok := b.Lookup(setup); ok { - t.Fatal("setup session was persisted") - } - b.DeleteAll() - if _, ok := NewPersistentSessions(path).Lookup(admin); ok { - t.Fatal("DeleteAll not persisted") - } -} - -func TestLimiter(t *testing.T) { - now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) - l := NewLimiter() - l.now = func() time.Time { return now } - for i := 0; i < 4; i++ { - l.Fail("a") - } - if l.Wait("a") != 0 { - t.Fatal("locked too early") - } - l.Fail("a") - if w := l.Wait("a"); w != 2*time.Second { - t.Fatalf("first delay %v", w) - } - l.Fail("a") - if w := l.Wait("a"); w != 4*time.Second { - t.Fatalf("second delay %v", w) - } - if l.Wait("b") != 0 { - t.Fatal("another client was locked by a's failures") - } - for i := 0; i < 20; i++ { - l.Fail("a") - } - if w := l.Wait("a"); w != maxDelay { - t.Fatalf("cap %v", w) - } - l.Succeed("a") - if l.Wait("a") != 0 { - t.Fatal("success did not clear") - } - // Rotating addresses hits the global backstop. - for i := 0; i < globalFreeAttempts; i++ { - l.Fail("x" + string(rune('A'+i%26)) + string(rune('a'+i/26))) - } - if l.Wait("fresh") == 0 { - t.Fatal("global backstop did not engage") - } -} diff --git a/admin/internal/auth/limiter.go b/admin/internal/auth/limiter.go deleted file mode 100644 index 8c8bca7..0000000 --- a/admin/internal/auth/limiter.go +++ /dev/null @@ -1,103 +0,0 @@ -package auth - -import ( - "sync" - "time" -) - -// Limiter slows down password guessing. Failures are counted per client -// address, so one hostile device cannot lock the admin out from another; -// a global counter backstops attackers who rotate addresses. After a few -// failures every attempt must wait, and the wait doubles. -type Limiter struct { - mu sync.Mutex - clients map[string]*counter - global counter - now func() time.Time -} - -type counter struct { - failures int - until time.Time - seen time.Time -} - -const ( - freeAttempts = 5 - globalFreeAttempts = 50 - firstDelay = 2 * time.Second - maxDelay = 5 * time.Minute - forget = time.Hour -) - -// NewLimiter returns an open limiter. -func NewLimiter() *Limiter { return &Limiter{clients: map[string]*counter{}, now: time.Now} } - -// Wait returns how long client must wait before another attempt is -// accepted; zero means go ahead. -func (l *Limiter) Wait(client string) time.Duration { - l.mu.Lock() - defer l.mu.Unlock() - now := l.now() - d := l.global.until.Sub(now) - if c, ok := l.clients[client]; ok { - if cd := c.until.Sub(now); cd > d { - d = cd - } - } - if d < 0 { - return 0 - } - return d -} - -// Fail records a failed attempt by client. -func (l *Limiter) Fail(client string) { - l.mu.Lock() - defer l.mu.Unlock() - now := l.now() - l.sweep(now) - c, ok := l.clients[client] - if !ok { - c = &counter{} - l.clients[client] = c - } - c.seen = now - c.failures++ - if c.failures >= freeAttempts { - c.until = now.Add(delay(c.failures - freeAttempts)) - } - l.global.failures++ - l.global.seen = now - if l.global.failures >= globalFreeAttempts { - l.global.until = now.Add(delay(l.global.failures - globalFreeAttempts)) - } -} - -// Succeed clears the client's counter (the global one decays on its own). -func (l *Limiter) Succeed(client string) { - l.mu.Lock() - delete(l.clients, client) - l.mu.Unlock() -} - -func delay(n int) time.Duration { - d := firstDelay << uint(n) - if d > maxDelay || d <= 0 { - return maxDelay - } - return d -} - -// sweep forgets clients not seen for an hour, and the global counter -// once an hour has passed without a failure. -func (l *Limiter) sweep(now time.Time) { - for k, c := range l.clients { - if now.Sub(c.seen) > forget { - delete(l.clients, k) - } - } - if l.global.failures > 0 && now.Sub(l.global.seen) > forget { - l.global = counter{} - } -} diff --git a/admin/internal/auth/password.go b/admin/internal/auth/password.go deleted file mode 100644 index 4eece78..0000000 --- a/admin/internal/auth/password.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package auth holds the admin's credentials: the password hash, sessions, -// the one-time setup token, API tokens and the login limiter. -package auth - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/base64" - "errors" - "fmt" - "strings" - - "golang.org/x/crypto/argon2" -) - -// argon2id parameters (OWASP's first recommendation). -const ( - argonTime = 1 - argonMemory = 64 * 1024 - argonThreads = 4 - argonKeyLen = 32 - saltLen = 16 -) - -// CheckPasswordStrength returns a plain message when a password is unusable. -// Only empty is: the page warns about a weak one and the person decides, -// as Fedora's installer does. Sign-in is rate-limited, which is what stops -// guessing. (A backup's passphrase has its own rule: a file can be -// attacked offline.) -func CheckPasswordStrength(pw string) error { - if pw == "" { - return errors.New("enter a password") - } - if len(pw) > 1024 { - return errors.New("that password is too long") - } - return nil -} - -// HashPassword returns a PHC-formatted argon2id hash. -func HashPassword(pw string) (string, error) { - salt := make([]byte, saltLen) - if _, err := rand.Read(salt); err != nil { - return "", err - } - key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) - return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, - base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil -} - -// VerifyPassword reports whether pw matches a hash from HashPassword. -func VerifyPassword(hash, pw string) bool { - parts := strings.Split(hash, "$") - if len(parts) != 6 || parts[1] != "argon2id" { - return false - } - var m, t uint32 - var p uint8 - if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { - return false - } - salt, err := base64.RawStdEncoding.DecodeString(parts[4]) - if err != nil { - return false - } - want, err := base64.RawStdEncoding.DecodeString(parts[5]) - if err != nil { - return false - } - got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want))) - return subtle.ConstantTimeCompare(got, want) == 1 -} diff --git a/admin/internal/auth/session.go b/admin/internal/auth/session.go deleted file mode 100644 index 136369f..0000000 --- a/admin/internal/auth/session.go +++ /dev/null @@ -1,145 +0,0 @@ -package auth - -import ( - "encoding/json" - "os" - "sync" - "time" -) - -// Session lifetimes from the plan: 12 hours idle, 7 days absolute. -const ( - SessionIdle = 12 * time.Hour - SessionAbsolute = 7 * 24 * time.Hour - // SetupSessionTTL bounds how long a verified setup token stays usable - // before a password is chosen. - SetupSessionTTL = 15 * time.Minute -) - -// Kind separates a logged-in admin from someone mid-setup. -type Kind int - -const ( - KindAdmin Kind = iota + 1 - KindSetup -) - -type session struct { - Kind Kind `json:"kind"` - Created time.Time `json:"created"` - LastSeen time.Time `json:"lastSeen"` -} - -// Sessions is the session store. Admin sessions are written to a file so -// a restart (every update) does not sign the person out; setup sessions -// are short-lived and stay in memory. -type Sessions struct { - mu sync.Mutex - m map[string]*session - now func() time.Time - path string // "" = memory only -} - -// NewSessions returns an empty store kept in memory. -func NewSessions() *Sessions { - return &Sessions{m: map[string]*session{}, now: time.Now} -} - -// NewPersistentSessions loads admin sessions from path and saves them -// back on every change. A missing or unreadable file is an empty store. -func NewPersistentSessions(path string) *Sessions { - s := &Sessions{m: map[string]*session{}, now: time.Now, path: path} - if raw, err := os.ReadFile(path); err == nil { - _ = json.Unmarshal(raw, &s.m) - s.sweep(s.now()) - } - return s -} - -// save writes admin sessions (never setup ones). Called with mu held. -func (s *Sessions) save() { - if s.path == "" { - return - } - keep := map[string]*session{} - for id, se := range s.m { - if se.Kind == KindAdmin { - keep[id] = se - } - } - data, err := json.Marshal(keep) - if err != nil { - return - } - tmp := s.path + ".tmp" - if os.WriteFile(tmp, data, 0o600) == nil { - _ = os.Rename(tmp, s.path) - } -} - -// Create returns a new session id. -func (s *Sessions) Create(kind Kind) string { - id := NewSecret(32) - now := s.now() - s.mu.Lock() - s.m[id] = &session{Kind: kind, Created: now, LastSeen: now} - s.sweep(now) - s.save() - s.mu.Unlock() - return id -} - -// Lookup returns the session's kind and refreshes its idle timer. -func (s *Sessions) Lookup(id string) (Kind, bool) { - s.mu.Lock() - defer s.mu.Unlock() - se, ok := s.m[id] - if !ok { - return 0, false - } - now := s.now() - if s.expired(se, now) { - delete(s.m, id) - return 0, false - } - // Refresh the idle timer, and persist it at most once a minute so a - // busy UI does not rewrite the file on every request. - if now.Sub(se.LastSeen) > time.Minute { - se.LastSeen = now - s.save() - } else { - se.LastSeen = now - } - return se.Kind, true -} - -// Delete ends a session. -func (s *Sessions) Delete(id string) { - s.mu.Lock() - delete(s.m, id) - s.save() - s.mu.Unlock() -} - -// DeleteAll ends every session (password change, reset). -func (s *Sessions) DeleteAll() { - s.mu.Lock() - s.m = map[string]*session{} - s.save() - s.mu.Unlock() -} - -func (s *Sessions) expired(se *session, now time.Time) bool { - if se.Kind == KindSetup { - return now.Sub(se.Created) > SetupSessionTTL - } - return now.Sub(se.LastSeen) > SessionIdle || now.Sub(se.Created) > SessionAbsolute -} - -func (s *Sessions) sweep(now time.Time) { - for id, se := range s.m { - if s.expired(se, now) { - delete(s.m, id) - } - } -} diff --git a/admin/internal/auth/token.go b/admin/internal/auth/token.go deleted file mode 100644 index d1c3d7a..0000000 --- a/admin/internal/auth/token.go +++ /dev/null @@ -1,56 +0,0 @@ -package auth - -import ( - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base32" - "encoding/hex" - "strings" -) - -// alphabet avoids characters people confuse when retyping (0/O, 1/I/L). -const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" - -// NewSetupToken makes a one-time token like "K7RT-9XPM-2WQF-HJ4N": easy to -// read from a log and type into a phone. -func NewSetupToken() string { - var b [16]byte - rand.Read(b[:]) - var sb strings.Builder - for i, c := range b { - if i > 0 && i%4 == 0 { - sb.WriteByte('-') - } - sb.WriteByte(alphabet[int(c)%len(alphabet)]) - } - return sb.String() -} - -// NormalizeSetupToken forgives case, spaces and missing dashes. -func NormalizeSetupToken(s string) string { - s = strings.ToUpper(strings.TrimSpace(s)) - s = strings.NewReplacer(" ", "", "-", "", "0", "O", "1", "I", "L", "I").Replace(s) - // Our alphabet has no O or I either; map them to the look-alikes we do use. - s = strings.NewReplacer("O", "Q", "I", "J").Replace(s) - return s -} - -// EqualTokens compares two normalized tokens in constant time. -func EqualTokens(a, b string) bool { - return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 -} - -// NewSecret returns a random URL-safe secret of n bytes. -func NewSecret(n int) string { - b := make([]byte, n) - rand.Read(b) - return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)) -} - -// HashSecret is for high-entropy random tokens, where a plain SHA-256 is -// the right tool (argon2 is for things people choose). -func HashSecret(s string) string { - sum := sha256.Sum256([]byte(s)) - return hex.EncodeToString(sum[:]) -} diff --git a/admin/internal/backup/backup.go b/admin/internal/backup/backup.go deleted file mode 100644 index b603ea2..0000000 --- a/admin/internal/backup/backup.go +++ /dev/null @@ -1,182 +0,0 @@ -// Package backup packs everything needed to bring a proxy back (state, -// secrets, the root CA) into one file, encrypted with a passphrase. The -// format is deliberately dull: a tar of the files, sealed with -// XChaCha20-Poly1305 under an argon2id-derived key. -package backup - -import ( - "archive/tar" - "bytes" - "crypto/rand" - "encoding/binary" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" - - "golang.org/x/crypto/argon2" - "golang.org/x/crypto/chacha20poly1305" -) - -const ( - magic = "RPBK1" // reverse-proxy backup, format 1 - saltLen = 16 - argonTime = 2 - argonMem = 64 * 1024 - argonLanes = 4 -) - -// Files are the paths (relative to the state directory) a backup carries. -// The setup certificate is deliberately left out: it is regenerated. -var Files = []string{"state.json", "secrets/ca-root.crt", "secrets/ca-root.key", "secrets/dns.json"} - -// Create writes an encrypted backup of dir to w. -func Create(w io.Writer, dir, passphrase string) error { - // Optional: with no passphrase the file is sealed under the empty one, - // which is to say anyone with the file can open it. Short ones are - // refused; "" is a choice, "abc" is a mistake. - if passphrase != "" && len(passphrase) < 8 { - return errors.New("use a passphrase of at least 8 characters, or none") - } - var tarBuf bytes.Buffer - tw := tar.NewWriter(&tarBuf) - for _, rel := range Files { - data, err := os.ReadFile(filepath.Join(dir, rel)) - if errors.Is(err, os.ErrNotExist) { - continue // no CA yet, for example - } - if err != nil { - return err - } - if err := tw.WriteHeader(&tar.Header{Name: rel, Mode: 0o600, Size: int64(len(data)), ModTime: time.Now()}); err != nil { - return err - } - if _, err := tw.Write(data); err != nil { - return err - } - } - if err := tw.Close(); err != nil { - return err - } - - salt := make([]byte, saltLen) - if _, err := rand.Read(salt); err != nil { - return err - } - key := argon2.IDKey([]byte(passphrase), salt, argonTime, argonMem, argonLanes, chacha20poly1305.KeySize) - aead, err := chacha20poly1305.NewX(key) - if err != nil { - return err - } - nonce := make([]byte, aead.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return err - } - sealed := aead.Seal(nil, nonce, tarBuf.Bytes(), []byte(magic)) - - // magic | salt | nonce | len | ciphertext - if _, err := io.WriteString(w, magic); err != nil { - return err - } - for _, part := range [][]byte{salt, nonce} { - if _, err := w.Write(part); err != nil { - return err - } - } - if err := binary.Write(w, binary.BigEndian, uint64(len(sealed))); err != nil { - return err - } - _, err = w.Write(sealed) - return err -} - -// ErrWrongPassphrase is returned when the file does not open. -var ErrWrongPassphrase = errors.New("that passphrase doesn't open this backup") - -// ErrNotABackup is returned for a file in another format. -var ErrNotABackup = errors.New("this isn't a Daemonless Reverse Proxy backup file") - -// Open decrypts a backup and returns its files by relative path. -func Open(r io.Reader, passphrase string) (map[string][]byte, error) { - head := make([]byte, len(magic)+saltLen+chacha20poly1305.NonceSizeX+8) - if _, err := io.ReadFull(r, head); err != nil || string(head[:len(magic)]) != magic { - return nil, ErrNotABackup - } - salt := head[len(magic) : len(magic)+saltLen] - nonce := head[len(magic)+saltLen : len(magic)+saltLen+chacha20poly1305.NonceSizeX] - n := binary.BigEndian.Uint64(head[len(head)-8:]) - if n > 64<<20 { - return nil, ErrNotABackup - } - sealed := make([]byte, n) - if _, err := io.ReadFull(r, sealed); err != nil { - return nil, ErrNotABackup - } - key := argon2.IDKey([]byte(passphrase), salt, argonTime, argonMem, argonLanes, chacha20poly1305.KeySize) - aead, err := chacha20poly1305.NewX(key) - if err != nil { - return nil, err - } - plain, err := aead.Open(nil, nonce, sealed, []byte(magic)) - if err != nil { - return nil, ErrWrongPassphrase - } - out := map[string][]byte{} - tr := tar.NewReader(bytes.NewReader(plain)) - for { - h, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, fmt.Errorf("reading backup: %w", err) - } - if !allowed(h.Name) { - return nil, fmt.Errorf("backup contains an unexpected file %q", h.Name) - } - data, err := io.ReadAll(io.LimitReader(tr, 16<<20)) - if err != nil { - return nil, err - } - out[h.Name] = data - } - if _, ok := out["state.json"]; !ok { - return nil, ErrNotABackup - } - return out, nil -} - -func allowed(name string) bool { - for _, f := range Files { - if f == name { - return true - } - } - return false -} - -// Write puts the files from Open into dir with safe modes. Existing files -// are kept beside them with a timestamp so a restore is never destructive. -func Write(dir string, files map[string][]byte) error { - stamp := time.Now().UTC().Format("20060102T150405Z") - for rel, data := range files { - p := filepath.Join(dir, rel) - if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { - return err - } - if _, err := os.Stat(p); err == nil { - _ = os.Rename(p, p+".before-restore-"+stamp) - } - mode := os.FileMode(0o600) - if strings.HasSuffix(rel, ".crt") { - mode = 0o644 - } - if err := os.WriteFile(p, data, mode); err != nil { - return err - } - } - return nil -} diff --git a/admin/internal/backup/backup_test.go b/admin/internal/backup/backup_test.go deleted file mode 100644 index d98ce64..0000000 --- a/admin/internal/backup/backup_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package backup - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRoundTrip(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "secrets"), 0o700) - os.WriteFile(filepath.Join(dir, "state.json"), []byte(`{"schemaVersion":1}`), 0o600) - os.WriteFile(filepath.Join(dir, "secrets/ca-root.crt"), []byte("CERT"), 0o644) - os.WriteFile(filepath.Join(dir, "secrets/ca-root.key"), []byte("KEY"), 0o600) - os.WriteFile(filepath.Join(dir, "secrets/setup.key"), []byte("NOT INCLUDED"), 0o600) - - var buf bytes.Buffer - if err := Create(&buf, dir, "correct horse battery"); err != nil { - t.Fatal(err) - } - if bytes.Contains(buf.Bytes(), []byte("schemaVersion")) || bytes.Contains(buf.Bytes(), []byte("KEY")) { - t.Fatal("backup is not encrypted") - } - - if _, err := Open(bytes.NewReader(buf.Bytes()), "wrong"); !errors.Is(err, ErrWrongPassphrase) { - t.Fatalf("wrong passphrase: %v", err) - } - if _, err := Open(strings.NewReader("garbage file"), "x"); !errors.Is(err, ErrNotABackup) { - t.Fatalf("garbage: %v", err) - } - files, err := Open(bytes.NewReader(buf.Bytes()), "correct horse battery") - if err != nil { - t.Fatal(err) - } - if string(files["secrets/ca-root.key"]) != "KEY" || len(files) != 3 { - t.Fatalf("files: %v", files) - } - - out := t.TempDir() - os.MkdirAll(filepath.Join(out, "secrets"), 0o700) - os.WriteFile(filepath.Join(out, "state.json"), []byte("old"), 0o600) - if err := Write(out, files); err != nil { - t.Fatal(err) - } - got, _ := os.ReadFile(filepath.Join(out, "state.json")) - if string(got) != `{"schemaVersion":1}` { - t.Fatalf("restored state: %s", got) - } - entries, _ := os.ReadDir(out) - var kept bool - for _, e := range entries { - if strings.HasPrefix(e.Name(), "state.json.before-restore-") { - kept = true - } - } - if !kept { - t.Fatal("previous state not kept") - } - info, _ := os.Stat(filepath.Join(out, "secrets/ca-root.key")) - if info.Mode().Perm() != 0o600 { - t.Fatalf("key mode %v", info.Mode().Perm()) - } -} - -func TestShortPassphraseRefused(t *testing.T) { - if err := Create(&bytes.Buffer{}, t.TempDir(), "short"); err == nil { - t.Fatal("short passphrase accepted") - } -} - -// No passphrase is a choice: the file opens with none, and not with one. -func TestNoPassphraseRoundTrip(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "secrets"), 0o700) - os.WriteFile(filepath.Join(dir, "state.json"), []byte(`{"schemaVersion":1}`), 0o600) - os.WriteFile(filepath.Join(dir, "secrets/ca-root.crt"), []byte("CERT"), 0o644) - os.WriteFile(filepath.Join(dir, "secrets/ca-root.key"), []byte("KEY"), 0o600) - var buf bytes.Buffer - if err := Create(&buf, dir, ""); err != nil { - t.Fatalf("no passphrase: %v", err) - } - files, err := Open(bytes.NewReader(buf.Bytes()), "") - if err != nil || string(files["secrets/ca-root.key"]) != "KEY" { - t.Fatalf("open with none: %v", err) - } - if _, err := Open(bytes.NewReader(buf.Bytes()), "something-else"); err == nil { - t.Fatal("opened with a passphrase it was not sealed with") - } -} diff --git a/admin/internal/ca/ca.go b/admin/internal/ca/ca.go deleted file mode 100644 index 5f1423c..0000000 --- a/admin/internal/ca/ca.go +++ /dev/null @@ -1,182 +0,0 @@ -// Package ca makes the private-mode root certificate. The admin owns the -// root so it can restrict it to the user's domain: a stolen key then cannot -// mint certificates for anything else. Caddy issues the intermediate and -// the leaves under it. -package ca - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "crypto/x509" - "crypto/x509/pkix" - "encoding/hex" - "encoding/pem" - "errors" - "fmt" - "math/big" - "os" - "path/filepath" - "strings" - "time" -) - -// Root describes the root certificate on disk. -type Root struct { - CertPath string - KeyPath string - Domain string - Fingerprint string // SHA-256 of the DER, colon-separated, upper case - NotAfter time.Time - Created bool // true when Ensure just generated it -} - -const ( - certFile = "ca-root.crt" - keyFile = "ca-root.key" - lifetime = 10 * 365 * 24 * time.Hour -) - -// Ensure returns the root for domain, generating it on first use or when -// the domain changed. The old pair is kept beside it with a timestamp, and -// an ending that comes back gets its kept root back: the devices that -// trusted it still do, so a round trip costs nobody a re-trust. -func Ensure(dir, domain string) (*Root, error) { - domain = strings.ToLower(strings.TrimSpace(domain)) - if domain == "" { - return nil, errors.New("no domain") - } - certPath := filepath.Join(dir, certFile) - keyPath := filepath.Join(dir, keyFile) - - if r, err := Load(dir); err == nil { - if r.Domain == domain { - return r, nil - } - stamp := time.Now().UTC().Format("20060102T150405Z") - _ = os.Rename(certPath, certPath+"."+r.Domain+"."+stamp) - _ = os.Rename(keyPath, keyPath+"."+r.Domain+"."+stamp) - } - if stamp := keptStamp(dir, domain); stamp != "" { - if os.Rename(certPath+"."+domain+"."+stamp, certPath) == nil && os.Rename(keyPath+"."+domain+"."+stamp, keyPath) == nil { - if r, err := Load(dir); err == nil && r.Domain == domain { - return r, nil - } - } - } - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127)) - if err != nil { - return nil, err - } - now := time.Now() - tmpl := &x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{ - CommonName: domain + " proxy root", - Organization: []string{"reverse-proxy"}, - }, - NotBefore: now.Add(-time.Hour), - NotAfter: now.Add(lifetime), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - BasicConstraintsValid: true, - IsCA: true, - // The whole point: only names under the domain may be issued. - PermittedDNSDomainsCritical: true, - PermittedDNSDomains: []string{domain}, - } - der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) - if err != nil { - return nil, err - } - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - return nil, err - } - if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil { - return nil, err - } - if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil { - return nil, err - } - r, err := Load(dir) - if err != nil { - return nil, err - } - r.Created = true - return r, nil -} - -// keptStamp is the newest kept pair for domain ("" when none): the stamp -// after "ca-root.crt.." on a file that has its key beside it. -func keptStamp(dir, domain string) string { - entries, err := os.ReadDir(dir) - if err != nil { - return "" - } - prefix := certFile + "." + domain + "." - best := "" - for _, e := range entries { - stamp, ok := strings.CutPrefix(e.Name(), prefix) - if !ok || stamp <= best { - continue - } - if _, err := os.Stat(filepath.Join(dir, keyFile+"."+domain+"."+stamp)); err == nil { - best = stamp - } - } - return best -} - -// Load reads the root on disk. -func Load(dir string) (*Root, error) { - certPath := filepath.Join(dir, certFile) - keyPath := filepath.Join(dir, keyFile) - raw, err := os.ReadFile(certPath) - if err != nil { - return nil, err - } - if _, err := os.Stat(keyPath); err != nil { - return nil, err - } - block, _ := pem.Decode(raw) - if block == nil { - return nil, fmt.Errorf("%s: not a PEM certificate", certPath) - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, err - } - if len(cert.PermittedDNSDomains) != 1 { - return nil, fmt.Errorf("%s: root is not restricted to one domain", certPath) - } - return &Root{ - CertPath: certPath, - KeyPath: keyPath, - Domain: cert.PermittedDNSDomains[0], - Fingerprint: Fingerprint(block.Bytes), - NotAfter: cert.NotAfter, - }, nil -} - -// ID is a short, stable name for this root, used as Caddy's CA id so a new -// root never shares storage with an old one. -func (r *Root) ID() string { - return "rp-" + strings.ToLower(strings.ReplaceAll(r.Fingerprint[:23], ":", "")) -} - -// Fingerprint formats a SHA-256 the way browsers show it. -func Fingerprint(der []byte) string { - sum := sha256.Sum256(der) - h := strings.ToUpper(hex.EncodeToString(sum[:])) - parts := make([]string, 0, 32) - for i := 0; i < len(h); i += 2 { - parts = append(parts, h[i:i+2]) - } - return strings.Join(parts, ":") -} diff --git a/admin/internal/ca/ca_test.go b/admin/internal/ca/ca_test.go deleted file mode 100644 index fe1528f..0000000 --- a/admin/internal/ca/ca_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package ca - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func leafSignedBy(t *testing.T, r *Root, name string) *x509.Certificate { - rootPEM, _ := os.ReadFile(r.CertPath) - rb, _ := pem.Decode(rootPEM) - root, _ := x509.ParseCertificate(rb.Bytes) - keyPEM, _ := os.ReadFile(r.KeyPath) - kb, _ := pem.Decode(keyPEM) - key, _ := x509.ParseECPrivateKey(kb.Bytes) - lk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - tmpl := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: name}, DNSNames: []string{name}, - NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} - der, err := x509.CreateCertificate(rand.Reader, tmpl, root, &lk.PublicKey, key) - if err != nil { - t.Fatal(err) - } - leaf, _ := x509.ParseCertificate(der) - return leaf -} - -func TestRootIsRestrictedToDomain(t *testing.T) { - dir := t.TempDir() - r, err := Ensure(dir, "Apps.Home.LAN") - if err != nil { - t.Fatal(err) - } - if r.Domain != "apps.home.lan" || len(r.Fingerprint) != 95 { - t.Fatalf("root %+v", r) - } - info, _ := os.Stat(r.KeyPath) - if info.Mode().Perm() != 0o600 { - t.Fatalf("key mode %v", info.Mode().Perm()) - } - rootPEM, _ := os.ReadFile(r.CertPath) - pool := x509.NewCertPool() - pool.AppendCertsFromPEM(rootPEM) - inside := leafSignedBy(t, r, "radarr.apps.home.lan") - if _, err := inside.Verify(x509.VerifyOptions{Roots: pool, DNSName: "radarr.apps.home.lan"}); err != nil { - t.Fatalf("name inside the domain rejected: %v", err) - } - outside := leafSignedBy(t, r, "evil.example.com") - if _, err := outside.Verify(x509.VerifyOptions{Roots: pool, DNSName: "evil.example.com"}); err == nil { - t.Fatal("name outside the domain was accepted") - } -} - -func TestEnsureIsStableAndRotatesOnDomainChange(t *testing.T) { - dir := t.TempDir() - a, _ := Ensure(dir, "a.lan") - b, _ := Ensure(dir, "a.lan") - if a.Fingerprint != b.Fingerprint { - t.Fatal("same domain regenerated the root") - } - c, _ := Ensure(dir, "b.lan") - if c.Fingerprint == a.Fingerprint || c.Domain != "b.lan" { - t.Fatal("domain change did not make a new root") - } - entries, _ := os.ReadDir(dir) - var kept int - for _, e := range entries { - if strings.Contains(e.Name(), ".a.lan.") { - kept++ - } - } - if kept != 2 { - t.Fatalf("old root not kept: %v", entries) - } - // Back to a.lan: the kept root returns, so devices that trusted it - // still do; b.lan's pair is kept in turn. - d, _ := Ensure(dir, "a.lan") - if d.Fingerprint != a.Fingerprint || d.Domain != "a.lan" { - t.Fatal("switching back did not restore the kept root") - } - entries, _ = os.ReadDir(dir) - var keptA, keptB int - for _, e := range entries { - if strings.Contains(e.Name(), ".a.lan.") { - keptA++ - } - if strings.Contains(e.Name(), ".b.lan.") { - keptB++ - } - } - if keptA != 0 || keptB != 2 { - t.Fatalf("kept pairs after switching back: %v", entries) - } - if _, err := Load(filepath.Join(dir, "missing")); err == nil { - t.Fatal("Load on empty dir succeeded") - } -} diff --git a/admin/internal/caddy/client.go b/admin/internal/caddy/client.go deleted file mode 100644 index fad3c5a..0000000 --- a/admin/internal/caddy/client.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package caddy talks to Caddy's admin API over its unix socket and renders -// the full configuration from the admin's state. -package caddy - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "time" -) - -// Client is an HTTP client bound to Caddy's admin unix socket. -type Client struct { - http *http.Client -} - -// NewClient returns a client for the admin socket at path. -func NewClient(path string) *Client { - dial := func(ctx context.Context, _, _ string) (net.Conn, error) { - var d net.Dialer - return d.DialContext(ctx, "unix", path) - } - return &Client{http: &http.Client{ - Transport: &http.Transport{DialContext: dial}, - Timeout: 10 * time.Second, - }} -} - -// Load replaces Caddy's running configuration. A rejected config leaves the -// previous one live; the error carries Caddy's explanation. -func (c *Client) Load(cfg any) error { - body, err := json.Marshal(cfg) - if err != nil { - return err - } - resp, err := c.http.Post("http://caddy/load", "application/json", bytes.NewReader(body)) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("caddy rejected config: %s: %s", resp.Status, bytes.TrimSpace(msg)) - } - return nil -} - -// Running reports whether Caddy answers on its admin socket. -// Stop makes Caddy exit; its supervisor starts it again with the bootstrap -// config. A reload keeps Caddy's in-memory certificate cache, so this is the -// only way to drop a cached staging certificate. -func (c *Client) Stop() error { - resp, err := c.http.Post("http://caddy/stop", "application/json", nil) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - return fmt.Errorf("caddy: stop: %s", resp.Status) - } - return nil -} - -func (c *Client) Running() bool { - resp, err := c.http.Get("http://caddy/config/") - if err != nil { - return false - } - resp.Body.Close() - return resp.StatusCode/100 == 2 -} - -// Loaded reports whether Caddy runs a full configuration. The bootstrap -// config has no apps, so a Caddy that restarted on its own answers null. -func (c *Client) Loaded() (bool, error) { - resp, err := c.http.Get("http://caddy/config/apps") - if err != nil { - return false, err - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) - if err != nil { - return false, err - } - if resp.StatusCode/100 != 2 { - return false, fmt.Errorf("caddy: %s", resp.Status) - } - return string(bytes.TrimSpace(body)) != "null", nil -} diff --git a/admin/internal/caddy/render.go b/admin/internal/caddy/render.go deleted file mode 100644 index 3126c44..0000000 --- a/admin/internal/caddy/render.go +++ /dev/null @@ -1,621 +0,0 @@ -package caddy - -import ( - "net" - "regexp" - "sort" - "strings" - - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// Input is everything the renderer needs. -type Input struct { - AdminSocket string // Caddy's admin socket; kept identical to the bootstrap - UISocket string // the admin's own unix socket, fronted on :8443 - // ProbeSocket serves the hosts' certificates over a unix socket so the - // admin can check them without a network path to Caddy (they run in - // separate containers). Empty leaves it out. - ProbeSocket string - CertPath string // setup certificate served on :8443 until setup finishes - KeyPath string - // RootCert/RootKey are the admin-made, domain-restricted root for the - // internal CA. Empty until a domain is set. - RootCert string - RootKey string - // RootID names the CA inside Caddy. It changes with the root, so Caddy - // never reuses an intermediate or a leaf issued under an older root - // (its storage is keyed by CA id). - RootID string - // LogLevel is Caddy's default log level ("WARN" normally, "INFO" with - // RP_DEBUG=1). - LogLevel string - // DNS is the provider Caddy may edit records at, for the wildcard's DNS - // challenge. Used only when the state's Certificates names the same - // provider; nil keeps everything under the internal CA. - DNS *dns.Config - // IssuanceLog is a file Caddy writes certificate events to, which the - // admin reads to report progress and failures. Empty disables it. - IssuanceLog string - // Listen are the addresses 80, 443 and 8443 answer on when the proxy - // answers on one network only; nil is every network. Empty (that - // network has no address yet) leaves only loopback: never all of them, - // which would undo what the person chose without a word. - Listen []string - // Shut are the subnets of the container's networks left out of - // Listen: their devices are never home, even routed to an address the - // proxy still answers on. - Shut []string - State *state.State -} - -// Staging is the Let's Encrypt staging directory: real issuance flow, -// untrusted certificates, generous limits. -const Staging = "https://acme-staging-v02.api.letsencrypt.org/directory" - -// Resolvers are public resolvers for the DNS challenge's propagation -// check. The container's own resolver often answers for the domain itself -// (that is the point of the local wildcard record) and would never see the -// _acme-challenge record at the real nameservers. -var Resolvers = []string{"1.1.1.1", "8.8.8.8"} - -// Public reports whether the wildcard is obtained from a public CA: a -// public domain, a provider chosen, and its credentials present. -func Public(in Input) bool { - st := in.State - return st != nil && st.Settings.Mode() == state.ModePublic && st.Settings.Certificates != nil && - in.DNS != nil && in.DNS.Provider == st.Settings.Certificates.Provider -} - -// setupSNI is the name the setup certificate carries; Caddy assumes it when a -// client sends no SNI (an IP address), so the UI answers on any address. -const setupSNI = "reverse-proxy.setup" - -// homeRanges are the addresses that count as the home network before the -// user adds any trusted networks. -var homeRanges = []string{ - "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", - "fc00::/7", "fe80::/10", "::1/128", -} - -// Render produces the complete Caddy JSON configuration. Everything Caddy -// runs comes from here; there is no other input path. -func Render(in Input) map[string]any { - st := in.State - if st == nil { - st = &state.State{} - } - hosts := st.Live() - if st.Settings.Mode() == state.ModeUnset { - hosts = nil // no domain yet: nothing to serve - } - - // proxy. is the proxy's own name: the admin UI and the trust - // check are reached through it once DNS points at the proxy. - var allNames []string - proxyName := "" - if st.Settings.Mode() != state.ModeUnset { - proxyName = ProxyName(st.Settings) - allNames = append(allNames, proxyName) - } - for _, h := range hosts { - allNames = append(allNames, h.Names(st.Settings)...) - } - sort.Strings(allNames) - - var hostNames []string - for _, h := range hosts { - hostNames = append(hostNames, h.Names(st.Settings)...) - } - sort.Strings(hostNames) - - servers := map[string]any{ - "admin": adminServer(in, proxyName), - "trust": trustServer(in, hostNames), - } - if proxyName != "" { - servers["hosts"] = hostsServer(in, st, hosts, proxyName) - if in.ProbeSocket != "" { - servers["probe"] = probeServer(in) - } - } - - tlsApp := map[string]any{ - "certificates": map[string]any{ - "load_files": []any{map[string]any{ - "certificate": in.CertPath, - "key": in.KeyPath, - "tags": []string{"setup"}, - }}, - }, - } - if len(allNames) > 0 { - // Automatic HTTPS is off on every server (we own the redirects and - // the matching), so the names Caddy must obtain certificates for are - // listed here explicitly; a policy alone only says how, not which. - // A public domain gets one wildcard: names stay out of the - // transparency logs and one issuance covers every host. - subjects := allNames - if Public(in) { - subjects = []string{"*." + st.Settings.Domain} - } - tlsApp["certificates"].(map[string]any)["automate"] = subjects - tlsApp["automation"] = map[string]any{ - "policies": []any{map[string]any{ - "subjects": subjects, - "issuers": issuers(in), - }}, - } - } - - apps := map[string]any{ - "http": map[string]any{"servers": servers}, - "tls": tlsApp, - } - // The CA exists only once the admin has made a domain-restricted root, - // so nothing is ever issued under a Caddy-generated root. Its root - // must never be installed into the container's own trust store; the - // trust page hands it out. - if in.RootCert != "" && st.Settings.Mode() != state.ModeUnset { - apps["pki"] = map[string]any{ - "certificate_authorities": map[string]any{ - in.RootID: map[string]any{ - "name": st.Settings.Domain + " proxy", - "install_trust": false, - "root": map[string]any{"format": "pem_file", "certificate": in.RootCert, "private_key": in.RootKey}, - }, - }, - } - } - - logs := map[string]any{ - // "admin" is excluded too: its one message is a warning that the - // unix-socket endpoint skips host checks (by design here), and a - // rejected config reaches us as the API response anyway. - "default": map[string]any{"level": in.LogLevel, "exclude": []string{"admin", "admin.api"}}, - } - if in.IssuanceLog != "" { - // Caddy runs as root; the admin (bsd) must be able to read this. - logs["issuance"] = map[string]any{ - "writer": map[string]any{"output": "file", "filename": in.IssuanceLog, "mode": "0644", "roll_size_mb": 1, "roll_keep": 1}, - "encoder": map[string]any{"format": "json"}, - "level": "INFO", - "include": []string{"tls.obtain", "tls.renew", "tls.issuance"}, - } - } - return map[string]any{ - "admin": map[string]any{ - "listen": "unix/" + in.AdminSocket + "|0660", - "origins": []string{"caddy"}, // what our client sends as Host - "config": map[string]any{"persist": false}, - }, - // Caddy's default log is chatty JSON at info level, which buries the - // setup link in `podman logs`. Warnings and errors only, and never - // the health check's polls of the admin API. RP_DEBUG=1 turns the - // detail back on. - "logging": map[string]any{"logs": logs}, - "apps": apps, - } -} - -// ProxyName is the proxy's own hostname under the user's domain. -func ProxyName(s state.Settings) string { return "proxy." + s.Domain } - -// listenOn is the listen list for a port: every address, or the chosen -// network's plus loopback (the in-container healthcheck URL and tools). -func listenOn(in Input, port string) []string { - if in.Listen == nil { - return []string{":" + port} - } - out := []string{"127.0.0.1:" + port} - for _, a := range in.Listen { - out = append(out, net.JoinHostPort(a, port)) - } - return out -} - -// LocalHeader carries the address a request came in on, so the admin -// (behind a unix socket) knows which network the person is on. Set by -// Caddy, overwriting anything the client sent. -const LocalHeader = "X-Rp-Local-Addr" - -// adminUpstream is the admin's unix socket as a reverse_proxy handler. -func adminUpstream(in Input) map[string]any { - return map[string]any{ - "handler": "reverse_proxy", - "upstreams": []any{map[string]any{"dial": "unix/" + in.UISocket}}, - "headers": map[string]any{"request": map[string]any{ - "set": map[string]any{LocalHeader: []string{"{http.request.local.host}"}}, - }}, - } -} - -// issuers picks how certificates are obtained: the internal CA under the -// admin's root, or ACME with the DNS challenge for a public wildcard. The -// trial stage points at the staging CA. -func issuers(in Input) []any { - if !Public(in) { - return []any{map[string]any{"module": "internal", "ca": in.RootID}} - } - provider := map[string]any{"name": in.DNS.Provider} - for k, v := range in.DNS.Fields { - provider[k] = v - } - acme := map[string]any{ - "module": "acme", - "challenges": map[string]any{ - "dns": map[string]any{ - "provider": provider, - "resolvers": Resolvers, - }, - }, - } - if in.State.Settings.Certificates.Stage == state.StageTrial { - acme["ca"] = Staging - } - return []any{acme} -} - -func adminServer(in Input, proxyName string) map[string]any { - // proxy. gets its real certificate; any other name or an IP - // gets the setup certificate, so the UI answers on every address. - var policies []any - if proxyName != "" { - policies = append(policies, map[string]any{"match": map[string]any{"sni": []string{proxyName}}}) - } - policies = append(policies, map[string]any{ - "default_sni": setupSNI, - "certificate_selection": map[string]any{"any_tag": []string{"setup"}}, - }) - for _, p := range policies { - p.(map[string]any)["protocol_min"] = "tls1.2" - } - return map[string]any{ - "listen": listenOn(in, "8443"), - "automatic_https": map[string]any{"disable": true}, - "routes": []any{ - // /internal/ is for the reset-password command over the unix - // socket only; it never exists from the network. - map[string]any{ - "match": []any{map[string]any{"path": []string{"/internal/*"}}}, - "handle": []any{map[string]any{"handler": "static_response", "status_code": 404}}, - "terminal": true, - }, - map[string]any{"handle": []any{adminUpstream(in)}}, - }, - "tls_connection_policies": policies, - } -} - -// trustServer is :80. Host names redirect to HTTPS; the proxy's own name -// and its bare IP serve the trust page from the admin (plain HTTP, because -// a device that does not trust the CA yet cannot load it over HTTPS). -func trustServer(in Input, hostNames []string) map[string]any { - routes := []any{ - map[string]any{ - "match": []any{map[string]any{"path": []string{"/healthz"}}}, - "handle": []any{map[string]any{ - "handler": "static_response", - "status_code": 200, - "body": "ok\n", - }}, - }, - } - routes = append(routes, shortLinkRoutes(in.State)...) - if len(hostNames) > 0 { - routes = append(routes, map[string]any{ - "match": []any{map[string]any{"host": hostNames}}, - "handle": []any{map[string]any{ - "handler": "static_response", - "status_code": 308, - "headers": map[string]any{"Location": []string{"https://{http.request.host}{http.request.uri}"}}, - }}, - }) - } - // Only the trust page and what it needs; the admin refuses the rest - // without a session anyway, but there is no reason to expose it here. - routes = append(routes, - map[string]any{ - "match": []any{map[string]any{"path": []string{"/trust", "/trust/*", "/assets/*", "/favicon.svg", "/api/v1/trust", "/api/v1/trust/*"}}}, - "handle": []any{adminUpstream(in)}, - }, - map[string]any{ - "handle": []any{map[string]any{ - "handler": "static_response", - "status_code": 302, - "headers": map[string]any{"Location": []string{"/trust"}}, - }}, - }) - return map[string]any{ - "listen": listenOn(in, "80"), - "automatic_https": map[string]any{"disable": true}, - "protocols": []string{"h1"}, // plain port: no h2/h3, and no warning about it - "routes": routes, - } -} - -// shortLinkRoutes answer http://go/radarr/x?y with radarr./x?y, the -// way go links work at work. Plain HTTP only: no certificate can name a -// host without a dot. A redirect, not permanent, so a changed name is not -// remembered wrong; one nobody has lands on the unknown-name page. Bare go/ -// opens the proxy's own page, the list of names. -func shortLinkRoutes(st *state.State) []any { - short := st.Settings.ShortName() - if short == "" || st.Settings.Domain == "" { - return nil - } - domain := st.Settings.Domain - to := func(loc string) map[string]any { - return map[string]any{"handler": "static_response", "status_code": 302, "headers": map[string]any{"Location": []string{loc}}} - } - name := map[string]any{"name": "short", "pattern": `^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$`} - on := func(m map[string]any) []any { - m["host"] = state.ShortHosts(short) - return []any{m} - } - app := "https://{http.regexp.short.1}." + domain + "{http.regexp.short.2}" - return []any{ - map[string]any{"match": on(map[string]any{"path_regexp": name, "expression": "{http.request.uri.query} != ''"}), "handle": []any{to(app + "?{http.request.uri.query}")}}, - map[string]any{"match": on(map[string]any{"path_regexp": name}), "handle": []any{to(app)}}, - map[string]any{"match": on(map[string]any{}), "handle": []any{to("https://" + ProxyName(st.Settings) + "/")}}, - } -} - -// adminPort is the port a browser reaches the admin's :8443 at: the one in -// the address the account last signed in from (8444 when the compose -// published 8444:8443), else 8443 itself. -// AdminPort is adminPort for the server's links. -func AdminPort(set state.Settings) string { return adminPort(set) } - -func adminPort(set state.Settings) string { - if _, port, err := net.SplitHostPort(set.AdminAddress); err == nil && port != "" { - return port - } - return "8443" -} - -// hostsServer is :443. One route per host; a home-only host also needs a -// home address, otherwise the request falls through to the same connection -// close an unknown name gets, so outsiders cannot tell the two apart. -func hostsServer(in Input, st *state.State, hosts []state.Host, proxyName string) map[string]any { - // The proxy's own name: the trust check lives here so a device can - // prove it trusts the certificate; anything else goes to the admin UI. - routes := []any{ - map[string]any{ - "match": []any{map[string]any{"host": []string{proxyName}, "path": []string{"/trust/check"}}}, - "handle": []any{adminUpstream(in)}, - "terminal": true, - }, - // https://proxy. is the admin, with no port in the address; - // it used to redirect to :8443, which put the port into every link - // to the proxy. /internal/ stays off the network, as on :8443. One - // route (a subroute), so the hosts keep their places after it. - map[string]any{ - "match": []any{map[string]any{"host": []string{proxyName}}}, - "handle": []any{map[string]any{ - "handler": "subroute", - "routes": []any{ - map[string]any{ - "match": []any{map[string]any{"path": []string{"/internal/*"}}}, - "handle": []any{map[string]any{"handler": "static_response", "status_code": 404}}, - }, - map[string]any{"handle": []any{adminUpstream(in)}}, - }, - }}, - "terminal": true, - }, - } - for _, h := range hosts { - match := map[string]any{"host": h.Names(st.Settings)} - if h.Access == state.AccessHome { - atHome(in, match, homeNetworks(st.Settings, h)) - } - routes = append(routes, map[string]any{ - "match": []any{match}, - "handle": []any{securityHeaders(pinHTTPS(st.Settings, h)), proxyHandler(h, h.Names(st.Settings))}, - "terminal": true, - }) - } - // A name nothing answers to: a page for someone at home, who would - // otherwise see a dropped connection and blame the proxy; outsiders - // still get the drop, so a home-only name stays indistinguishable from - // none. Someone at home matches every home-only name anyway. - routes = append(routes, - map[string]any{ - "match": []any{atHome(in, map[string]any{}, homeNetworks(st.Settings, state.Host{}))}, - "handle": []any{unknownNameResponse(proxyName)}, - "terminal": true, - }, - map[string]any{ - "handle": []any{map[string]any{"handler": "static_response", "abort": true}}, - }) - return map[string]any{ - "listen": listenOn(in, "443"), - "automatic_https": map[string]any{"disable": true}, - // The Host header must name the same site the TLS handshake did, so - // one host's certificate cannot be used to reach another's backend. - "strict_sni_host": true, - "routes": routes, - "errors": map[string]any{"routes": []any{targetDownRoute()}}, - "tls_connection_policies": []any{map[string]any{ - "protocol_min": "tls1.2", - }}, - } -} - -// probeServer is TLS and nothing else: the same certificates as :443, picked -// by SNI, and every request aborted. It has no access rules because a unix -// socket has no client address; only the admin (group bsd) can open it. -func probeServer(in Input) map[string]any { - return map[string]any{ - "listen": []string{"unix/" + in.ProbeSocket + "|0660"}, - "automatic_https": map[string]any{"disable": true}, - "routes": []any{map[string]any{ - "handle": []any{map[string]any{"handler": "static_response", "abort": true}}, - }}, - "tls_connection_policies": []any{map[string]any{ - "protocol_min": "tls1.2", - }}, - } -} - -// targetDownRoute replaces Caddy's bare 502 with a page a visitor can read: -// the name they asked for, that it is not answering, and an automatic -// retry. Nothing about the target leaks. -func targetDownRoute() map[string]any { - return map[string]any{ - "match": []any{map[string]any{"expression": "{http.error.status_code} in [502, 503, 504]"}}, - "handle": []any{map[string]any{ - "handler": "static_response", - "status_code": 503, - "headers": map[string]any{ - "Content-Type": []string{"text/html; charset=utf-8"}, - "Retry-After": []string{"5"}, - "Cache-Control": []string{"no-store"}, - }, - "body": targetDownHTML, - }}, - } -} - -// targetDownHTML uses Caddy placeholders for the host name; everything else -// is static. Styled to match the admin, inline so it needs no assets. -const targetDownHTML = `Not responding

{http.request.host} isn't responding right now

The proxy reached out to the service behind this address and got no answer. This page tries again every few seconds; if it keeps happening, check that the service is running.

` - -// unknownNameResponse says that the name leads nowhere from here, and where -// names are given. -func unknownNameResponse(proxyName string) map[string]any { - return map[string]any{ - "handler": "static_response", - "status_code": 404, - "headers": map[string]any{ - "Content-Type": []string{"text/html; charset=utf-8"}, - "Cache-Control": []string{"no-store"}, - }, - "body": strings.ReplaceAll(unknownNameHTML, "PROXYNAME", proxyName), - } -} - -const unknownNameHTML = `No app here

No app is called {http.request.host}

Nothing on this proxy answers to that name from here. Check how it is spelled, or that the app still has this name.

Every name is listed at PROXYNAME.

` - -// pinHTTPS: HSTS only where it cannot lock anyone out. A browser that has -// seen HSTS refuses the name outright, with no way past the warning, once -// the certificate changes: a new ending, a restored backup, going back to -// the proxy's own root, an old DNS record leading to another proxy. So -// only names open to the internet with a public certificate get it. -func pinHTTPS(set state.Settings, h state.Host) bool { - return h.Access == state.AccessInternet && set.Mode() == state.ModePublic && set.Certificates != nil -} - -// hstsOff clears what earlier versions told browsers (a year of HSTS on -// every name) as soon as a browser opens the name with a good certificate. -const hstsOff = "max-age=0" - -// securityHeaders are added to every proxied response. Only headers that -// never break an application: no frame or content-security policy, which -// the applications themselves own. Server and Via go: they name the proxy -// and the app's server software to anyone who asks. -func securityHeaders(pin bool) map[string]any { - hsts := hstsOff - if pin { - hsts = "max-age=31536000" - } - return map[string]any{ - "handler": "headers", - "response": map[string]any{ - "set": map[string]any{ - "Strict-Transport-Security": []string{hsts}, - "X-Content-Type-Options": []string{"nosniff"}, - "Referrer-Policy": []string{"strict-origin-when-cross-origin"}, - }, - "delete": []string{"Server", "Via"}, - "deferred": true, - }, - } -} - -// atHome adds "from home" to a matcher set: from one of ranges, and not -// from a network shut out of the proxy. -func atHome(in Input, match map[string]any, ranges []string) map[string]any { - match["remote_ip"] = map[string]any{"ranges": ranges} - if len(in.Shut) > 0 { - match["not"] = []any{map[string]any{"remote_ip": map[string]any{"ranges": in.Shut}}} - } - return match -} - -func homeNetworks(s state.Settings, h state.Host) []string { - extra := s.TrustedNetworks - if len(h.TrustedNetworks) > 0 { - extra = h.TrustedNetworks - } - out := append([]string(nil), homeRanges...) - out = append(out, extra...) - return out -} - -func proxyHandler(host state.Host, names []string) map[string]any { - t := host.Target - addr := t.Address + ":" + itoa(t.Port) - h := map[string]any{ - "handler": "reverse_proxy", - "upstreams": []any{map[string]any{"dial": addr}}, - } - // OwnOrigin (per name, off by default): an app that checks Origin or - // Referer against its own address (UniFi: 403 on login, then 401 on - // every call, 2026-10-26) sees the name instead. Only a request from the - // name's own pages is rewritten, to the app's address: one from any - // other site keeps its headers, so the app's CSRF check still holds. - // Not the default: most apps compare Origin with Host, which the - // rewrite would break. - if host.OwnOrigin && len(names) > 0 { - scheme, defPort := "http", 80 - if t.HTTPS { - scheme, defPort = "https", 443 - } - own := scheme + "://" + t.Address - if t.Port != defPort { - own += ":" + itoa(t.Port) - } - // Referer the same way, path kept: UniFi's API answered 401 to a - // Referer naming the name, after a login that went through. - var origin, referer []any - for _, n := range names { - q := regexp.QuoteMeta(n) - origin = append(origin, map[string]any{"search_regexp": "^https://" + q + "$", "replace": own}) - referer = append(referer, map[string]any{"search_regexp": "^https://" + q + "(/.*)?$", "replace": own + "${1}"}) - } - h["headers"] = map[string]any{"request": map[string]any{"replace": map[string]any{"Origin": origin, "Referer": referer}}} - } - if t.HTTPS { - tlsCfg := map[string]any{} - if t.AcceptUntrusted { - tlsCfg["insecure_skip_verify"] = true - } - h["transport"] = map[string]any{"protocol": "http", "tls": tlsCfg} - } - return h -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - var b [12]byte - i := len(b) - neg := n < 0 - if neg { - n = -n - } - for n > 0 { - i-- - b[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - b[i] = '-' - } - return string(b[i:]) -} diff --git a/admin/internal/caddy/render_test.go b/admin/internal/caddy/render_test.go deleted file mode 100644 index 4f6bdf4..0000000 --- a/admin/internal/caddy/render_test.go +++ /dev/null @@ -1,441 +0,0 @@ -package caddy - -import ( - "bytes" - "encoding/json" - "flag" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -var update = flag.Bool("update", false, "rewrite golden files") - -func fixedInput(st *state.State) Input { - return Input{ - AdminSocket: "/var/run/reverse-proxy/caddy.sock", - UISocket: "/var/run/reverse-proxy/admin.sock", - ProbeSocket: "/var/run/reverse-proxy/tls.sock", - CertPath: "/config/secrets/setup.crt", - KeyPath: "/config/secrets/setup.key", - LogLevel: "WARN", - State: st, - } -} - -func TestRenderGolden(t *testing.T) { - now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) - deleted := now.Add(-time.Hour) - cases := map[string]*state.State{ - "empty": {SchemaVersion: 1}, - "private-two-hosts": { - SchemaVersion: 1, - Settings: state.Settings{Domain: "apps.home.lan", TrustedNetworks: []string{"100.64.0.0/10"}}, - Hosts: []state.Host{ - {ID: "a", Name: "radarr", Target: state.Target{Address: "192.168.1.20", Port: 7878}, Access: state.AccessHome, CreatedAt: now}, - {ID: "b", Name: "unifi", ExtraNames: []string{"network"}, Target: state.Target{Address: "192.168.1.1", Port: 8443, HTTPS: true, AcceptUntrusted: true}, Access: state.AccessInternet, CreatedAt: now}, - {ID: "c", Name: "gone", Target: state.Target{Address: "192.168.1.9", Port: 80}, Access: state.AccessHome, CreatedAt: now, DeletedAt: &deleted}, - }, - }, - "host-trusted-override": { - SchemaVersion: 1, - Settings: state.Settings{Domain: "home.example.com", TrustedNetworks: []string{"100.64.0.0/10"}}, - Hosts: []state.Host{ - {ID: "a", Name: "nas", Target: state.Target{Address: "nas.lan", Port: 5000}, Access: state.AccessHome, TrustedNetworks: []string{"10.9.0.0/24"}, CreatedAt: now}, - }, - }, - } - cases["public-wildcard-live"] = publicState() - cases["public-wildcard-live"].Settings.Certificates.Stage = state.StageLive - for name, st := range cases { - t.Run(name, func(t *testing.T) { - in := fixedInput(st) - if name == "public-wildcard-live" { - in.DNS = &dns.Config{Provider: "cloudflare", Fields: map[string]string{"api_token": "tok"}} - in.IssuanceLog = "/config/logs/issuance.log" - } - got, err := json.MarshalIndent(Render(in), "", " ") - if err != nil { - t.Fatal(err) - } - got = append(got, '\n') - p := filepath.Join("testdata", name+".json") - if *update { - if err := os.WriteFile(p, got, 0o644); err != nil { - t.Fatal(err) - } - } - want, err := os.ReadFile(p) - if err != nil { - t.Fatalf("%v (run with -update to create)", err) - } - if !bytes.Equal(got, want) { - t.Errorf("rendered config differs from %s:\n%s", p, got) - } - }) - } -} - -func TestDeletedHostNotServed(t *testing.T) { - gone := time.Now() - st := &state.State{Settings: state.Settings{Domain: "x.lan"}, Hosts: []state.Host{ - {ID: "a", Name: "gone", Target: state.Target{Address: "1.2.3.4", Port: 80}, Access: state.AccessHome, DeletedAt: &gone}, - }} - cfg := Render(fixedInput(st)) - servers := cfg["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any) - routes := servers["hosts"].(map[string]any)["routes"].([]any) - // proxy name check + proxy name + unknown-name page + abort: nothing - // for the host - if len(routes) != 4 { - t.Fatalf("deleted host still routed: %d routes", len(routes)) - } -} - -func TestProxyNameServedOn443WithoutHosts(t *testing.T) { - st := &state.State{Settings: state.Settings{Domain: "x.lan"}} - servers := Render(fixedInput(st))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any) - hs, ok := servers["hosts"].(map[string]any) - if !ok { - t.Fatal("no :443 listener with a domain but no hosts") - } - first := hs["routes"].([]any)[0].(map[string]any)["match"].([]any)[0].(map[string]any) - if first["host"].([]string)[0] != "proxy.x.lan" || first["path"].([]string)[0] != "/trust/check" { - t.Fatalf("first route: %v", first) - } - if _, ok := Render(fixedInput(&state.State{}))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any)["hosts"]; ok { - t.Fatal(":443 listener without a domain") - } -} - -func TestHomeOnlyHasRemoteIPMatcher(t *testing.T) { - st := &state.State{Settings: state.Settings{Domain: "x.lan"}, Hosts: []state.Host{ - {ID: "a", Name: "home", Target: state.Target{Address: "1.2.3.4", Port: 80}, Access: state.AccessHome}, - {ID: "b", Name: "open", Target: state.Target{Address: "1.2.3.5", Port: 80}, Access: state.AccessInternet}, - }} - srv := Render(fixedInput(st))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any)["hosts"].(map[string]any) - routes := srv["routes"].([]any) - m0 := routes[2].(map[string]any)["match"].([]any)[0].(map[string]any) - m1 := routes[3].(map[string]any)["match"].([]any)[0].(map[string]any) - if _, ok := m0["remote_ip"]; !ok { - t.Fatal("home-only host lacks remote_ip matcher") - } - if _, ok := m1["remote_ip"]; ok { - t.Fatal("internet host has remote_ip matcher") - } - last := routes[len(routes)-1].(map[string]any)["handle"].([]any)[0].(map[string]any) - if last["abort"] != true { - t.Fatal("catch-all route does not abort") - } -} - -func TestNamesAreAutomated(t *testing.T) { - st := &state.State{Settings: state.Settings{Domain: "x.lan"}, Hosts: []state.Host{ - {ID: "a", Name: "one", ExtraNames: []string{"two"}, Target: state.Target{Address: "1.2.3.4", Port: 80}, Access: state.AccessHome}, - }} - in := fixedInput(st) - in.RootCert, in.RootKey, in.RootID = "/config/secrets/ca-root.crt", "/config/secrets/ca-root.key", "rp-1441" - apps := Render(in)["apps"].(map[string]any) - automate := apps["tls"].(map[string]any)["certificates"].(map[string]any)["automate"].([]string) - if len(automate) != 3 || automate[0] != "one.x.lan" || automate[1] != "proxy.x.lan" || automate[2] != "two.x.lan" { - t.Fatalf("automate = %v", automate) - } - ca := apps["pki"].(map[string]any)["certificate_authorities"].(map[string]any)["rp-1441"].(map[string]any) - if ca["install_trust"] != false { - t.Fatal("install_trust not disabled") - } - root := ca["root"].(map[string]any) - if root["certificate"] != in.RootCert || root["private_key"] != in.RootKey { - t.Fatalf("root not wired: %v", root) - } - issuer := apps["tls"].(map[string]any)["automation"].(map[string]any)["policies"].([]any)[0].(map[string]any)["issuers"].([]any)[0].(map[string]any) - if issuer["ca"] != "rp-1441" { - t.Fatalf("issuer not bound to the CA: %v", issuer) - } - // Without a domain there is no CA at all, even if files exist. - in.State = &state.State{} - if _, ok := Render(in)["apps"].(map[string]any)["pki"]; ok { - t.Fatal("CA configured without a domain") - } -} - -func TestHostsServerHardening(t *testing.T) { - st := &state.State{Settings: state.Settings{Domain: "x.lan"}, Hosts: []state.Host{ - {ID: "a", Name: "one", Target: state.Target{Address: "1.2.3.4", Port: 80}, Access: state.AccessHome}, - }} - srv := Render(fixedInput(st))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any)["hosts"].(map[string]any) - if srv["strict_sni_host"] != true { - t.Fatal("strict_sni_host off") - } - pol := srv["tls_connection_policies"].([]any)[0].(map[string]any) - if pol["protocol_min"] != "tls1.2" { - t.Fatalf("protocol_min %v", pol["protocol_min"]) - } - handlers := srv["routes"].([]any)[2].(map[string]any)["handle"].([]any) - if handlers[0].(map[string]any)["handler"] != "headers" || handlers[1].(map[string]any)["handler"] != "reverse_proxy" { - t.Fatalf("handler order: %v", handlers) - } - set := handlers[0].(map[string]any)["response"].(map[string]any)["set"].(map[string]any) - if _, ok := set["Strict-Transport-Security"]; !ok { - t.Fatal("no HSTS") - } - errs := srv["errors"].(map[string]any)["routes"].([]any) - body := errs[0].(map[string]any)["handle"].([]any)[0].(map[string]any)["body"].(string) - if !strings.Contains(body, "{http.request.host}") || strings.Contains(body, "1.2.3.4") { - t.Fatal("error page wrong") - } -} - -func TestAdminServesProxyNameWithRealCert(t *testing.T) { - st := &state.State{Settings: state.Settings{Domain: "x.lan"}} - srv := Render(fixedInput(st))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any)["admin"].(map[string]any) - pols := srv["tls_connection_policies"].([]any) - if len(pols) != 2 { - t.Fatalf("policies: %v", pols) - } - sni := pols[0].(map[string]any)["match"].(map[string]any)["sni"].([]string) - if sni[0] != "proxy.x.lan" { - t.Fatalf("first policy sni %v", sni) - } -} - -func publicState() *state.State { - return &state.State{ - SchemaVersion: 1, - Settings: state.Settings{Domain: "home.example.com", - Certificates: &state.Certificates{Provider: "cloudflare", Stage: state.StageTrial, StartedAt: time.Date(2026, 10, 13, 8, 0, 0, 0, time.UTC)}}, - Hosts: []state.Host{{ID: "a", Name: "radarr", Target: state.Target{Address: "192.168.1.20", Port: 7878}, Access: state.AccessHome}}, - } -} - -func tlsPolicy(t *testing.T, cfg map[string]any) (subjects []string, issuer map[string]any) { - t.Helper() - tlsApp := cfg["apps"].(map[string]any)["tls"].(map[string]any) - pol := tlsApp["automation"].(map[string]any)["policies"].([]any)[0].(map[string]any) - return pol["subjects"].([]string), pol["issuers"].([]any)[0].(map[string]any) -} - -func TestPublicDomainGetsOneWildcardViaDNS(t *testing.T) { - in := fixedInput(publicState()) - in.DNS = &dns.Config{Provider: "cloudflare", Fields: map[string]string{"api_token": "tok"}} - in.IssuanceLog = "/config/logs/issuance.log" - cfg := Render(in) - subjects, issuer := tlsPolicy(t, cfg) - if len(subjects) != 1 || subjects[0] != "*.home.example.com" { - t.Fatalf("subjects %v", subjects) - } - if issuer["module"] != "acme" || issuer["ca"] != Staging { - t.Fatalf("trial must use the staging CA: %v", issuer) - } - prov := issuer["challenges"].(map[string]any)["dns"].(map[string]any)["provider"].(map[string]any) - if prov["name"] != "cloudflare" || prov["api_token"] != "tok" { - t.Fatalf("provider %v", prov) - } - automate := cfg["apps"].(map[string]any)["tls"].(map[string]any)["certificates"].(map[string]any)["automate"].([]string) - if len(automate) != 1 || automate[0] != "*.home.example.com" { - t.Fatalf("automate %v", automate) - } - logs := cfg["logging"].(map[string]any)["logs"].(map[string]any) - if _, ok := logs["issuance"]; !ok { - t.Fatal("issuance log missing") - } - // Live stage: no ca override, so Caddy's default (Let's Encrypt). - in.State.Settings.Certificates.Stage = state.StageLive - _, issuer = tlsPolicy(t, Render(in)) - if _, has := issuer["ca"]; has { - t.Fatalf("live stage must not pin a CA: %v", issuer) - } -} - -func TestPublicDomainWithoutCredentialsStaysInternal(t *testing.T) { - // Provider chosen in state but no secrets file (or a different one): - // never ask a public CA with nothing to answer the challenge. - in := fixedInput(publicState()) - subjects, issuer := tlsPolicy(t, Render(in)) - if issuer["module"] != "internal" || len(subjects) != 2 { - t.Fatalf("got %v %v", subjects, issuer) - } - in.DNS = &dns.Config{Provider: "desec"} - if _, issuer = tlsPolicy(t, Render(in)); issuer["module"] != "internal" { - t.Fatalf("mismatched provider must stay internal: %v", issuer) - } - // A private ending never goes public, whatever the state says. - in.State.Settings.Domain = "apps.home.lan" - in.DNS = &dns.Config{Provider: "cloudflare"} - if _, issuer = tlsPolicy(t, Render(in)); issuer["module"] != "internal" { - t.Fatalf("private ending must stay internal: %v", issuer) - } -} - -// The proxy's own name on :443 is the admin itself, no port in the address; -// /internal/ (reset-password over the socket) stays off the network there -// as on :8443. -func TestProxyNameServesTheAdmin(t *testing.T) { - servers := Render(fixedInput(&state.State{Settings: state.Settings{Domain: "x.lan"}}))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any) - routes := servers["hosts"].(map[string]any)["routes"].([]any) - h := routes[1].(map[string]any)["handle"].([]any)[0].(map[string]any) - if h["handler"] != "subroute" { - t.Fatalf("proxy name route: %v", h) - } - inner := h["routes"].([]any) - first := inner[0].(map[string]any) - if first["match"].([]any)[0].(map[string]any)["path"].([]string)[0] != "/internal/*" || first["handle"].([]any)[0].(map[string]any)["status_code"] != 404 { - t.Fatalf("/internal/ not refused: %v", first) - } - if inner[1].(map[string]any)["handle"].([]any)[0].(map[string]any)["handler"] != "reverse_proxy" { - t.Fatalf("admin not served: %v", inner[1]) - } -} - -// With OwnOrigin on, a request from the name's own pages reaches the app -// with the app's own Origin and Referer (UniFi refused the login, then every -// call); off, which is the default, nothing is rewritten. -func TestProxyHandlerRewritesOwnOrigin(t *testing.T) { - if h := proxyHandler(state.Host{Target: state.Target{Address: "192.168.86.8", Port: 8443, HTTPS: true}}, []string{"unifi.apps.lan"}); h["headers"] != nil { - t.Fatalf("off by default: %v", h["headers"]) - } - h := proxyHandler(state.Host{OwnOrigin: true, Target: state.Target{Address: "192.168.86.8", Port: 8443, HTTPS: true}}, []string{"unifi.apps.lan"}) - rules := h["headers"].(map[string]any)["request"].(map[string]any)["replace"].(map[string]any)["Origin"].([]any) - r := rules[0].(map[string]any) - if r["search_regexp"] != `^https://unifi\.apps\.lan$` || r["replace"] != "https://192.168.86.8:8443" { - t.Fatalf("rule %v", r) - } - ref := h["headers"].(map[string]any)["request"].(map[string]any)["replace"].(map[string]any)["Referer"].([]any)[0].(map[string]any) - if ref["search_regexp"] != `^https://unifi\.apps\.lan(/.*)?$` || ref["replace"] != "https://192.168.86.8:8443${1}" { - t.Fatalf("referer rule %v", ref) - } - h = proxyHandler(state.Host{OwnOrigin: true, Target: state.Target{Address: "10.0.0.5", Port: 80}}, []string{"a.x.lan", "b.x.lan"}) - rules = h["headers"].(map[string]any)["request"].(map[string]any)["replace"].(map[string]any)["Origin"].([]any) - if len(rules) != 2 || rules[1].(map[string]any)["replace"] != "http://10.0.0.5" { - t.Fatalf("rules %v", rules) - } -} - -// HSTS turns a changed certificate into a wall, so only names open to the -// internet with a public certificate keep it. -func TestPinHTTPS(t *testing.T) { - public := state.Settings{Domain: "home.example.com", Certificates: &state.Certificates{Provider: "cloudflare", Stage: state.StageLive}} - private := state.Settings{Domain: "apps.home.lan"} - noCerts := state.Settings{Domain: "home.example.com"} - home, internet := state.Host{Access: state.AccessHome}, state.Host{Access: state.AccessInternet} - for _, c := range []struct { - set state.Settings - h state.Host - want bool - }{ - {public, internet, true}, - {public, home, false}, - {private, internet, false}, - {noCerts, internet, false}, - } { - if got := pinHTTPS(c.set, c.h); got != c.want { - t.Errorf("%s %s: got %v", c.set.Domain, c.h.Access, got) - } - } -} - -// A name nothing answers to: a page for someone at home, the dropped -// connection for anyone else, so a home-only name looks like none. -func TestUnknownNamePageOnlyAtHome(t *testing.T) { - st := &state.State{Settings: state.Settings{Domain: "x.lan"}} - servers := Render(fixedInput(st))["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any) - routes := servers["hosts"].(map[string]any)["routes"].([]any) - page := routes[len(routes)-2].(map[string]any) - ranges := page["match"].([]any)[0].(map[string]any)["remote_ip"].(map[string]any)["ranges"].([]string) - if len(ranges) == 0 || !slices.Contains(ranges, "192.168.0.0/16") { - t.Fatalf("page not limited to home: %v", ranges) - } - h := page["handle"].([]any)[0].(map[string]any) - if h["status_code"] != 404 || !strings.Contains(h["body"].(string), "proxy.x.lan") { - t.Fatalf("page: %v", h["status_code"]) - } - last := routes[len(routes)-1].(map[string]any)["handle"].([]any)[0].(map[string]any) - if last["abort"] != true || routes[len(routes)-1].(map[string]any)["match"] != nil { - t.Fatalf("last route is not the drop for everyone else: %v", last) - } -} - -func TestListenOnOneNetwork(t *testing.T) { - listens := func(listen []string) map[string][]string { - in := fixedInput(&state.State{Settings: state.Settings{Domain: "x.lan"}}) - in.Listen = listen - servers := Render(in)["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any) - out := map[string][]string{} - for _, name := range []string{"admin", "trust", "hosts"} { - out[name] = servers[name].(map[string]any)["listen"].([]string) - } - return out - } - for name, l := range listens(nil) { - if len(l) != 1 || !strings.HasPrefix(l[0], ":") { - t.Fatalf("every network: %s listens on %v", name, l) - } - } - got := listens([]string{"192.168.86.11", "2001:db8::5"}) - if want := []string{"127.0.0.1:443", "192.168.86.11:443", "[2001:db8::5]:443"}; !slices.Equal(got["hosts"], want) { - t.Fatalf("one network: hosts listen on %v, want %v", got["hosts"], want) - } - if want := []string{"127.0.0.1:8443", "192.168.86.11:8443", "[2001:db8::5]:8443"}; !slices.Equal(got["admin"], want) { - t.Fatalf("one network: admin listens on %v, want %v", got["admin"], want) - } - // The network has no address yet (DHCP): loopback only, never all. - for name, l := range listens([]string{}) { - if len(l) != 1 || !strings.HasPrefix(l[0], "127.0.0.1:") { - t.Fatalf("network without an address: %s listens on %v", name, l) - } - } -} - -func TestAdminLearnsTheLocalAddress(t *testing.T) { - up := adminUpstream(fixedInput(nil)) - set := up["headers"].(map[string]any)["request"].(map[string]any)["set"].(map[string]any) - if v := set[LocalHeader].([]string); len(v) != 1 || v[0] != "{http.request.local.host}" { - t.Fatalf("%s: %v", LocalHeader, v) - } -} - -func TestShutNetworksAreNeverHome(t *testing.T) { - now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) - st := &state.State{Settings: state.Settings{Domain: "x.lan"}, Hosts: []state.Host{ - {ID: "a", Name: "radarr", Target: state.Target{Address: "192.168.5.10", Port: 7878}, Access: state.AccessHome, CreatedAt: now}, - }} - routes := func(shut []string) []any { - in := fixedInput(st) - in.Shut = shut - return Render(in)["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any)["hosts"].(map[string]any)["routes"].([]any) - } - homeMatches := func(rs []any) (n int, nots []any) { - for _, r := range rs { - m, _ := r.(map[string]any)["match"].([]any) - if len(m) == 0 { - continue - } - set := m[0].(map[string]any) - if _, ok := set["remote_ip"]; ok { - n++ - if not, ok := set["not"].([]any); ok { - nots = append(nots, not...) - } - } - } - return n, nots - } - if n, nots := homeMatches(routes(nil)); n != 2 || len(nots) != 0 { - t.Fatalf("every network: %d home matchers, %d nots", n, len(nots)) - } - // The home-only name and the unknown-name page both refuse vlan4. - n, nots := homeMatches(routes([]string{"192.168.4.0/24"})) - if n != 2 || len(nots) != 2 { - t.Fatalf("vlan4 shut: %d home matchers, %d nots", n, len(nots)) - } - for _, not := range nots { - if r := not.(map[string]any)["remote_ip"].(map[string]any)["ranges"].([]string); !slices.Equal(r, []string{"192.168.4.0/24"}) { - t.Fatalf("not: %v", r) - } - } -} diff --git a/admin/internal/caddy/setupcert.go b/admin/internal/caddy/setupcert.go deleted file mode 100644 index c242f9b..0000000 --- a/admin/internal/caddy/setupcert.go +++ /dev/null @@ -1,69 +0,0 @@ -package caddy - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "errors" - "math/big" - "os" - "path/filepath" - "time" -) - -// EnsureSetupCert returns the self-signed certificate the admin UI is served -// with before setup finishes, generating it on first run. Caddy (root) reads -// the files, so they are world-readable certificate and 0600 key in a 0700 -// directory root can enter. -func EnsureSetupCert(dir string) (certPath, keyPath string, err error) { - certPath = filepath.Join(dir, "setup.crt") - keyPath = filepath.Join(dir, "setup.key") - if _, err := os.Stat(certPath); err == nil { - if _, err := os.Stat(keyPath); err == nil { - return certPath, keyPath, nil - } - } - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return "", "", err - } - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127)) - if err != nil { - return "", "", err - } - tmpl := &x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: "reverse-proxy setup"}, - DNSNames: []string{setupSNI}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().AddDate(10, 0, 0), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) - if err != nil { - return "", "", err - } - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - return "", "", err - } - - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - if certPEM == nil || keyPEM == nil { - return "", "", errors.New("encoding setup certificate") - } - if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { - return "", "", err - } - if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { - return "", "", err - } - return certPath, keyPath, nil -} diff --git a/admin/internal/caddy/testdata/empty.json b/admin/internal/caddy/testdata/empty.json deleted file mode 100644 index ddb92bf..0000000 --- a/admin/internal/caddy/testdata/empty.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "admin": { - "config": { - "persist": false - }, - "listen": "unix//var/run/reverse-proxy/caddy.sock|0660", - "origins": [ - "caddy" - ] - }, - "apps": { - "http": { - "servers": { - "admin": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":8443" - ], - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ], - "tls_connection_policies": [ - { - "certificate_selection": { - "any_tag": [ - "setup" - ] - }, - "default_sni": "reverse-proxy.setup", - "protocol_min": "tls1.2" - } - ] - }, - "trust": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":80" - ], - "protocols": [ - "h1" - ], - "routes": [ - { - "handle": [ - { - "body": "ok\n", - "handler": "static_response", - "status_code": 200 - } - ], - "match": [ - { - "path": [ - "/healthz" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "path": [ - "/trust", - "/trust/*", - "/assets/*", - "/favicon.svg", - "/api/v1/trust", - "/api/v1/trust/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "/trust" - ] - }, - "status_code": 302 - } - ] - } - ] - } - } - }, - "tls": { - "certificates": { - "load_files": [ - { - "certificate": "/config/secrets/setup.crt", - "key": "/config/secrets/setup.key", - "tags": [ - "setup" - ] - } - ] - } - } - }, - "logging": { - "logs": { - "default": { - "exclude": [ - "admin", - "admin.api" - ], - "level": "WARN" - } - } - } -} diff --git a/admin/internal/caddy/testdata/host-trusted-override.json b/admin/internal/caddy/testdata/host-trusted-override.json deleted file mode 100644 index c0651ca..0000000 --- a/admin/internal/caddy/testdata/host-trusted-override.json +++ /dev/null @@ -1,557 +0,0 @@ -{ - "admin": { - "config": { - "persist": false - }, - "listen": "unix//var/run/reverse-proxy/caddy.sock|0660", - "origins": [ - "caddy" - ] - }, - "apps": { - "http": { - "servers": { - "admin": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":8443" - ], - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ], - "tls_connection_policies": [ - { - "match": { - "sni": [ - "proxy.home.example.com" - ] - }, - "protocol_min": "tls1.2" - }, - { - "certificate_selection": { - "any_tag": [ - "setup" - ] - }, - "default_sni": "reverse-proxy.setup", - "protocol_min": "tls1.2" - } - ] - }, - "hosts": { - "automatic_https": { - "disable": true - }, - "errors": { - "routes": [ - { - "handle": [ - { - "body": "\u003c!doctype html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\u003cmeta http-equiv=\"refresh\" content=\"5\"\u003e\u003ctitle\u003eNot responding\u003c/title\u003e\u003cstyle\u003ebody{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f2;color:#1d1d1f;font-family:system-ui,sans-serif}main{max-width:32rem;margin:1rem;padding:2.5rem;background:#fff;border:1px solid #d9d9d6;border-radius:1rem}h1{margin:0 0 .5rem;font-size:1.6rem;line-height:1.25}p{margin:0;color:#5c5c62;line-height:1.5}code{font-family:ui-monospace,monospace;color:#1d1d1f}@media(prefers-color-scheme:dark){body{background:#1d1d20;color:#f4f4f2}main{background:#28282c;border-color:#3b3b40}p{color:#a6a6ae}code{color:#f4f4f2}}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\u003cmain\u003e\u003ch1\u003e\u003ccode\u003e{http.request.host}\u003c/code\u003e isn't responding right now\u003c/h1\u003e\u003cp\u003eThe proxy reached out to the service behind this address and got no answer. This page tries again every few seconds; if it keeps happening, check that the service is running.\u003c/p\u003e\u003c/main\u003e\u003c/body\u003e\u003c/html\u003e", - "handler": "static_response", - "headers": { - "Cache-Control": [ - "no-store" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ], - "Retry-After": [ - "5" - ] - }, - "status_code": 503 - } - ], - "match": [ - { - "expression": "{http.error.status_code} in [502, 503, 504]" - } - ] - } - ] - }, - "listen": [ - ":443" - ], - "routes": [ - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "host": [ - "proxy.home.example.com" - ], - "path": [ - "/trust/check" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "subroute", - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ] - } - ], - "match": [ - { - "host": [ - "proxy.home.example.com" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "headers", - "response": { - "deferred": true, - "delete": [ - "Server", - "Via" - ], - "set": { - "Referrer-Policy": [ - "strict-origin-when-cross-origin" - ], - "Strict-Transport-Security": [ - "max-age=0" - ], - "X-Content-Type-Options": [ - "nosniff" - ] - } - } - }, - { - "handler": "reverse_proxy", - "upstreams": [ - { - "dial": "nas.lan:5000" - } - ] - } - ], - "match": [ - { - "host": [ - "nas.home.example.com" - ], - "remote_ip": { - "ranges": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fc00::/7", - "fe80::/10", - "::1/128", - "10.9.0.0/24" - ] - } - } - ], - "terminal": true - }, - { - "handle": [ - { - "body": "\u003c!doctype html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\u003ctitle\u003eNo app here\u003c/title\u003e\u003cstyle\u003ebody{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f2;color:#1d1d1f;font-family:system-ui,sans-serif}main{max-width:32rem;margin:1rem;padding:2.5rem;background:#fff;border:1px solid #d9d9d6;border-radius:1rem}h1{margin:0 0 .5rem;font-size:1.6rem;line-height:1.25}p{margin:0 0 .75rem;color:#5c5c62;line-height:1.5}a{color:#2563eb}code{font-family:ui-monospace,monospace;color:#1d1d1f}@media(prefers-color-scheme:dark){body{background:#1d1d20;color:#f4f4f2}main{background:#28282c;border-color:#3b3b40}p{color:#a6a6ae}a{color:#60a5fa}code{color:#f4f4f2}}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\u003cmain\u003e\u003ch1\u003eNo app is called \u003ccode\u003e{http.request.host}\u003c/code\u003e\u003c/h1\u003e\u003cp\u003eNothing on this proxy answers to that name from here. Check how it is spelled, or that the app still has this name.\u003c/p\u003e\u003cp\u003eEvery name is listed at \u003ca href=\"https://proxy.home.example.com/\"\u003eproxy.home.example.com\u003c/a\u003e.\u003c/p\u003e\u003c/main\u003e\u003c/body\u003e\u003c/html\u003e", - "handler": "static_response", - "headers": { - "Cache-Control": [ - "no-store" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ] - }, - "status_code": 404 - } - ], - "match": [ - { - "remote_ip": { - "ranges": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fc00::/7", - "fe80::/10", - "::1/128", - "100.64.0.0/10" - ] - } - } - ], - "terminal": true - }, - { - "handle": [ - { - "abort": true, - "handler": "static_response" - } - ] - } - ], - "strict_sni_host": true, - "tls_connection_policies": [ - { - "protocol_min": "tls1.2" - } - ] - }, - "probe": { - "automatic_https": { - "disable": true - }, - "listen": [ - "unix//var/run/reverse-proxy/tls.sock|0660" - ], - "routes": [ - { - "handle": [ - { - "abort": true, - "handler": "static_response" - } - ] - } - ], - "tls_connection_policies": [ - { - "protocol_min": "tls1.2" - } - ] - }, - "trust": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":80" - ], - "protocols": [ - "h1" - ], - "routes": [ - { - "handle": [ - { - "body": "ok\n", - "handler": "static_response", - "status_code": 200 - } - ], - "match": [ - { - "path": [ - "/healthz" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.regexp.short.1}.home.example.com{http.regexp.short.2}?{http.request.uri.query}" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "expression": "{http.request.uri.query} != ''", - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ], - "path_regexp": { - "name": "short", - "pattern": "^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$" - } - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.regexp.short.1}.home.example.com{http.regexp.short.2}" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ], - "path_regexp": { - "name": "short", - "pattern": "^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$" - } - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://proxy.home.example.com/" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.request.host}{http.request.uri}" - ] - }, - "status_code": 308 - } - ], - "match": [ - { - "host": [ - "nas.home.example.com" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "path": [ - "/trust", - "/trust/*", - "/assets/*", - "/favicon.svg", - "/api/v1/trust", - "/api/v1/trust/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "/trust" - ] - }, - "status_code": 302 - } - ] - } - ] - } - } - }, - "tls": { - "automation": { - "policies": [ - { - "issuers": [ - { - "ca": "", - "module": "internal" - } - ], - "subjects": [ - "nas.home.example.com", - "proxy.home.example.com" - ] - } - ] - }, - "certificates": { - "automate": [ - "nas.home.example.com", - "proxy.home.example.com" - ], - "load_files": [ - { - "certificate": "/config/secrets/setup.crt", - "key": "/config/secrets/setup.key", - "tags": [ - "setup" - ] - } - ] - } - } - }, - "logging": { - "logs": { - "default": { - "exclude": [ - "admin", - "admin.api" - ], - "level": "WARN" - } - } - } -} diff --git a/admin/internal/caddy/testdata/private-two-hosts.json b/admin/internal/caddy/testdata/private-two-hosts.json deleted file mode 100644 index 7852745..0000000 --- a/admin/internal/caddy/testdata/private-two-hosts.json +++ /dev/null @@ -1,611 +0,0 @@ -{ - "admin": { - "config": { - "persist": false - }, - "listen": "unix//var/run/reverse-proxy/caddy.sock|0660", - "origins": [ - "caddy" - ] - }, - "apps": { - "http": { - "servers": { - "admin": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":8443" - ], - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ], - "tls_connection_policies": [ - { - "match": { - "sni": [ - "proxy.apps.home.lan" - ] - }, - "protocol_min": "tls1.2" - }, - { - "certificate_selection": { - "any_tag": [ - "setup" - ] - }, - "default_sni": "reverse-proxy.setup", - "protocol_min": "tls1.2" - } - ] - }, - "hosts": { - "automatic_https": { - "disable": true - }, - "errors": { - "routes": [ - { - "handle": [ - { - "body": "\u003c!doctype html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\u003cmeta http-equiv=\"refresh\" content=\"5\"\u003e\u003ctitle\u003eNot responding\u003c/title\u003e\u003cstyle\u003ebody{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f2;color:#1d1d1f;font-family:system-ui,sans-serif}main{max-width:32rem;margin:1rem;padding:2.5rem;background:#fff;border:1px solid #d9d9d6;border-radius:1rem}h1{margin:0 0 .5rem;font-size:1.6rem;line-height:1.25}p{margin:0;color:#5c5c62;line-height:1.5}code{font-family:ui-monospace,monospace;color:#1d1d1f}@media(prefers-color-scheme:dark){body{background:#1d1d20;color:#f4f4f2}main{background:#28282c;border-color:#3b3b40}p{color:#a6a6ae}code{color:#f4f4f2}}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\u003cmain\u003e\u003ch1\u003e\u003ccode\u003e{http.request.host}\u003c/code\u003e isn't responding right now\u003c/h1\u003e\u003cp\u003eThe proxy reached out to the service behind this address and got no answer. This page tries again every few seconds; if it keeps happening, check that the service is running.\u003c/p\u003e\u003c/main\u003e\u003c/body\u003e\u003c/html\u003e", - "handler": "static_response", - "headers": { - "Cache-Control": [ - "no-store" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ], - "Retry-After": [ - "5" - ] - }, - "status_code": 503 - } - ], - "match": [ - { - "expression": "{http.error.status_code} in [502, 503, 504]" - } - ] - } - ] - }, - "listen": [ - ":443" - ], - "routes": [ - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "host": [ - "proxy.apps.home.lan" - ], - "path": [ - "/trust/check" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "subroute", - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ] - } - ], - "match": [ - { - "host": [ - "proxy.apps.home.lan" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "headers", - "response": { - "deferred": true, - "delete": [ - "Server", - "Via" - ], - "set": { - "Referrer-Policy": [ - "strict-origin-when-cross-origin" - ], - "Strict-Transport-Security": [ - "max-age=0" - ], - "X-Content-Type-Options": [ - "nosniff" - ] - } - } - }, - { - "handler": "reverse_proxy", - "upstreams": [ - { - "dial": "192.168.1.20:7878" - } - ] - } - ], - "match": [ - { - "host": [ - "radarr.apps.home.lan" - ], - "remote_ip": { - "ranges": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fc00::/7", - "fe80::/10", - "::1/128", - "100.64.0.0/10" - ] - } - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "headers", - "response": { - "deferred": true, - "delete": [ - "Server", - "Via" - ], - "set": { - "Referrer-Policy": [ - "strict-origin-when-cross-origin" - ], - "Strict-Transport-Security": [ - "max-age=0" - ], - "X-Content-Type-Options": [ - "nosniff" - ] - } - } - }, - { - "handler": "reverse_proxy", - "transport": { - "protocol": "http", - "tls": { - "insecure_skip_verify": true - } - }, - "upstreams": [ - { - "dial": "192.168.1.1:8443" - } - ] - } - ], - "match": [ - { - "host": [ - "unifi.apps.home.lan", - "network.apps.home.lan" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "body": "\u003c!doctype html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\u003ctitle\u003eNo app here\u003c/title\u003e\u003cstyle\u003ebody{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f2;color:#1d1d1f;font-family:system-ui,sans-serif}main{max-width:32rem;margin:1rem;padding:2.5rem;background:#fff;border:1px solid #d9d9d6;border-radius:1rem}h1{margin:0 0 .5rem;font-size:1.6rem;line-height:1.25}p{margin:0 0 .75rem;color:#5c5c62;line-height:1.5}a{color:#2563eb}code{font-family:ui-monospace,monospace;color:#1d1d1f}@media(prefers-color-scheme:dark){body{background:#1d1d20;color:#f4f4f2}main{background:#28282c;border-color:#3b3b40}p{color:#a6a6ae}a{color:#60a5fa}code{color:#f4f4f2}}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\u003cmain\u003e\u003ch1\u003eNo app is called \u003ccode\u003e{http.request.host}\u003c/code\u003e\u003c/h1\u003e\u003cp\u003eNothing on this proxy answers to that name from here. Check how it is spelled, or that the app still has this name.\u003c/p\u003e\u003cp\u003eEvery name is listed at \u003ca href=\"https://proxy.apps.home.lan/\"\u003eproxy.apps.home.lan\u003c/a\u003e.\u003c/p\u003e\u003c/main\u003e\u003c/body\u003e\u003c/html\u003e", - "handler": "static_response", - "headers": { - "Cache-Control": [ - "no-store" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ] - }, - "status_code": 404 - } - ], - "match": [ - { - "remote_ip": { - "ranges": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fc00::/7", - "fe80::/10", - "::1/128", - "100.64.0.0/10" - ] - } - } - ], - "terminal": true - }, - { - "handle": [ - { - "abort": true, - "handler": "static_response" - } - ] - } - ], - "strict_sni_host": true, - "tls_connection_policies": [ - { - "protocol_min": "tls1.2" - } - ] - }, - "probe": { - "automatic_https": { - "disable": true - }, - "listen": [ - "unix//var/run/reverse-proxy/tls.sock|0660" - ], - "routes": [ - { - "handle": [ - { - "abort": true, - "handler": "static_response" - } - ] - } - ], - "tls_connection_policies": [ - { - "protocol_min": "tls1.2" - } - ] - }, - "trust": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":80" - ], - "protocols": [ - "h1" - ], - "routes": [ - { - "handle": [ - { - "body": "ok\n", - "handler": "static_response", - "status_code": 200 - } - ], - "match": [ - { - "path": [ - "/healthz" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.regexp.short.1}.apps.home.lan{http.regexp.short.2}?{http.request.uri.query}" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "expression": "{http.request.uri.query} != ''", - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ], - "path_regexp": { - "name": "short", - "pattern": "^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$" - } - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.regexp.short.1}.apps.home.lan{http.regexp.short.2}" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ], - "path_regexp": { - "name": "short", - "pattern": "^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$" - } - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://proxy.apps.home.lan/" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.request.host}{http.request.uri}" - ] - }, - "status_code": 308 - } - ], - "match": [ - { - "host": [ - "network.apps.home.lan", - "radarr.apps.home.lan", - "unifi.apps.home.lan" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "path": [ - "/trust", - "/trust/*", - "/assets/*", - "/favicon.svg", - "/api/v1/trust", - "/api/v1/trust/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "/trust" - ] - }, - "status_code": 302 - } - ] - } - ] - } - } - }, - "tls": { - "automation": { - "policies": [ - { - "issuers": [ - { - "ca": "", - "module": "internal" - } - ], - "subjects": [ - "network.apps.home.lan", - "proxy.apps.home.lan", - "radarr.apps.home.lan", - "unifi.apps.home.lan" - ] - } - ] - }, - "certificates": { - "automate": [ - "network.apps.home.lan", - "proxy.apps.home.lan", - "radarr.apps.home.lan", - "unifi.apps.home.lan" - ], - "load_files": [ - { - "certificate": "/config/secrets/setup.crt", - "key": "/config/secrets/setup.key", - "tags": [ - "setup" - ] - } - ] - } - } - }, - "logging": { - "logs": { - "default": { - "exclude": [ - "admin", - "admin.api" - ], - "level": "WARN" - } - } - } -} diff --git a/admin/internal/caddy/testdata/public-wildcard-live.json b/admin/internal/caddy/testdata/public-wildcard-live.json deleted file mode 100644 index 71d4ed5..0000000 --- a/admin/internal/caddy/testdata/public-wildcard-live.json +++ /dev/null @@ -1,582 +0,0 @@ -{ - "admin": { - "config": { - "persist": false - }, - "listen": "unix//var/run/reverse-proxy/caddy.sock|0660", - "origins": [ - "caddy" - ] - }, - "apps": { - "http": { - "servers": { - "admin": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":8443" - ], - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ], - "tls_connection_policies": [ - { - "match": { - "sni": [ - "proxy.home.example.com" - ] - }, - "protocol_min": "tls1.2" - }, - { - "certificate_selection": { - "any_tag": [ - "setup" - ] - }, - "default_sni": "reverse-proxy.setup", - "protocol_min": "tls1.2" - } - ] - }, - "hosts": { - "automatic_https": { - "disable": true - }, - "errors": { - "routes": [ - { - "handle": [ - { - "body": "\u003c!doctype html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\u003cmeta http-equiv=\"refresh\" content=\"5\"\u003e\u003ctitle\u003eNot responding\u003c/title\u003e\u003cstyle\u003ebody{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f2;color:#1d1d1f;font-family:system-ui,sans-serif}main{max-width:32rem;margin:1rem;padding:2.5rem;background:#fff;border:1px solid #d9d9d6;border-radius:1rem}h1{margin:0 0 .5rem;font-size:1.6rem;line-height:1.25}p{margin:0;color:#5c5c62;line-height:1.5}code{font-family:ui-monospace,monospace;color:#1d1d1f}@media(prefers-color-scheme:dark){body{background:#1d1d20;color:#f4f4f2}main{background:#28282c;border-color:#3b3b40}p{color:#a6a6ae}code{color:#f4f4f2}}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\u003cmain\u003e\u003ch1\u003e\u003ccode\u003e{http.request.host}\u003c/code\u003e isn't responding right now\u003c/h1\u003e\u003cp\u003eThe proxy reached out to the service behind this address and got no answer. This page tries again every few seconds; if it keeps happening, check that the service is running.\u003c/p\u003e\u003c/main\u003e\u003c/body\u003e\u003c/html\u003e", - "handler": "static_response", - "headers": { - "Cache-Control": [ - "no-store" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ], - "Retry-After": [ - "5" - ] - }, - "status_code": 503 - } - ], - "match": [ - { - "expression": "{http.error.status_code} in [502, 503, 504]" - } - ] - } - ] - }, - "listen": [ - ":443" - ], - "routes": [ - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "host": [ - "proxy.home.example.com" - ], - "path": [ - "/trust/check" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "subroute", - "routes": [ - { - "handle": [ - { - "handler": "static_response", - "status_code": 404 - } - ], - "match": [ - { - "path": [ - "/internal/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ] - } - ] - } - ], - "match": [ - { - "host": [ - "proxy.home.example.com" - ] - } - ], - "terminal": true - }, - { - "handle": [ - { - "handler": "headers", - "response": { - "deferred": true, - "delete": [ - "Server", - "Via" - ], - "set": { - "Referrer-Policy": [ - "strict-origin-when-cross-origin" - ], - "Strict-Transport-Security": [ - "max-age=0" - ], - "X-Content-Type-Options": [ - "nosniff" - ] - } - } - }, - { - "handler": "reverse_proxy", - "upstreams": [ - { - "dial": "192.168.1.20:7878" - } - ] - } - ], - "match": [ - { - "host": [ - "radarr.home.example.com" - ], - "remote_ip": { - "ranges": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fc00::/7", - "fe80::/10", - "::1/128" - ] - } - } - ], - "terminal": true - }, - { - "handle": [ - { - "body": "\u003c!doctype html\u003e\u003chtml lang=\"en\"\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"\u003e\u003ctitle\u003eNo app here\u003c/title\u003e\u003cstyle\u003ebody{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f2;color:#1d1d1f;font-family:system-ui,sans-serif}main{max-width:32rem;margin:1rem;padding:2.5rem;background:#fff;border:1px solid #d9d9d6;border-radius:1rem}h1{margin:0 0 .5rem;font-size:1.6rem;line-height:1.25}p{margin:0 0 .75rem;color:#5c5c62;line-height:1.5}a{color:#2563eb}code{font-family:ui-monospace,monospace;color:#1d1d1f}@media(prefers-color-scheme:dark){body{background:#1d1d20;color:#f4f4f2}main{background:#28282c;border-color:#3b3b40}p{color:#a6a6ae}a{color:#60a5fa}code{color:#f4f4f2}}\u003c/style\u003e\u003c/head\u003e\u003cbody\u003e\u003cmain\u003e\u003ch1\u003eNo app is called \u003ccode\u003e{http.request.host}\u003c/code\u003e\u003c/h1\u003e\u003cp\u003eNothing on this proxy answers to that name from here. Check how it is spelled, or that the app still has this name.\u003c/p\u003e\u003cp\u003eEvery name is listed at \u003ca href=\"https://proxy.home.example.com/\"\u003eproxy.home.example.com\u003c/a\u003e.\u003c/p\u003e\u003c/main\u003e\u003c/body\u003e\u003c/html\u003e", - "handler": "static_response", - "headers": { - "Cache-Control": [ - "no-store" - ], - "Content-Type": [ - "text/html; charset=utf-8" - ] - }, - "status_code": 404 - } - ], - "match": [ - { - "remote_ip": { - "ranges": [ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "127.0.0.0/8", - "fc00::/7", - "fe80::/10", - "::1/128" - ] - } - } - ], - "terminal": true - }, - { - "handle": [ - { - "abort": true, - "handler": "static_response" - } - ] - } - ], - "strict_sni_host": true, - "tls_connection_policies": [ - { - "protocol_min": "tls1.2" - } - ] - }, - "probe": { - "automatic_https": { - "disable": true - }, - "listen": [ - "unix//var/run/reverse-proxy/tls.sock|0660" - ], - "routes": [ - { - "handle": [ - { - "abort": true, - "handler": "static_response" - } - ] - } - ], - "tls_connection_policies": [ - { - "protocol_min": "tls1.2" - } - ] - }, - "trust": { - "automatic_https": { - "disable": true - }, - "listen": [ - ":80" - ], - "protocols": [ - "h1" - ], - "routes": [ - { - "handle": [ - { - "body": "ok\n", - "handler": "static_response", - "status_code": 200 - } - ], - "match": [ - { - "path": [ - "/healthz" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.regexp.short.1}.home.example.com{http.regexp.short.2}?{http.request.uri.query}" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "expression": "{http.request.uri.query} != ''", - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ], - "path_regexp": { - "name": "short", - "pattern": "^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$" - } - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.regexp.short.1}.home.example.com{http.regexp.short.2}" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ], - "path_regexp": { - "name": "short", - "pattern": "^/([A-Za-z0-9][A-Za-z0-9-]*)(/.*)?$" - } - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://proxy.home.example.com/" - ] - }, - "status_code": 302 - } - ], - "match": [ - { - "host": [ - "go", - "go.lan", - "go.internal", - "go.home.arpa", - "go.home", - "go.test", - "go.localdomain" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "https://{http.request.host}{http.request.uri}" - ] - }, - "status_code": 308 - } - ], - "match": [ - { - "host": [ - "radarr.home.example.com" - ] - } - ] - }, - { - "handle": [ - { - "handler": "reverse_proxy", - "headers": { - "request": { - "set": { - "X-Rp-Local-Addr": [ - "{http.request.local.host}" - ] - } - } - }, - "upstreams": [ - { - "dial": "unix//var/run/reverse-proxy/admin.sock" - } - ] - } - ], - "match": [ - { - "path": [ - "/trust", - "/trust/*", - "/assets/*", - "/favicon.svg", - "/api/v1/trust", - "/api/v1/trust/*" - ] - } - ] - }, - { - "handle": [ - { - "handler": "static_response", - "headers": { - "Location": [ - "/trust" - ] - }, - "status_code": 302 - } - ] - } - ] - } - } - }, - "tls": { - "automation": { - "policies": [ - { - "issuers": [ - { - "challenges": { - "dns": { - "provider": { - "api_token": "tok", - "name": "cloudflare" - }, - "resolvers": [ - "1.1.1.1", - "8.8.8.8" - ] - } - }, - "module": "acme" - } - ], - "subjects": [ - "*.home.example.com" - ] - } - ] - }, - "certificates": { - "automate": [ - "*.home.example.com" - ], - "load_files": [ - { - "certificate": "/config/secrets/setup.crt", - "key": "/config/secrets/setup.key", - "tags": [ - "setup" - ] - } - ] - } - } - }, - "logging": { - "logs": { - "default": { - "exclude": [ - "admin", - "admin.api" - ], - "level": "WARN" - }, - "issuance": { - "encoder": { - "format": "json" - }, - "include": [ - "tls.obtain", - "tls.renew", - "tls.issuance" - ], - "level": "INFO", - "writer": { - "filename": "/config/logs/issuance.log", - "mode": "0644", - "output": "file", - "roll_keep": 1, - "roll_size_mb": 1 - } - } - } - } -} diff --git a/admin/internal/certs/certs.go b/admin/internal/certs/certs.go deleted file mode 100644 index e841b81..0000000 --- a/admin/internal/certs/certs.go +++ /dev/null @@ -1,386 +0,0 @@ -// Package certs reports how the public wildcard certificate is doing: -// what Caddy serves right now, and what its issuance log says went wrong. -package certs - -import ( - "bufio" - "bytes" - "context" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "log" - "math" - "os" - "regexp" - "strconv" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// Issuer classes, from the certificate Caddy actually serves. -const ( - IssuerNone = "" // no certificate yet - IssuerInternal = "internal" // the admin's own root - IssuerStaging = "staging" // Let's Encrypt staging: the trial - IssuerLetsEncrypt = "letsencrypt" // the real thing -) - -// Observation is what a TLS handshake with the proxy showed. -type Observation struct { - Issuer string - NotAfter time.Time -} - -// Observe connects to addr with serverName and classifies the leaf. -func Observe(ctx context.Context, network, addr, serverName string) Observation { - d := tls.Dialer{Config: &tls.Config{ServerName: serverName, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}} //nolint:gosec // classifying, not trusting - c, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - conn, err := d.DialContext(c, network, addr) - if err != nil { - return Observation{} - } - defer conn.Close() - certs := conn.(*tls.Conn).ConnectionState().PeerCertificates - if len(certs) == 0 { - return Observation{} - } - leaf := certs[0] - org := strings.Join(leaf.Issuer.Organization, " ") - obs := Observation{Issuer: IssuerInternal, NotAfter: leaf.NotAfter} - switch { - case strings.Contains(org, "(STAGING)") || strings.Contains(leaf.Issuer.CommonName, "(STAGING)"): - obs.Issuer = IssuerStaging - case strings.Contains(org, "Let's Encrypt"): - obs.Issuer = IssuerLetsEncrypt - } - return obs -} - -// TrialPatience is how long the trial runs before the page says it is stuck. -const TrialPatience = 10 * time.Minute - -// LastLine is the newest line Caddy logged about identifier, whatever it -// says, for a person to read: "09:14:05 waiting on internal rate limiter". -func LastLine(path, identifier string) string { - f, err := os.Open(path) - if err != nil { - return "" - } - defer f.Close() - const tail = 256 << 10 - if fi, err := f.Stat(); err == nil && fi.Size() > tail { - f.Seek(fi.Size()-tail, io.SeekStart) - } - last := "" - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 64<<10), 1<<20) - for sc.Scan() { - line := sc.Bytes() - if !bytes.Contains(line, []byte(identifier)) { - continue - } - var rec struct { - TS float64 `json:"ts"` - Msg string `json:"msg"` - Error string `json:"error"` - } - if json.Unmarshal(line, &rec) != nil || rec.Msg == "" { - continue - } - last = time.Unix(0, int64(rec.TS*float64(time.Second))).Local().Format("15:04:05") + " " + rec.Msg - if rec.Error != "" { - last += ": " + redact(rec.Error) - } - } - return last -} - -// Event is the last thing Caddy logged about the wildcard. -type Event struct { - At time.Time - OK bool // obtained or renewed - Issuer string // Caddy's issuer key, e.g. acme-v02.api.letsencrypt.org-directory - Error string -} - -// LastEvent scans the issuance log for the newest line about identifier. -// Only the tail is read; the file is rolled at 1 MB anyway. -func LastEvent(path, identifier string) (Event, bool) { - f, err := os.Open(path) - if err != nil { - return Event{}, false - } - defer f.Close() - const tail = 256 << 10 - if fi, err := f.Stat(); err == nil && fi.Size() > tail { - f.Seek(fi.Size()-tail, io.SeekStart) - } - var last Event - found := false - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 64<<10), 1<<20) - for sc.Scan() { - line := sc.Bytes() - if !bytes.Contains(line, []byte(identifier)) { - continue - } - var rec struct { - TS float64 `json:"ts"` - Msg string `json:"msg"` - Identifier string `json:"identifier"` - Issuer string `json:"issuer"` - Error string `json:"error"` - } - if json.Unmarshal(line, &rec) != nil || rec.Identifier != identifier { - continue - } - ev := Event{At: time.Unix(0, int64(rec.TS*float64(time.Second))), Issuer: rec.Issuer} - switch rec.Msg { - case "certificate obtained successfully", "certificate renewed successfully": - ev.OK = true - case "could not get certificate from issuer": - ev.Error = rec.Error - default: - continue - } - last, found = ev, true - } - return last, found -} - -var retryAfter = regexp.MustCompile(`retry after ([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} UTC)`) - -// Explain turns an issuance error into one plain message with a next -// step, matching the failure states in the plan. -func Explain(errText, zone, providerLabel string) (msg, detail string) { - msg, detail, _ = explain(errText, zone, providerLabel) - return msg, detail -} - -// kindCAA is a failure only the person can fix, but not with a new token. -const kindCAA = "caa" - -// explain also says whose move it is: a dns.Kind* or kindCAA when only the -// person can fix it, "" when retrying may (rate limits, slow DNS, outages). -func explain(errText, zone, providerLabel string) (msg, detail, kind string) { - errText = redact(errText) - e := strings.ToLower(errText) - has := func(parts ...string) bool { - for _, p := range parts { - if strings.Contains(e, p) { - return true - } - } - return false - } - if zone == "" { - zone = "your domain" - } - switch { - case has("expected (ok) but got (ko)"): - // DuckDNS says only KO: a wrong token, or a name the token's - // account doesn't have. - return "DuckDNS refused the token or the name. Check that " + zone + " is on the account the token comes from.", errText, dns.KindRejected - case has("ratelimited", "rate limit", "too many certificates", "too many failed", "429"): - when := "in an hour" - if m := retryAfter.FindStringSubmatch(errText); m != nil { - if t, err := time.Parse("2006-01-02 15:04:05 MST", m[1]); err == nil { - when = "after " + t.Local().Format("15:04 on Jan 2") - } - } - return "Let's Encrypt is limiting requests for " + zone + ". It will be tried again " + when + ".", errText, "" - case has("nosuchhostedzone", "1003") || (has("zone") && has("not found", "could not find", "unknown", "no zone", "missing", "does not exist")): - return "The token works, but it can't change records for " + zone + ". Give it access to that zone.", errText, dns.KindZone - case has("401", "403", "unauthorized", "unauthenticated", "forbidden", "authentication", "appears invalid", "invalid token", "invalid api", "invalid key", "invalid credentials", "accessdenied", "invalidclienttokenid", "signaturedoesnotmatch", "tsig", "bad auth", "bad key", "6003", "10000"): - return "The token was rejected by " + providerLabel + ". Check it and try again.", errText, dns.KindRejected - case has("caa"): - return "A CAA record on " + zone + " does not allow Let's Encrypt. Allow letsencrypt.org there, or remove it.", errText, kindCAA - case has("propagat", "timed out waiting", "no txt record", "nxdomain", "not yet"): - return "The verification record never showed up at " + zone + "'s nameservers. Check that " + zone + " is really hosted at " + providerLabel + "; records usually appear within two minutes.", errText, "" - } - return "Couldn't get a certificate. It will be tried again automatically.", errText, "" -} - -// tokenInURL is a credential some providers' errors repeat in the request -// URL they quote (DuckDNS: ...&token=&...). -var tokenInURL = regexp.MustCompile(`(?i)((?:token|key|secret|password)=)[^&\s\]"]+`) - -// redact blanks credentials out of an error before anyone sees it. -func redact(errText string) string { - return tokenInURL.ReplaceAllString(errText, "${1}…") -} - -// renewalTrouble is what to say while the certificate still works but -// renewing it can't succeed until the person acts. -func renewalTrouble(kind, label, zone, until string) string { - works := "Everything works until " + until + ": " - switch kind { - case dns.KindRejected: - return label + " stopped accepting your token. " + works + "make a new token before then." - case dns.KindZone: - return "Your " + label + " token can't change " + zone + " any more. " + works + "give it access to " + zone + " again, or make a new token." - case dns.KindPermission: - return "Your " + label + " token can't change " + zone + "'s DNS records any more. " + works + "make a new token with the Edit zone DNS template." - case kindCAA: - return "A CAA record on " + zone + " now blocks Let's Encrypt. " + works + "allow letsencrypt.org there, or remove it." - } - return "" -} - -// fixFor is the Status.Fix for a kind: a new token mends all but CAA. -func fixFor(kind string) string { - if kind == "" || kind == kindCAA { - return "" - } - return "token" -} - -// Status is what the UI shows. -type Status struct { - // State: manual (own root), trial (proving the token), issuing (the - // real certificate is on its way), ok, failed. - State string `json:"state"` - Provider string `json:"provider,omitempty"` - Issuer string `json:"issuer,omitempty"` - NotAfter *time.Time `json:"notAfter,omitempty"` - DaysLeft int `json:"daysLeft,omitempty"` - Message string `json:"message,omitempty"` - Detail string `json:"detail,omitempty"` - Since *time.Time `json:"since,omitempty"` - // Fix is what mends it: "token" means a new token, pasted in Settings. - Fix string `json:"fix,omitempty"` -} - -// Compute combines the settings, what is served, the last log event and -// the last token check (nil: fine, or not checked; dns.Check knows only -// some providers, and Caddy's renewal error tells for the rest). -func Compute(s state.Settings, obs Observation, ev *Event, check *dns.Problem, zone string, now time.Time) Status { - c := s.Certificates - if c == nil || s.Mode() != state.ModePublic { - return Status{State: "manual"} - } - label := c.Provider - if p, ok := dns.Find(c.Provider); ok { - label = p.Label - } - st := Status{Provider: c.Provider, Since: &c.StartedAt} - fresh := ev != nil && !ev.At.Before(c.StartedAt) - failed := func() Status { - var kind string - st.State = "failed" - st.Message, st.Detail, kind = explain(ev.Error, zone, label) - st.Fix = fixFor(kind) - return st - } - switch c.Stage { - case state.StageTrial: - switch { - case obs.Issuer == IssuerStaging: - st.State = "issuing" // the watcher moves to live any moment - case fresh && ev.Error != "": - return failed() - case !c.StartedAt.IsZero() && now.Sub(c.StartedAt) > TrialPatience: - // Caddy retries without the line explain reads (waiting on DNS, - // on its rate limiter): the page spun on "Proving you own" for - // 92 minutes. Said here, with Caddy's last line as the detail; - // it keeps trying, and a staging certificate still moves it on. - st.State = "failed" - st.Message = fmt.Sprintf("Proving you own %s is taking far longer than it should: %d minutes, where it usually takes one to three. The proxy keeps trying; Details shows the last thing it logged.", zone, int(now.Sub(c.StartedAt).Minutes())) - default: - st.State = "trial" - } - default: - switch { - case obs.Issuer == IssuerLetsEncrypt: - st.State, st.Issuer = "ok", obs.Issuer - t := obs.NotAfter - st.NotAfter = &t - st.DaysLeft = int(math.Ceil(obs.NotAfter.Sub(now).Hours() / 24)) - // Said as soon as it is known, not 10 days before the end: a - // revoked token fails every renewal from 30 days out. - until := obs.NotAfter.Local().Format("Jan 2") - var kind string - if fresh && ev.Error != "" { - _, _, kind = explain(ev.Error, zone, label) - } - switch { - case check != nil: - st.Message, st.Detail, st.Fix = renewalTrouble(check.Kind, label, zone, until), check.Msg, "token" - case kind != "": - st.Message, st.Detail, st.Fix = renewalTrouble(kind, label, zone, until), redact(ev.Error), fixFor(kind) - case st.DaysLeft < 10: - st.Message = "Renewal has been failing; the certificate runs out in " + plural(st.DaysLeft, "day") + "." - if fresh && ev.Error != "" { - _, st.Detail = Explain(ev.Error, zone, label) - } - } - case fresh && ev.Error != "" && (obs.Issuer == IssuerNone || obs.Issuer == IssuerInternal || obs.Issuer == IssuerStaging): - return failed() - default: - st.State = "issuing" - } - } - return st -} - -func plural(n int, unit string) string { - if n == 1 { - return "1 " + unit - } - return strconv.Itoa(n) + " " + unit + "s" -} - -// Applier is the manager's write path. -type Applier interface { - State() *state.State - Apply(func(*state.State) error) error -} - -// Watch moves a trial to live once the staging certificate is served: -// from then on Caddy asks the real CA. It returns when ctx ends. -// restart is called once, right after the switch to live: Caddy keeps the -// staging certificate in memory across reloads, so it must start over. -func Watch(ctx context.Context, mgr Applier, observe func(context.Context, string) Observation, restart func() error) { - t := time.NewTicker(3 * time.Second) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - } - tick(mgr, func(name string) Observation { return observe(ctx, name) }, restart) - } -} - -func tick(mgr Applier, observe func(string) Observation, restart func() error) { - st := mgr.State() - c := st.Settings.Certificates - if c == nil || c.Stage != state.StageTrial || st.Settings.Domain == "" { - return - } - if observe("proxy."+st.Settings.Domain).Issuer != IssuerStaging { - return - } - err := mgr.Apply(func(st *state.State) error { - if c := st.Settings.Certificates; c != nil && c.Stage == state.StageTrial { - c.Stage = state.StageLive - c.StartedAt = time.Now() - } - return nil - }) - if err != nil { - log.Printf("[admin] certificates: trial passed but switching to the real CA failed: %v", err) - return - } - log.Printf("[admin] certificates: the token works (staging certificate issued); restarting Caddy to ask Let's Encrypt for the real one") - if err := restart(); err != nil { - log.Printf("[admin] certificates: restarting Caddy failed, the real certificate waits for the next restart: %v", err) - } -} diff --git a/admin/internal/certs/certs_test.go b/admin/internal/certs/certs_test.go deleted file mode 100644 index 0feee2d..0000000 --- a/admin/internal/certs/certs_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package certs - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -func TestLastEventReadsNewestLineForTheWildcard(t *testing.T) { - p := filepath.Join(t.TempDir(), "issuance.log") - lines := []string{ - `{"level":"info","ts":1760342400.5,"logger":"tls.obtain","msg":"obtaining certificate","identifier":"*.home.example.com"}`, - `{"level":"error","ts":1760342460.1,"logger":"tls.obtain","msg":"could not get certificate from issuer","identifier":"*.home.example.com","issuer":"acme-staging-v02.api.letsencrypt.org-directory","error":"[*.home.example.com] solving challenges: presenting for challenge: adding temporary record for zone \"example.com.\": got error status: HTTP 403: [{Code:10000 Message:Authentication error}]"}`, - `{"level":"error","ts":1760342461.0,"logger":"tls","msg":"will retry","error":"...","attempt":1}`, - `{"level":"info","ts":1760342470.0,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"*.other.example.com","issuer":"acme-v02.api.letsencrypt.org-directory"}`, - `not json at all`, - } - os.WriteFile(p, []byte(strings.Join(lines, "\n")+"\n"), 0o644) - ev, ok := LastEvent(p, "*.home.example.com") - if !ok || ev.OK || !strings.Contains(ev.Error, "Authentication error") || ev.At.Unix() != 1760342460 { - t.Fatalf("got %+v %v", ev, ok) - } - if _, ok := LastEvent(p, "*.nothing.example.com"); ok { - t.Fatal("unexpected event") - } - if _, ok := LastEvent(filepath.Join(t.TempDir(), "missing"), "x"); ok { - t.Fatal("missing file must be no event") - } -} - -func TestExplain(t *testing.T) { - cases := []struct{ err, want string }{ - {"HTTP 403: Authentication error", "rejected by Cloudflare"}, - {"provision dns.providers.cloudflare: API token 'x' appears invalid; ensure it's correctly entered", "rejected by Cloudflare"}, - {"InvalidClientTokenId: The security token included in the request is invalid", "rejected by Cloudflare"}, - {"NoSuchHostedZone: No hosted zone found", "can't change records for example.com"}, - {"zone example.com. not found in account", "can't change records"}, - {"timed out waiting for record to fully propagate", "never showed up"}, - {"urn:ietf:params:acme:error:rateLimited: too many certificates (5) already issued for this exact set of identifiers in the last 168h0m0s, retry after 2026-10-14 03:00:00 UTC", "limiting requests"}, - {"urn:ietf:params:acme:error:caa: CAA record for example.com prevents issuance", "CAA record"}, - {"something odd", "tried again automatically"}, - {"DuckDNS request failed, expected (OK) but got (KO), url: [https://www.duckdns.org/update?domains=you&token=x], body: KO", "DuckDNS refused the token or the name"}, - } - for _, c := range cases { - msg, detail := Explain(c.err, "example.com", "Cloudflare") - if !strings.Contains(msg, c.want) || detail != redact(c.err) { - t.Errorf("%q -> %q", c.err, msg) - } - } - msg, _ := Explain("retry after 2026-10-14 03:00:00 UTC rateLimited", "example.com", "x") - if !strings.Contains(msg, "after ") || strings.Contains(msg, "in an hour") { - t.Errorf("retry time not used: %s", msg) - } -} - -func TestCompute(t *testing.T) { - now := time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC) - started := now.Add(-time.Minute) - pub := func(stage string) state.Settings { - return state.Settings{Domain: "home.example.com", Certificates: &state.Certificates{Provider: "cloudflare", Stage: stage, StartedAt: started}} - } - oldErr := &Event{At: started.Add(-time.Hour), Error: "HTTP 403"} - newErr := &Event{At: now, Error: "HTTP 403"} - if s := Compute(state.Settings{Domain: "apps.lan"}, Observation{}, nil, nil, "", now); s.State != "manual" { - t.Fatalf("private: %s", s.State) - } - if s := Compute(state.Settings{Domain: "home.example.com"}, Observation{}, nil, nil, "", now); s.State != "manual" { - t.Fatalf("public without provider: %s", s.State) - } - if s := Compute(pub(state.StageTrial), Observation{}, oldErr, nil, "", now); s.State != "trial" { - t.Fatalf("old error must not count: %s", s.State) - } - if s := Compute(pub(state.StageTrial), Observation{}, newErr, nil, "example.com", now); s.State != "failed" || !strings.Contains(s.Message, "Cloudflare") { - t.Fatalf("fresh error: %+v", s) - } - if s := Compute(pub(state.StageTrial), Observation{Issuer: IssuerStaging}, newErr, nil, "", now); s.State != "issuing" { - t.Fatalf("staging served: %s", s.State) - } - // A trial with no answer either way stops spinning after TrialPatience, - // and a staging certificate still moves it on after that. - stuck := pub(state.StageTrial) - stuck.Certificates.StartedAt = now.Add(-92 * time.Minute) - if s := Compute(stuck, Observation{}, nil, nil, "example.org", now); s.State != "failed" || !strings.Contains(s.Message, "92 minutes") { - t.Fatalf("stuck trial: %+v", s) - } - if s := Compute(stuck, Observation{Issuer: IssuerStaging}, nil, nil, "example.org", now); s.State != "issuing" { - t.Fatalf("stuck, then staging served: %s", s.State) - } - if s := Compute(pub(state.StageLive), Observation{Issuer: IssuerStaging}, nil, nil, "", now); s.State != "issuing" { - t.Fatalf("live, still staging: %s", s.State) - } - s := Compute(pub(state.StageLive), Observation{Issuer: IssuerLetsEncrypt, NotAfter: now.Add(80 * 24 * time.Hour)}, nil, nil, "", now) - if s.State != "ok" || s.DaysLeft != 80 || s.Message != "" { - t.Fatalf("ok: %+v", s) - } - slow := &Event{At: now, Error: "timed out waiting for record to fully propagate"} - s = Compute(pub(state.StageLive), Observation{Issuer: IssuerLetsEncrypt, NotAfter: now.Add(5 * 24 * time.Hour)}, slow, nil, "", now) - if s.State != "ok" || !strings.Contains(s.Message, "5 days") || s.Detail == "" || s.Fix != "" { - t.Fatalf("expiring: %+v", s) - } - // A temporary failure with time to spare stays quiet: Caddy retries. - if s := Compute(pub(state.StageLive), Observation{Issuer: IssuerLetsEncrypt, NotAfter: now.Add(25 * 24 * time.Hour)}, slow, nil, "", now); s.Message != "" { - t.Fatalf("temporary, 25 days left: %+v", s) - } - // A rejected token is said at once, while the certificate still works. - okUntil := Observation{Issuer: IssuerLetsEncrypt, NotAfter: time.Date(2026, 11, 7, 9, 0, 0, 0, time.Local)} - s = Compute(pub(state.StageLive), okUntil, newErr, nil, "example.com", now) - if s.State != "ok" || !strings.Contains(s.Message, "Cloudflare stopped accepting your token") || !strings.Contains(s.Message, "Nov 7") || s.Fix != "token" { - t.Fatalf("rejected on renewal: %+v", s) - } - caa := &Event{At: now, Error: "urn:ietf:params:acme:error:caa: CAA record for example.com prevents issuance"} - if s := Compute(pub(state.StageLive), okUntil, caa, nil, "example.com", now); !strings.Contains(s.Message, "CAA") || s.Fix != "" { - t.Fatalf("caa on renewal: %+v", s) - } - // The daily check finds it before any renewal is tried. - check := &dns.Problem{Kind: dns.KindZone, FieldError: &state.FieldError{Field: "api_token", Msg: "can't see example.com"}} - s = Compute(pub(state.StageLive), okUntil, nil, check, "example.com", now) - if s.State != "ok" || !strings.Contains(s.Message, "can't change example.com any more") || s.Fix != "token" || s.Detail != check.Msg { - t.Fatalf("daily check: %+v", s) - } - // Failing outright with a rejected token points at the token too. - if s := Compute(pub(state.StageLive), Observation{Issuer: IssuerInternal}, newErr, nil, "", now); s.Fix != "token" { - t.Fatalf("failed fix: %+v", s) - } - if s := Compute(pub(state.StageLive), Observation{Issuer: IssuerInternal}, newErr, nil, "", now); s.State != "failed" { - t.Fatalf("live failed: %s", s.State) - } -} - -type fakeMgr struct { - st *state.State - applied int -} - -func (f *fakeMgr) State() *state.State { return f.st.Clone() } -func (f *fakeMgr) Apply(fn func(*state.State) error) error { - f.applied++ - return fn(f.st) -} - -func TestWatchMovesTrialToLive(t *testing.T) { - m := &fakeMgr{st: &state.State{Settings: state.Settings{Domain: "home.example.com", Certificates: &state.Certificates{Provider: "desec", Stage: state.StageTrial}}}} - // Exercise one tick's worth of logic directly. - obs := Observation{Issuer: IssuerStaging} - restarts := 0 - restart := func() error { restarts++; return nil } - tick(m, func(string) Observation { return Observation{} }, restart) - if m.applied != 0 || restarts != 0 { - t.Fatal("no staging certificate yet: nothing may change") - } - tick(m, func(string) Observation { return obs }, restart) - if m.st.Settings.Certificates.Stage != state.StageLive || m.applied != 1 || restarts != 1 { - t.Fatalf("got %+v", m.st.Settings.Certificates) - } - tick(m, func(string) Observation { return obs }, restart) - if m.applied != 1 || restarts != 1 { - t.Fatal("live must not be touched again") - } -} - -func TestRedactKeepsTokensOutOfDetails(t *testing.T) { - tok := "a1b2c3d4-e5f6-7890-abcd-ef0123456789" - _, detail := Explain("DuckDNS request failed, expected (OK) but got (KO), url: [https://www.duckdns.org/update?domains=you&token="+tok+"&txt=abc], body: KO", "you.duckdns.org", "DuckDNS") - if strings.Contains(detail, tok) || !strings.Contains(detail, "token=…&txt=abc") { - t.Fatalf("token in detail: %s", detail) - } -} - -func TestLastLine(t *testing.T) { - dir := t.TempDir() - log := filepath.Join(dir, "issuance.log") - os.WriteFile(log, []byte(`{"ts":1793360000.5,"msg":"obtaining certificate","identifier":"*.example.org"} -{"ts":1793360030.5,"msg":"waiting on internal rate limiter","identifiers":["*.example.org"]} -{"ts":1793360040.5,"msg":"obtaining certificate","identifier":"*.other.nz"} -`), 0o644) - if got := LastLine(log, "*.example.org"); !strings.HasSuffix(got, " waiting on internal rate limiter") { - t.Errorf("LastLine = %q", got) - } - if got := LastLine(filepath.Join(dir, "none"), "*.example.org"); got != "" { - t.Errorf("no log: %q", got) - } -} diff --git a/admin/internal/dns/check.go b/admin/internal/dns/check.go deleted file mode 100644 index 1225796..0000000 --- a/admin/internal/dns/check.go +++ /dev/null @@ -1,96 +0,0 @@ -package dns - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "net/url" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// cloudflareAPI is a variable so tests can point it at a fake. -var cloudflareAPI = "https://api.cloudflare.com/client/v4" - -// Check asks the provider, before any certificate is tried, whether the -// credentials can reach zone's DNS. A wrong token then fails in a second -// with what to fix, not a minute later inside the trial. Providers without -// a check, and a provider that cannot be reached, return nil: the trial -// still tells. -func Check(ctx context.Context, c *Config, zone string) error { - switch c.Provider { - case "cloudflare": - return checkCloudflare(ctx, c.Fields["api_token"], zone) - } - return nil -} - -// Problem kinds: only the person can fix these. -const ( - KindRejected = "rejected" // the provider refuses the credentials - KindZone = "zone" // they work, but not for this zone - KindPermission = "permission" // the zone, but not its records -) - -// Problem is a check's answer the person must act on. It unwraps to the -// form's field error, which setup shows under the token field. -type Problem struct { - Kind string - *state.FieldError -} - -func (p *Problem) Unwrap() error { return p.FieldError } - -func checkCloudflare(ctx context.Context, token, zone string) error { - problem := func(kind, msg string) error { - return &Problem{Kind: kind, FieldError: &state.FieldError{Field: "api_token", Msg: msg}} - } - get := func(path string, out any) (int, error) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, cloudflareAPI+path, nil) - req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - return resp.StatusCode, json.NewDecoder(resp.Body).Decode(out) - } - - // Zones the token can see: any kind of token (profile or account) can - // ask, unlike /user/tokens/verify. - var zones struct { - Result []struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"result"` - } - code, err := get("/zones?name="+url.QueryEscape(zone), &zones) - switch { - case err != nil && code == 0: - log.Printf("[admin] certificates: could not ask Cloudflare about the token (%v); the test certificate will tell", err) - return nil - case code == http.StatusUnauthorized || code == http.StatusForbidden || code == http.StatusBadRequest: - return problem(KindRejected, "Cloudflare doesn't accept this token. Copy it again; Cloudflare shows it only once, so you may need to make a new one.") - case code/100 != 2: - log.Printf("[admin] certificates: Cloudflare answered %d about the token; the test certificate will tell", code) - return nil - case len(zones.Result) == 0: - return problem(KindZone, fmt.Sprintf("The token works, but it can't see %s. In Cloudflare, edit the token and choose %s under Zone Resources.", zone, zone)) - } - - // The template's permission is DNS Edit, which includes reading. - var recs struct{} - code, err = get("/zones/"+url.PathEscape(zones.Result[0].ID)+"/dns_records?per_page=1", &recs) - switch { - case err != nil && code == 0: - return nil - case code == http.StatusUnauthorized || code == http.StatusForbidden: - return problem(KindPermission, fmt.Sprintf("The token can see %s but not its DNS records. Make it with the Edit zone DNS template.", zone)) - } - return nil -} diff --git a/admin/internal/dns/check_test.go b/admin/internal/dns/check_test.go deleted file mode 100644 index 3c1edd7..0000000 --- a/admin/internal/dns/check_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package dns - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// A fake Cloudflare: "good" sees ahze.net and its records, "blind" sees no -// zone, "readonly" sees the zone but not its records, anything else is -// refused. -func fakeCloudflare(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - switch { - case tok != "good" && tok != "blind" && tok != "readonly": - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"success":false,"errors":[{"code":6003,"message":"Invalid request headers"}]}`)) - case r.URL.Path == "/zones" && tok == "blind": - w.Write([]byte(`{"success":true,"result":[]}`)) - case r.URL.Path == "/zones": - if r.URL.Query().Get("name") != "ahze.net" { - t.Errorf("asked about %q", r.URL.Query().Get("name")) - } - w.Write([]byte(`{"success":true,"result":[{"id":"z1","name":"ahze.net"}]}`)) - case r.URL.Path == "/zones/z1/dns_records" && tok == "readonly": - w.WriteHeader(http.StatusForbidden) - w.Write([]byte(`{"success":false}`)) - default: - w.Write([]byte(`{"success":true,"result":[]}`)) - } - })) - t.Cleanup(srv.Close) - old := cloudflareAPI - cloudflareAPI = srv.URL - t.Cleanup(func() { cloudflareAPI = old }) -} - -func TestCheckCloudflare(t *testing.T) { - fakeCloudflare(t) - for tok, want := range map[string]string{ - "good": "", - "wrong": "doesn't accept this token", - "blind": "can't see ahze.net", - "readonly": "not its DNS records", - } { - kind := map[string]string{"wrong": KindRejected, "blind": KindZone, "readonly": KindPermission}[tok] - err := Check(context.Background(), &Config{Provider: "cloudflare", Fields: map[string]string{"api_token": tok}}, "ahze.net") - if want == "" { - if err != nil { - t.Errorf("%s: %v", tok, err) - } - continue - } - var fe *state.FieldError - if !errors.As(err, &fe) || fe.Field != "api_token" || !strings.Contains(fe.Msg, want) { - t.Errorf("%s: got %v, want %q", tok, err, want) - } - var p *Problem - if !errors.As(err, &p) || p.Kind != kind { - t.Errorf("%s: kind %v, want %s", tok, p, kind) - } - } -} - -// No answer from Cloudflare must not block: the trial still tells. -func TestCheckCloudflareUnreachable(t *testing.T) { - old := cloudflareAPI - cloudflareAPI = "http://127.0.0.1:1" - defer func() { cloudflareAPI = old }() - if err := Check(context.Background(), &Config{Provider: "cloudflare", Fields: map[string]string{"api_token": "x"}}, "ahze.net"); err != nil { - t.Fatal(err) - } -} diff --git a/admin/internal/dns/dns.go b/admin/internal/dns/dns.go deleted file mode 100644 index edb2f17..0000000 --- a/admin/internal/dns/dns.go +++ /dev/null @@ -1,314 +0,0 @@ -// Package dns knows the DNS providers Caddy can edit records at, keeps the -// credentials for the one in use, and guesses which one a domain lives at. -package dns - -import ( - "context" - "encoding/json" - "errors" - "net" - "os" - "path/filepath" - "regexp" - "slices" - "sort" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// Field is one thing a provider needs from the person. -type Field struct { - Key string `json:"key"` // the Caddy module's JSON key - Label string `json:"label"` - Secret bool `json:"secret"` - Placeholder string `json:"placeholder,omitempty"` - Optional bool `json:"optional,omitempty"` - // Shape, when set, is what a value must look like (a regexp), and - // ShapeMsg what to say when it doesn't: a wrong paste is caught here, - // not by a failed certificate. - Shape string `json:"-"` - ShapeMsg string `json:"-"` -} - -// Free is a provider that gives out names under its own domain at no cost: -// the person makes one there and the proxy uses it as their ending. -type Free struct { - Suffix string `json:"suffix"` // the shared domain, e.g. duckdns.org - SignUp string `json:"signUp"` // where the name is made - // Steps make the name; {name} is the one the person is typing. - Steps []string `json:"steps"` - // Note is what to know before picking it. - Note string `json:"note,omitempty"` -} - -// Provider is one compiled-in Caddy DNS module. -type Provider struct { - ID string `json:"id"` // Caddy's dns.providers. - Label string `json:"label"` - Fields []Field `json:"fields"` - // Help says where the credential comes from, in the provider's words. - Help string `json:"help"` - // Steps walk through making the credential; {zone} is the domain. The - // first step goes with URL. Empty: Help alone. - Steps []string `json:"steps,omitempty"` - // URL opens the page that makes one, when there is a stable one. - URL string `json:"url,omitempty"` - // Nameservers are substrings of the zone's NS names that identify it. - Nameservers []string `json:"-"` - // Checked: Connect asks the provider whether the credentials work - // (Check) before the test certificate. - Checked bool `json:"checked,omitempty"` - // Generic providers (a DNS server, acme-dns) are offered last. - Generic bool `json:"generic,omitempty"` - // Free names: set for a provider that gives them out. - Free *Free `json:"free,omitempty"` -} - -// Providers is the catalog, in the order the UI offers them. Field keys -// are the libdns provider structs' JSON tags, read from their sources. -var Providers = []Provider{ - {ID: "cloudflare", Label: "Cloudflare", - Fields: []Field{{Key: "api_token", Label: "API token", Secret: true}}, - Help: "Make an API token with the Zone › DNS › Edit permission for this zone.", - // developers.cloudflare.com/fundamentals/api/get-started/create-token/ - Steps: []string{ - "Open Cloudflare's API Tokens page and sign in.", - "Click Create Token.", - // The blue Get started at the top makes a custom token from - // nothing; the template is further down. - "Find Edit zone DNS in the list and click its Use template button, not Get started.", - "Under Zone Resources, leave Include and Specific zone, and choose {zone}.", - "Click Continue to summary, then Create Token.", - "Copy the token Cloudflare shows and paste it below. Cloudflare shows it only once.", - }, - URL: "https://dash.cloudflare.com/profile/api-tokens", - Checked: true, - Nameservers: []string{"ns.cloudflare.com"}}, - {ID: "route53", Label: "Amazon Route 53", - Fields: []Field{ - {Key: "access_key_id", Label: "Access key ID", Placeholder: "AKIA…"}, - {Key: "secret_access_key", Label: "Secret access key", Secret: true}, - {Key: "region", Label: "Region", Placeholder: "us-east-1", Optional: true}, - }, - Help: "An IAM user or key that may change records in the hosted zone (route53:ChangeResourceRecordSets, ListHostedZones).", - Nameservers: []string{"awsdns-"}}, - {ID: "duckdns", Label: "DuckDNS", - Fields: []Field{{Key: "api_token", Label: "Token", Secret: true, Placeholder: "a1b2c3d4-…", - Shape: `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, - ShapeMsg: "a DuckDNS token is 36 characters, like a1b2c3d4-e5f6-… Copy the one next to \"token\" at the top of duckdns.org."}}, - Help: "The token at the top of your duckdns.org page.", - Steps: []string{ - "Open duckdns.org and sign in.", - "Copy the token shown at the top of the page, next to \"token\", and paste it below.", - }, - URL: "https://www.duckdns.org", - Free: &Free{Suffix: "duckdns.org", SignUp: "https://www.duckdns.org", - Steps: []string{ - "Open duckdns.org and sign in with Google, GitHub, Reddit or X.", - "Type {name} under \"sub domain\" and click \"add domain\".", - }}}, - // deSEC also gave out free dedyn.io names, until it suspended new ones - // "until further notice" (2026-09-21): here it is for domains people - // already have there, not a free name to sign up for. - {ID: "desec", Label: "deSEC", - Fields: []Field{{Key: "token", Label: "Token", Secret: true}}, - Help: "Make a token under Token management.", - URL: "https://desec.io/tokens", - Nameservers: []string{"desec.io", "desec.org"}}, - {ID: "porkbun", Label: "Porkbun", - Fields: []Field{ - {Key: "api_key", Label: "API key", Placeholder: "pk1_…"}, - {Key: "api_secret_key", Label: "Secret key", Secret: true, Placeholder: "sk1_…"}, - }, - Help: "Make an API key, then switch on API access for the domain on its details page.", - URL: "https://porkbun.com/account/api", - Nameservers: []string{"porkbun.com"}}, - {ID: "hetzner", Label: "Hetzner", - Fields: []Field{{Key: "api_token", Label: "API token", Secret: true}}, - Help: "Make an API token in the DNS console settings.", - URL: "https://dns.hetzner.com/settings/api-token", - Nameservers: []string{"hetzner.com", "hetzner.de", "your-server.de"}}, - {ID: "ovh", Label: "OVH", - Fields: []Field{ - {Key: "endpoint", Label: "Endpoint", Placeholder: "ovh-eu"}, - {Key: "application_key", Label: "Application key"}, - {Key: "application_secret", Label: "Application secret", Secret: true}, - {Key: "consumer_key", Label: "Consumer key", Secret: true}, - }, - Help: "Create API keys with GET, PUT, POST and DELETE rights on /domain/zone/*.", - URL: "https://www.ovh.com/auth/api/createToken", - Nameservers: []string{"ovh.net", "anycast.me"}}, - {ID: "gandi", Label: "Gandi", - Fields: []Field{{Key: "bearer_token", Label: "Personal access token", Secret: true}}, - Help: "Make a personal access token for the organization that owns the domain, allowed to manage domain name technical configurations.", - Nameservers: []string{"gandi.net"}}, - {ID: "rfc2136", Label: "My own DNS server (RFC 2136)", Generic: true, - Fields: []Field{ - {Key: "server", Label: "Server", Placeholder: "ns1.example.com:53"}, - {Key: "key_name", Label: "Key name", Placeholder: "proxy-key"}, - {Key: "key_alg", Label: "Key algorithm", Placeholder: "hmac-sha256"}, - {Key: "key", Label: "Key", Secret: true}, - }, - Help: "A TSIG key the server accepts dynamic updates with (BIND, PowerDNS, Knot)."}, - {ID: "acmedns", Label: "acme-dns", Generic: true, - Fields: []Field{ - {Key: "server_url", Label: "Server", Placeholder: "https://auth.acme-dns.io"}, - {Key: "username", Label: "Username"}, - {Key: "password", Label: "Password", Secret: true}, - {Key: "subdomain", Label: "Subdomain"}, - }, - Help: "The account acme-dns returned when you registered. Point _acme-challenge under your domain at that account's name with a CNAME record."}, -} - -// Find returns the provider with id. -func Find(id string) (Provider, bool) { - for _, p := range Providers { - if p.ID == id { - return p, true - } - } - return Provider{}, false -} - -// Config is the provider in use and what it was given. It lives in the -// secrets directory, never in the state file. -type Config struct { - Provider string `json:"provider"` - Fields map[string]string `json:"fields"` -} - -// File is the secrets file name; the backup carries it. -const File = "dns.json" - -// Load reads the saved configuration; ErrNotExist when there is none. -func Load(secrets string) (*Config, error) { - raw, err := os.ReadFile(filepath.Join(secrets, File)) - if err != nil { - return nil, err - } - var c Config - if err := json.Unmarshal(raw, &c); err != nil { - return nil, err - } - return &c, nil -} - -// Save writes the configuration, readable by the admin only. -func Save(secrets string, c *Config) error { - data, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(secrets, 0o700); err != nil { - return err - } - p := filepath.Join(secrets, File) - tmp, err := os.CreateTemp(secrets, ".dns-*.json") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if _, err := tmp.Write(append(data, '\n')); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmp.Name(), 0o600); err != nil { - return err - } - return os.Rename(tmp.Name(), p) -} - -// Remove forgets the configuration. -func Remove(secrets string) error { - err := os.Remove(filepath.Join(secrets, File)) - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err -} - -// Validate checks a configuration the person entered and trims it. -func Validate(c *Config) error { - p, ok := Find(c.Provider) - if !ok { - return &state.FieldError{Field: "provider", Msg: "choose where your domain's DNS is hosted"} - } - clean := map[string]string{} - for _, f := range p.Fields { - v := strings.TrimSpace(c.Fields[f.Key]) - if v == "" && !f.Optional { - return &state.FieldError{Field: f.Key, Msg: "enter the " + strings.ToLower(f.Label)} - } - if v != "" && f.Shape != "" && !regexp.MustCompile(f.Shape).MatchString(v) { - return &state.FieldError{Field: f.Key, Msg: f.ShapeMsg} - } - if v != "" { - clean[f.Key] = v - } - } - c.Fields = clean - return nil -} - -// Guess is what Detect learned about a domain. -type Guess struct { - Zone string `json:"zone"` // the zone the domain is in - Nameservers []string `json:"nameservers"` // its NS names - Provider string `json:"provider,omitempty"` // a catalog id, when recognised -} - -// Detect finds the zone a domain belongs to (the nearest ancestor with NS -// records) and which provider those nameservers belong to. -func Detect(ctx context.Context, domain string) Guess { - return detect(ctx, domain, net.DefaultResolver.LookupNS) -} - -func detect(ctx context.Context, domain string, lookupNS func(context.Context, string) ([]*net.NS, error)) Guess { - labels := strings.Split(strings.Trim(strings.ToLower(domain), "."), ".") - // A free name is its own zone, whatever DNS says: you.duckdns.org has - // no NS records of its own, and the shared domain is not the person's. - for _, p := range Providers { - if p.Free == nil { - continue - } - suffix := strings.Split(p.Free.Suffix, ".") - if n := len(labels) - len(suffix); n >= 1 && slices.Equal(labels[n:], suffix) { - return Guess{Zone: strings.Join(labels[n-1:], "."), Nameservers: []string{}, Provider: p.ID} - } - } - for i := 0; i+1 < len(labels); i++ { - zone := strings.Join(labels[i:], ".") - c, cancel := context.WithTimeout(ctx, 3*time.Second) - nss, err := lookupNS(c, zone) - cancel() - if err != nil || len(nss) == 0 { - continue - } - g := Guess{Zone: zone} - for _, ns := range nss { - g.Nameservers = append(g.Nameservers, strings.TrimSuffix(strings.ToLower(ns.Host), ".")) - } - sort.Strings(g.Nameservers) - g.Provider = match(g.Nameservers) - return g - } - return Guess{} -} - -func match(nameservers []string) string { - for _, p := range Providers { - for _, hint := range p.Nameservers { - for _, ns := range nameservers { - if strings.Contains(ns, hint) { - return p.ID - } - } - } - } - return "" -} diff --git a/admin/internal/dns/dns_test.go b/admin/internal/dns/dns_test.go deleted file mode 100644 index cddc58d..0000000 --- a/admin/internal/dns/dns_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package dns - -import ( - "context" - "errors" - "net" - "os" - "path/filepath" - "regexp" - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -func TestDetectWalksUpToTheZone(t *testing.T) { - lookup := func(_ context.Context, name string) ([]*net.NS, error) { - if name == "example.com" { - return []*net.NS{{Host: "Ada.ns.cloudflare.com."}, {Host: "bob.ns.cloudflare.com."}}, nil - } - return nil, errors.New("no NS") - } - g := detect(context.Background(), "Home.Example.com.", lookup) - if g.Zone != "example.com" || g.Provider != "cloudflare" || len(g.Nameservers) != 2 || g.Nameservers[0] != "ada.ns.cloudflare.com" { - t.Fatalf("got %+v", g) - } -} - -func TestDetectUnknownProvider(t *testing.T) { - lookup := func(_ context.Context, name string) ([]*net.NS, error) { - return []*net.NS{{Host: "ns1.example.net."}}, nil - } - g := detect(context.Background(), "home.example.com", lookup) - if g.Zone != "home.example.com" || g.Provider != "" { - t.Fatalf("got %+v", g) - } - if g := detect(context.Background(), "com", lookup); g.Zone != "" { - t.Fatalf("single label must not be looked up: %+v", g) - } -} - -func TestValidateTrimsAndRequires(t *testing.T) { - c := &Config{Provider: "cloudflare", Fields: map[string]string{"api_token": " abc ", "junk": "x"}} - if err := Validate(c); err != nil { - t.Fatal(err) - } - if c.Fields["api_token"] != "abc" || len(c.Fields) != 1 { - t.Fatalf("fields %v", c.Fields) - } - if err := Validate(&Config{Provider: "route53", Fields: map[string]string{"access_key_id": "A"}}); err == nil { - t.Fatal("missing secret must fail") - } - if err := Validate(&Config{Provider: "route53", Fields: map[string]string{"access_key_id": "A", "secret_access_key": "S"}}); err != nil { - t.Fatalf("optional region: %v", err) - } - if err := Validate(&Config{Provider: "nope"}); err == nil { - t.Fatal("unknown provider must fail") - } -} - -func TestSaveLoadRemove(t *testing.T) { - dir := t.TempDir() - if _, err := Load(dir); err == nil { - t.Fatal("expected not exist") - } - if err := Save(dir, &Config{Provider: "desec", Fields: map[string]string{"token": "t"}}); err != nil { - t.Fatal(err) - } - c, err := Load(dir) - if err != nil || c.Provider != "desec" || c.Fields["token"] != "t" { - t.Fatalf("%v %+v", err, c) - } - if err := Remove(dir); err != nil { - t.Fatal(err) - } - if err := Remove(dir); err != nil { - t.Fatalf("second remove: %v", err) - } - if _, err := Load(filepath.Join(dir)); err == nil { - t.Fatal("still there") - } -} - -func TestEveryProviderIsCompiledIntoCaddy(t *testing.T) { - // The xcaddy --with list in Containerfile.j2. - raw, err := os.ReadFile("../../../Containerfile.j2") - if err != nil { - t.Fatal(err) - } - want := map[string]bool{} - for _, m := range regexp.MustCompile(`--with github\.com/caddy-dns/([a-z0-9]+)`).FindAllStringSubmatch(string(raw), -1) { - want[m[1]] = true - } - if len(want) < 9 { - t.Fatalf("read only %d modules from Containerfile.j2", len(want)) - } - for _, p := range Providers { - if !want[p.ID] { - t.Errorf("%s is not built into Caddy", p.ID) - } - delete(want, p.ID) - if len(p.Fields) == 0 || p.Help == "" { - t.Errorf("%s needs fields and help", p.ID) - } - } - for id := range want { - t.Errorf("%s is built into Caddy but not offered", id) - } -} - -// A free name is its own zone and needs no DNS lookup: you.duckdns.org has -// no NS records, and duckdns.org is not the person's zone. -func TestDetectFreeNames(t *testing.T) { - noLookup := func(context.Context, string) ([]*net.NS, error) { - t.Fatal("a free name must not need a lookup") - return nil, nil - } - for domain, want := range map[string]Guess{ - "you.duckdns.org": {Zone: "you.duckdns.org", Provider: "duckdns"}, - "home.you.duckdns.org": {Zone: "you.duckdns.org", Provider: "duckdns"}, - } { - g := detect(context.Background(), domain, noLookup) - if g.Zone != want.Zone || g.Provider != want.Provider || g.Nameservers == nil { - t.Errorf("%s: %+v", domain, g) - } - } - // The shared domain itself is not a free name. - g := detect(context.Background(), "duckdns.org", func(context.Context, string) ([]*net.NS, error) { return nil, errors.New("none") }) - if g.Provider != "" { - t.Errorf("duckdns.org: %+v", g) - } -} - -func TestValidateTokenShape(t *testing.T) { - bad := &Config{Provider: "duckdns", Fields: map[string]string{"api_token": "my-password"}} - var fe *state.FieldError - if err := Validate(bad); !errors.As(err, &fe) || fe.Field != "api_token" || fe.Msg == "" { - t.Fatalf("bad shape: %v", err) - } - good := &Config{Provider: "duckdns", Fields: map[string]string{"api_token": " a1b2c3d4-e5f6-7890-abcd-ef0123456789 "}} - if err := Validate(good); err != nil || good.Fields["api_token"] != "a1b2c3d4-e5f6-7890-abcd-ef0123456789" { - t.Fatalf("good: %v %+v", err, good.Fields) - } -} diff --git a/admin/internal/dns/refusal.go b/admin/internal/dns/refusal.go deleted file mode 100644 index 3b00337..0000000 --- a/admin/internal/dns/refusal.go +++ /dev/null @@ -1,33 +0,0 @@ -package dns - -import ( - "maps" - "sync" -) - -// Refusal remembers credentials Caddy would not load at startup (a token -// the provider module rejects by its shape, say from a restored backup). -// The proxy then runs on its own certificates instead of not at all, until -// the saved credentials change. -type Refusal struct { - mu sync.Mutex - cfg *Config - err string -} - -// Set records c as refused with Caddy's error. -func (r *Refusal) Set(c *Config, err error) { - r.mu.Lock() - defer r.mu.Unlock() - r.cfg, r.err = c, err.Error() -} - -// Refused is Caddy's error when c is the refused configuration, else "". -func (r *Refusal) Refused(c *Config) string { - r.mu.Lock() - defer r.mu.Unlock() - if r.cfg == nil || c == nil || c.Provider != r.cfg.Provider || !maps.Equal(c.Fields, r.cfg.Fields) { - return "" - } - return r.err -} diff --git a/admin/internal/dns/refusal_test.go b/admin/internal/dns/refusal_test.go deleted file mode 100644 index 3f1d774..0000000 --- a/admin/internal/dns/refusal_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package dns - -import ( - "errors" - "testing" -) - -func TestRefusalUntilTheCredentialsChange(t *testing.T) { - var r Refusal - bad := &Config{Provider: "cloudflare", Fields: map[string]string{"api_token": "x"}} - if r.Refused(bad) != "" { - t.Fatal("nothing refused yet") - } - r.Set(bad, errors.New("API token 'x' appears invalid")) - same := &Config{Provider: "cloudflare", Fields: map[string]string{"api_token": "x"}} - if r.Refused(same) == "" { - t.Fatal("the same credentials, loaded again, must stay refused") - } - if r.Refused(&Config{Provider: "cloudflare", Fields: map[string]string{"api_token": "y"}}) != "" { - t.Fatal("a new token must be tried") - } - if r.Refused(nil) != "" { - t.Fatal("no credentials, nothing refused") - } -} diff --git a/admin/internal/netif/netif.go b/admin/internal/netif/netif.go deleted file mode 100644 index 67efdfe..0000000 --- a/admin/internal/netif/netif.go +++ /dev/null @@ -1,163 +0,0 @@ -// Package netif lists the networks the container is on, and turns the one -// the proxy answers on into the addresses Caddy binds. -package netif - -import ( - "net" - "sort" - "strings" -) - -// Network is one interface with an IPv4 address: what a person picks from. -type Network struct { - Interface string `json:"interface"` - Network string `json:"network"` // 192.168.86.0/24 - Address string `json:"address"` // 192.168.86.11 - // Addresses are every address it answers on there: the IPv4 one and - // any global IPv6 ones. Link-local addresses need a zone Caddy cannot - // bind by name, and nobody types them. - Addresses []string `json:"-"` -} - -// Interfaces is the system's list; tests replace it. -var Interfaces = func() ([]Iface, error) { - ifs, err := net.Interfaces() - if err != nil { - return nil, err - } - var out []Iface - for _, i := range ifs { - if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 { - continue - } - addrs, err := i.Addrs() - if err != nil { - continue - } - var nets []*net.IPNet - for _, a := range addrs { - if n, ok := a.(*net.IPNet); ok { - nets = append(nets, n) - } - } - out = append(out, Iface{Name: i.Name, Addrs: nets}) - } - return out, nil -} - -// Iface is an interface and its addresses. -type Iface struct { - Name string - Addrs []*net.IPNet -} - -// List is the networks the container is on, by interface name. -func List() []Network { - ifs, err := Interfaces() - if err != nil { - return nil - } - var out []Network - for _, i := range ifs { - var n Network - for _, a := range i.Addrs { - if a.IP.IsLoopback() || a.IP.IsLinkLocalUnicast() { - continue - } - if v4 := a.IP.To4(); v4 != nil && n.Address == "" { - n.Address = v4.String() - n.Network = (&net.IPNet{IP: v4.Mask(a.Mask), Mask: a.Mask}).String() - } - n.Addresses = append(n.Addresses, a.IP.String()) - } - if n.Address == "" { - continue - } - n.Interface = i.Name - out = append(out, n) - } - sort.Slice(out, func(a, b int) bool { return out[a].Interface < out[b].Interface }) - return out -} - -// Find is the network a setting names: its subnet (192.168.86.0/24), an -// address on it, or an interface name (eth0). ok is false when the -// container is not on it (yet: a DHCP lease still to come). -func Find(networks []Network, want string) (Network, bool) { - want = strings.TrimSpace(want) - _, cidr, cerr := net.ParseCIDR(want) - ip := net.ParseIP(want) - for _, n := range networks { - switch { - case n.Interface == want, n.Network == want: - return n, true - case cerr == nil && cidr.Contains(net.ParseIP(n.Address)): - return n, true - case ip != nil: - for _, a := range n.Addresses { - if net.ParseIP(a).Equal(ip) { - return n, true - } - } - } - } - return Network{}, false -} - -// Contains reports whether address (an IP, maybe with a port) is on n. -func (n Network) Contains(address string) bool { - if h, _, err := net.SplitHostPort(address); err == nil { - address = h - } - ip := net.ParseIP(strings.Trim(address, "[]")) - if ip == nil { - return false - } - for _, a := range n.Addresses { - if net.ParseIP(a).Equal(ip) { - return true - } - } - return false -} - -// Addresses is what a choice of networks binds: the addresses of each one -// the container is on, in order. nil for no choice (every network); empty -// when none of them has an address yet. -func Addresses(networks []Network, wants []string) []string { - if len(wants) == 0 { - return nil - } - out := []string{} - seen := map[string]bool{} - for _, w := range wants { - n, ok := Find(networks, w) - if !ok || seen[n.Network] { - continue - } - seen[n.Network] = true - out = append(out, n.Addresses...) - } - return out -} - -// Shut is the subnets of the networks a choice leaves out (none when there -// is no choice: every network). -func Shut(networks []Network, wants []string) []string { - if len(wants) == 0 { - return nil - } - chosen := map[string]bool{} - for _, w := range wants { - if n, ok := Find(networks, w); ok { - chosen[n.Network] = true - } - } - var out []string - for _, n := range networks { - if !chosen[n.Network] { - out = append(out, n.Network) - } - } - return out -} diff --git a/admin/internal/netif/netif_test.go b/admin/internal/netif/netif_test.go deleted file mode 100644 index 5460934..0000000 --- a/admin/internal/netif/netif_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package netif - -import ( - "net" - "testing" -) - -func cidr(s string) *net.IPNet { - ip, n, _ := net.ParseCIDR(s) - n.IP = ip - return n -} - -func TestListAndFind(t *testing.T) { - Interfaces = func() ([]Iface, error) { - return []Iface{ - {Name: "eth1", Addrs: []*net.IPNet{cidr("192.168.5.145/24")}}, - {Name: "eth0", Addrs: []*net.IPNet{cidr("192.168.86.11/24"), cidr("fe80::1/64"), cidr("2001:db8::5/64")}}, - {Name: "eth9", Addrs: []*net.IPNet{cidr("fe80::9/64")}}, // no IPv4: not offered - }, nil - } - nets := List() - if len(nets) != 2 || nets[0].Interface != "eth0" || nets[0].Network != "192.168.86.0/24" || nets[0].Address != "192.168.86.11" { - t.Fatalf("list: %+v", nets) - } - if a := nets[0].Addresses; len(a) != 2 || a[1] != "2001:db8::5" { - t.Fatalf("eth0 binds %v (no link-local)", a) - } - for _, want := range []string{"eth0", "192.168.86.0/24", "192.168.86.11", "2001:db8::5", "192.168.86.0/25"} { - if n, ok := Find(nets, want); !ok || n.Interface != "eth0" { - t.Fatalf("Find(%q) = %+v %v", want, n, ok) - } - } - if _, ok := Find(nets, "10.0.0.0/8"); ok { - t.Fatal("found a network it is not on") - } - if !nets[0].Contains("192.168.86.11:443") || nets[0].Contains("192.168.5.145") { - t.Fatal("Contains") - } -} - -func TestAddresses(t *testing.T) { - nets := []Network{ - {Interface: "eth0", Network: "192.168.86.0/24", Address: "192.168.86.11", Addresses: []string{"192.168.86.11", "2001:db8::5"}}, - {Interface: "eth2", Network: "192.168.5.0/24", Address: "192.168.5.145", Addresses: []string{"192.168.5.145"}}, - } - if Addresses(nets, nil) != nil { - t.Fatal("no choice must be every network (nil)") - } - if got := Addresses(nets, []string{"192.168.86.0/24", "eth2", "eth0"}); len(got) != 3 || got[2] != "192.168.5.145" { - t.Fatalf("two networks: %v", got) - } - if got := Addresses(nets, []string{"10.0.0.0/8"}); got == nil || len(got) != 0 { - t.Fatalf("not on it yet must be empty, not nil: %v", got) - } -} - -func TestShut(t *testing.T) { - nets := []Network{ - {Interface: "eth0", Network: "192.168.86.0/24", Address: "192.168.86.11"}, - {Interface: "eth1", Network: "192.168.4.0/24", Address: "192.168.4.204"}, - {Interface: "eth2", Network: "192.168.5.0/24", Address: "192.168.5.145"}, - } - if Shut(nets, nil) != nil { - t.Fatal("no choice shuts nothing") - } - if got := Shut(nets, []string{"eth0", "192.168.5.0/24"}); len(got) != 1 || got[0] != "192.168.4.0/24" { - t.Fatalf("shut %v", got) - } -} diff --git a/admin/internal/probe/probe.go b/admin/internal/probe/probe.go deleted file mode 100644 index 0c9cce8..0000000 --- a/admin/internal/probe/probe.go +++ /dev/null @@ -1,141 +0,0 @@ -// Package probe looks at a target so Add Host can say "found a service" and -// pick http or https for the user. -package probe - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "net" - "net/http" - "strconv" - "time" -) - -// Result is what we learned about address:port. -type Result struct { - Reachable bool `json:"reachable"` - HTTPS bool `json:"https"` - UntrustedCert bool `json:"untrustedCert"` - Status int `json:"status,omitempty"` - Server string `json:"server,omitempty"` - // Self: the target is this proxy's own 80 or 443, usually an old DNS - // record for the name. Saving it makes the name a loop. - Self bool `json:"self,omitempty"` -} - -// sitePorts are the ports Caddy serves names on inside the container. Other -// ports on the proxy's own address are other services (host networking). -var sitePorts = map[int]bool{80: true, 443: true} - -// isSelf reports whether address resolves to one of this container's own -// addresses. -func isSelf(ctx context.Context, address string, port int) bool { - if !sitePorts[port] { - return false - } - ips := []net.IP{net.ParseIP(address)} - if ips[0] == nil { - addrs, err := net.DefaultResolver.LookupIPAddr(ctx, address) - if err != nil { - return false - } - ips = ips[:0] - for _, a := range addrs { - ips = append(ips, a.IP) - } - } - own, err := net.InterfaceAddrs() - if err != nil { - return false - } - for _, ip := range ips { - if ip.IsLoopback() { - return true - } - for _, a := range own { - if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) { - return true - } - } - } - return false -} - -const timeout = 3 * time.Second - -// Probe runs from inside the container, where the proxy itself will connect -// from, so what it sees is what Caddy will see. -func Probe(ctx context.Context, address string, port int) Result { - var r Result - addr := net.JoinHostPort(address, strconv.Itoa(port)) - d := net.Dialer{Timeout: timeout} - - conn, err := d.DialContext(ctx, "tcp", addr) - if err != nil { - return r - } - conn.Close() - r.Reachable = true - if isSelf(ctx, address, port) { - r.Self = true - return r - } - - // Is it TLS? A plain HTTP server answers the ClientHello with something - // that is not a TLS record; a certificate we cannot verify, or an alert - // (the server wants an SNI we don't know), still means TLS. Anything - // else -- the server hung up on the ClientHello, as nginx on a plain - // port does -- is settled by asking in plain HTTP: an answer there is - // an http app (apprise behind nginx read as https, and the proxy then - // spoke TLS to it: 503 behind its name). - tconn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: address, MinVersion: tls.VersionTLS12}) - switch { - case err == nil: - tconn.Close() - r.HTTPS = true - case errors.As(err, new(tls.RecordHeaderError)): - // not TLS - case errors.As(err, new(x509.UnknownAuthorityError)), errors.As(err, new(x509.HostnameError)), - errors.As(err, new(x509.CertificateInvalidError)), errors.As(err, new(tls.AlertError)): - r.HTTPS, r.UntrustedCert = true, true - default: - if !answersPlain(ctx, addr) { - r.HTTPS, r.UntrustedCert = true, true - } - } - - scheme := "http" - if r.HTTPS { - scheme = "https" - } - client := &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: r.UntrustedCert, MinVersion: tls.VersionTLS12}, - }, - CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, scheme+"://"+addr+"/", nil) - resp, err := client.Do(req) - if err != nil { - return r - } - resp.Body.Close() - r.Status = resp.StatusCode - r.Server = resp.Header.Get("Server") - return r -} - -// answersPlain reports whether a plain HTTP request gets any HTTP answer. -func answersPlain(ctx context.Context, addr string) bool { - client := &http.Client{Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/", nil) - resp, err := client.Do(req) - if err != nil { - return false - } - resp.Body.Close() - return true -} diff --git a/admin/internal/probe/probe_test.go b/admin/internal/probe/probe_test.go deleted file mode 100644 index 42bae3f..0000000 --- a/admin/internal/probe/probe_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package probe - -import ( - "context" - "net" - "net/http" - "net/http/httptest" - "strconv" - "testing" -) - -func split(t *testing.T, addr string) (string, int) { - t.Helper() - host, p, err := net.SplitHostPort(addr) - if err != nil { - t.Fatal(err) - } - n, _ := strconv.Atoi(p) - return host, n -} - -func TestProbePlainAndTLS(t *testing.T) { - plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", "x"); w.WriteHeader(200) })) - defer plain.Close() - h, p := split(t, plain.Listener.Addr().String()) - if r := Probe(context.Background(), h, p); !r.Reachable || r.HTTPS || r.Status != 200 { - t.Fatalf("plain: %+v", r) - } - secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) - defer secure.Close() - h, p = split(t, secure.Listener.Addr().String()) - if r := Probe(context.Background(), h, p); !r.Reachable || !r.HTTPS || !r.UntrustedCert || r.Status != 204 { - t.Fatalf("self-signed https: %+v", r) - } -} - -// nginx on a plain port hangs up on a ClientHello instead of answering it -// with HTTP; that is still a plain http app (apprise-api, 2026-10-26). -func TestProbePlainServerThatHangsUpOnTLS(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - go func() { - for { - c, err := ln.Accept() - if err != nil { - return - } - go func(c net.Conn) { - defer c.Close() - buf := make([]byte, 1) - if _, err := c.Read(buf); err != nil || buf[0] == 0x16 { // a TLS record: hang up - return - } - rest := make([]byte, 4096) - c.Read(rest) - c.Write([]byte("HTTP/1.0 200 OK\r\nServer: nginx\r\nContent-Length: 0\r\n\r\n")) - }(c) - } - }() - h, p := split(t, ln.Addr().String()) - r := Probe(context.Background(), h, p) - if !r.Reachable || r.HTTPS || r.UntrustedCert || r.Status != 200 { - t.Fatalf("plain behind a hang-up: %+v", r) - } -} - -// An old DNS record that still points at the proxy makes the name a loop -// (radarr.apps.lan at the proxy's own address, jupiter 2026-10-26). -func TestProbeSeesItself(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - defer srv.Close() - h, p := split(t, srv.Listener.Addr().String()) - if r := Probe(context.Background(), h, p); r.Self { - t.Fatalf("another port on our own address is another service: %+v", r) - } - sitePorts[p] = true - defer delete(sitePorts, p) - if r := Probe(context.Background(), h, p); !r.Reachable || !r.Self { - t.Fatalf("our own site port: %+v", r) - } -} diff --git a/admin/internal/server/address.go b/admin/internal/server/address.go deleted file mode 100644 index 6ca6a73..0000000 --- a/admin/internal/server/address.go +++ /dev/null @@ -1,107 +0,0 @@ -package server - -import ( - "context" - "net" - "net/http" - "os" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// browserAddress is the host[:port] the browser reached the admin at, or "" -// when the request did not come through Caddy or the header is not one a -// browser would send. The value is printed later, so it is checked here. -func browserAddress(r *http.Request) string { - if r.Header.Get("X-Forwarded-For") == "" { - return "" - } - h := strings.ToLower(strings.TrimSpace(r.Host)) - if h == "" || len(h) > 253 { - return "" - } - host, port := h, "" - if hp, p, err := net.SplitHostPort(h); err == nil { - host, port = hp, p - if _, err := net.LookupPort("tcp", port); err != nil || port == "" { - return "" - } - } - host = strings.Trim(host, "[]") - if net.ParseIP(host) == nil { - for _, label := range strings.Split(host, ".") { - if label == "" || strings.Trim(label, "abcdefghijklmnopqrstuvwxyz0123456789-") != "" { - return "" - } - } - } - return h -} - -// rememberAddress records where the account reached the admin from, when -// that changed. A sign-in is the one moment the address is known to be a -// person's, so this is the only write the sign-in path does. -func (s *Server) rememberAddress(r *http.Request) { - a := browserAddress(r) - set := s.mgr.State().Settings - if a == "" || (set.AdminAddress == a && (!isIPAddress(a) || set.AdminIPAddress == a)) { - return - } - s.mgr.Apply(func(st *state.State) error { noteAddress(&st.Settings, a); return nil }) -} - -// noteAddress records a browser address; an IP one is also the way back in. -func noteAddress(set *state.Settings, a string) { - set.AdminAddress = a - if isIPAddress(a) { - set.AdminIPAddress = a - } -} - -func isIPAddress(hostport string) bool { - h := hostport - if hp, _, err := net.SplitHostPort(hostport); err == nil { - h = hp - } - return net.ParseIP(strings.Trim(h, "[]")) != nil -} - -// FallbackURL is an address the admin opens at whatever happens to names -// and certificates: the last IP address someone signed in from, else the -// address the proxy's own name leads to now. "" when neither is known. -func FallbackURL(ctx context.Context, set state.Settings) string { - if set.AdminIPAddress != "" { - return "https://" + set.AdminIPAddress - } - if set.Domain == "" { - return "" - } - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - addrs, err := net.DefaultResolver.LookupHost(ctx, caddy.ProxyName(set)) - if err != nil { - return "" - } - for _, a := range addrs { - if ip := net.ParseIP(a); ip != nil && ip.To4() != nil { - return "https://" + net.JoinHostPort(a, caddy.AdminPort(set)) - } - } - return "" -} - -// AdminURL is where a person opens the admin: RP_ADMIN_URL when the -// operator set it, else the address the account last used, else "" (the -// caller words the unknown case). -func AdminURL(st *state.State) string { - if u := strings.TrimRight(strings.TrimSpace(os.Getenv("RP_ADMIN_URL")), "/"); u != "" { - return u - } - if st.Settings.AdminAddress != "" { - return "https://" + st.Settings.AdminAddress - } - return "" -} diff --git a/admin/internal/server/address_fallback_test.go b/admin/internal/server/address_fallback_test.go deleted file mode 100644 index 241d1d9..0000000 --- a/admin/internal/server/address_fallback_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package server - -import ( - "context" - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// A sign-in by name keeps the last IP address as the way back in. -func TestFallbackKeepsLastIPAddress(t *testing.T) { - var set state.Settings - noteAddress(&set, "192.168.86.10:8443") - noteAddress(&set, "proxy.home.example.com") - if set.AdminAddress != "proxy.home.example.com" || set.AdminIPAddress != "192.168.86.10:8443" { - t.Fatalf("%+v", set) - } - if got := FallbackURL(context.Background(), set); got != "https://192.168.86.10:8443" { - t.Fatalf("got %q", got) - } - if got := FallbackURL(context.Background(), state.Settings{}); got != "" { - t.Fatalf("nothing known: got %q", got) - } -} diff --git a/admin/internal/server/address_test.go b/admin/internal/server/address_test.go deleted file mode 100644 index 057edaa..0000000 --- a/admin/internal/server/address_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// The admin learns where it is reached from the account's setup and -// sign-ins, so the commands can print a link that works. -func TestAdminAddressFollowsTheAccount(t *testing.T) { - s, c, _ := setUp(t) // setUp signs in at proxy.test - if _, out := c.do("GET", "/api/v1/status", ""); out["adminUrl"] != "https://proxy.test" { - t.Fatalf("after setup: %v", out["adminUrl"]) - } - // The public view says nothing about it. - if _, out := (&client{t: t, s: s}).do("GET", "/api/v1/status", ""); out["adminUrl"] != nil { - t.Fatalf("public status leaks the address: %v", out) - } - login := func(host string) int { - req := httptest.NewRequest("POST", "https://proxy.test/api/v1/login", strings.NewReader(`{"password":"a long enough password"}`)) - req.Host = host - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Forwarded-For", "192.168.1.50") - req.Header.Set("Origin", "https://"+host) - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - return rr.Code - } - if code := login("192.168.1.5:8444"); code != 200 { - t.Fatalf("login: %d", code) - } - if got := s.mgr.State().Settings.AdminAddress; got != "192.168.1.5:8444" { - t.Fatalf("after login: %q", got) - } - // A header no browser sends is not kept. - for _, bad := range []string{"evil host", "a;b", "proxy.test:notaport", strings.Repeat("a", 260)} { - login(bad) - if got := s.mgr.State().Settings.AdminAddress; got != "192.168.1.5:8444" { - t.Fatalf("%q was kept: %q", bad, got) - } - } - // reset-password gets the same address to print. - req := httptest.NewRequest("POST", "http://admin/internal/reset", nil) - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - var res map[string]any - json.Unmarshal(rr.Body.Bytes(), &res) - if rr.Code != 200 || res["adminUrl"] != "https://192.168.1.5:8444" { - t.Fatalf("reset: %d %s", rr.Code, rr.Body.String()) - } - // The operator's RP_ADMIN_URL wins over anything learned. - t.Setenv("RP_ADMIN_URL", "https://proxy.home.example:8443/") - if got := AdminURL(s.mgr.State()); got != "https://proxy.home.example:8443" { - t.Fatalf("RP_ADMIN_URL: %q", got) - } -} - -func TestBrowserAddress(t *testing.T) { - cases := map[string]string{ - "proxy.test": "proxy.test", - "Proxy.Test:8443": "proxy.test:8443", - "192.168.1.5:8444": "192.168.1.5:8444", - "[fd00::1]:8443": "[fd00::1]:8443", - "admin": "admin", - "evil host": "", - "a_b.test": "", - "proxy.test:99999": "", - "proxy.test:": "", - "": "", - "-leading.test": "-leading.test", // a label the resolver would refuse, but harmless in print - } - for host, want := range cases { - req := httptest.NewRequest("GET", "https://x/", nil) - req.Host = host - req.Header.Set("X-Forwarded-For", "1.2.3.4") - if got := browserAddress(req); got != want { - t.Errorf("%q: got %q, want %q", host, got, want) - } - } - // Straight over the socket there is no browser. - req := httptest.NewRequest("GET", "http://admin/", nil) - req.Host = "admin" - if got := browserAddress(req); got != "" { - t.Errorf("socket request kept: %q", got) - } - _ = http.StatusOK -} diff --git a/admin/internal/server/api_test.go b/admin/internal/server/api_test.go deleted file mode 100644 index ee733c6..0000000 --- a/admin/internal/server/api_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" -) - -// setUp runs first-run setup and returns a signed-in client and a key. -func setUp(t *testing.T) (*Server, *client, string) { - s, tok := newTestServer(t) - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - if rr, _ := c.do("POST", "/api/v1/setup/password", `{"password":"a long enough password"}`); rr.Code != 200 { - t.Fatalf("set password: %d", rr.Code) - } - if rr, _ := c.do("PUT", "/api/v1/settings", `{"domain":"apps.test","trustedNetworks":[]}`); rr.Code != 200 { - t.Fatalf("domain: %d", rr.Code) - } - _, out := c.do("POST", "/api/v1/tokens", `{"label":"script"}`) - key, _ := out["token"].(string) - if key == "" { - t.Fatalf("token: %v", out) - } - return s, c, key -} - -// A connected app's key manages hosts only; the proxy's own settings, -// certificates, backup, password and keys need the account. -func TestKeyScope(t *testing.T) { - s, _, key := setUp(t) - api := &client{t: t, s: s, bearer: key} - allowed := []struct{ method, path string }{ - {"GET", "/api/v1/status"}, {"GET", "/api/v1/hosts"}, {"GET", "/api/v1/hosts/health"}, - {"GET", "/api/v1/hosts/uptime"}, {"GET", "/api/v1/probe?address=127.0.0.1&port=1"}, - } - for _, a := range allowed { - if rr, out := api.do(a.method, a.path, ""); rr.Code != 200 { - t.Errorf("%s %s with a key: %d %v", a.method, a.path, rr.Code, out) - } - } - denied := []struct{ method, path, body string }{ - {"GET", "/api/v1/settings", ""}, {"PUT", "/api/v1/settings", `{"domain":"x.test"}`}, - {"GET", "/api/v1/certificates", ""}, {"GET", "/api/v1/tokens", ""}, - {"POST", "/api/v1/tokens", `{"label":"more"}`}, {"PUT", "/api/v1/password", `{"current":"a","password":"b"}`}, - {"POST", "/api/v1/backup", `{"passphrase":"12345678"}`}, {"GET", "/api/v1/dns-check", ""}, - {"GET", "/api/v1/sources/fjord", ""}, {"GET", "/api/v1/alerts", ""}, - {"PUT", "/api/v1/alerts", `{"kind":"ntfy","url":"https://ntfy.example/t"}`}, {"POST", "/api/v1/alerts/test", `{}`}, {"PUT", "/api/v1/uptime/enabled", `{"enabled":false}`}, - } - for _, d := range denied { - if rr, _ := api.do(d.method, d.path, d.body); rr.Code != 403 { - t.Errorf("%s %s with a key: %d, want 403", d.method, d.path, rr.Code) - } - } - if _, out := api.do("GET", "/api/v1/status", ""); out["product"] != "reverse-proxy" || out["apiVersion"] != float64(1) || out["domain"] != "apps.test" { - t.Fatalf("status: %v", out) - } -} - -// A request straight over the unix socket (no X-Forwarded-For: it did not -// come through Caddy) is the command line and acts as the account. -func TestLocalSocket(t *testing.T) { - s, _, _ := setUp(t) - do := func(method, path, body string) (*httptest.ResponseRecorder, map[string]any) { - req := httptest.NewRequest(method, "http://admin"+path, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - out := map[string]any{} - json.Unmarshal(rr.Body.Bytes(), &out) - return rr, out - } - if rr, out := do("POST", "/api/v1/hosts", `{"name":"cli","target":{"address":"1.2.3.4","port":80},"access":"home"}`); rr.Code != 201 { - t.Fatalf("local add: %d %v", rr.Code, out) - } - if rr, out := do("GET", "/api/v1/settings", ""); rr.Code != 200 || out["domain"] != "apps.test" { - t.Fatalf("local settings: %d %v", rr.Code, out) - } - // Removed hosts are listed only when asked for, so a restore can find them. - _, made := do("GET", "/api/v1/hosts", "") - id := made["hosts"].([]any)[0].(map[string]any)["id"].(string) - do("DELETE", "/api/v1/hosts/"+id, "") - if _, out := do("GET", "/api/v1/hosts", ""); len(out["hosts"].([]any)) != 0 { - t.Fatalf("removed host still listed: %v", out) - } - _, out := do("GET", "/api/v1/hosts?include=removed", "") - if l := out["hosts"].([]any); len(l) != 1 || l[0].(map[string]any)["deletedAt"] == nil { - t.Fatalf("removed host not listed with include=removed: %v", out) - } - // Through Caddy the same request without a session or key is refused. - c := &client{t: t, s: s} - if rr, _ := c.do("GET", "/api/v1/settings", ""); rr.Code != 401 { - t.Fatalf("network without auth: %d", rr.Code) - } -} - -// Every path and method the OpenAPI document lists is routed, and the -// document itself is served. -func TestOpenAPIMatchesRoutes(t *testing.T) { - s, c, key := setUp(t) - rr, _ := c.do("GET", "/api/v1/openapi.json", "") - if rr.Code != 200 { - t.Fatalf("openapi.json: %d", rr.Code) - } - var doc struct { - Paths map[string]map[string]any `json:"paths"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &doc); err != nil { - t.Fatal(err) - } - _, made := c.do("POST", "/api/v1/hosts", `{"name":"doc","target":{"address":"1.2.3.4","port":80},"access":"home"}`) - id, _ := made["id"].(string) - api := &client{t: t, s: s, bearer: key} - // Map order is random: the delete of the host made above has to come - // after its get and put, or those answer 404 for the right reason. - type op struct{ path, method string } - var ops []op - for path, methods := range doc.Paths { - for method := range methods { - if method != "parameters" { - ops = append(ops, op{path, method}) - } - } - } - sort.Slice(ops, func(i, j int) bool { - if (ops[i].method == "delete") != (ops[j].method == "delete") { - return ops[j].method == "delete" - } - return ops[i].path+ops[i].method < ops[j].path+ops[j].method - }) - for _, o := range ops { - path, method := o.path, o.method - { - p := strings.ReplaceAll(path, "{id}", id) - if strings.Contains(p, "probe") { - p += "?address=127.0.0.1&port=1" - } - body := "" - if method == "post" || method == "put" { - body = `{"name":"doc","target":{"address":"1.2.3.4","port":80},"access":"home"}` - } - rr, _ := api.do(strings.ToUpper(method), p, body) - if rr.Code == http.StatusNotFound && !strings.Contains(path, "restore") && !strings.Contains(path, "root.crt") && !strings.HasSuffix(path, "/icon") || rr.Code == http.StatusMethodNotAllowed { - t.Errorf("%s %s is in openapi.json but answers %d", method, path, rr.Code) - } - } - } -} diff --git a/admin/internal/server/auth.go b/admin/internal/server/auth.go deleted file mode 100644 index defb035..0000000 --- a/admin/internal/server/auth.go +++ /dev/null @@ -1,580 +0,0 @@ -package server - -import ( - "fmt" - "log" - "net/http" - "net/url" - "slices" - "strconv" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/app" - "github.com/daemonless/reverse-proxy/admin/internal/auth" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -const ( - sessionCookie = "rp_session" - setupCookie = "rp_setup" -) - -// principal is who is calling: an admin session, a connected app, or nobody. -type principal int - -const ( - nobody principal = iota - adminSession - apiToken - // local is a request straight over the admin's unix socket (the - // reverse-proxy command inside the container): Caddy adds - // X-Forwarded-For to everything it proxies, so a request without it - // never came through the network. It acts as the account. - local -) - -// who identifies the caller. Bearer tokens win over cookies so a connected -// app in a browser context is never treated as a cookie session. -func (s *Server) who(r *http.Request) principal { - if r.Header.Get("X-Forwarded-For") == "" { - return local - } - if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { - if id, ok := s.tokenValid(strings.TrimPrefix(h, "Bearer ")); ok { - s.touchToken(id) - return apiToken - } - return nobody - } - if c, err := r.Cookie(sessionCookie); err == nil { - if k, ok := s.sessions.Lookup(c.Value); ok && k == auth.KindAdmin { - return adminSession - } - } - return nobody -} - -// tokenValid is the id of the key tok is, if any. -func (s *Server) tokenValid(tok string) (string, bool) { - // rp__, or a paired key sent whole. - parts := strings.SplitN(tok, "_", 3) - if len(parts) == 3 && parts[0] == "rp" { - want := auth.HashSecret(parts[2]) - st := s.mgr.State() - for _, t := range st.Tokens { - // A key from RP_PAIRING_KEY is only good sent whole. - if t.ID == parts[1] && !t.Disabled && auth.EqualTokens(t.Hash, want) && !slices.Contains(st.PairedKeys, t.Hash) { - return t.ID, true - } - } - return "", false - } - want := auth.HashSecret(tok) - for _, t := range s.mgr.State().Tokens { - if t.Paired && !t.Disabled && auth.EqualTokens(t.Hash, want) { - return t.ID, true - } - } - return "", false -} - -// touchInterval is how often a key's last use is written: an app may call -// every minute, and the state file need not follow each call. -const touchInterval = 5 * time.Minute - -// touchToken records that key id was used now, at most once per -// touchInterval; the Settings page shows it as connected and when. -func (s *Server) touchToken(id string) { - now := time.Now() - s.mu.Lock() - if s.touched == nil { - s.touched = map[string]time.Time{} - } - if last, ok := s.touched[id]; ok && now.Sub(last) < touchInterval { - s.mu.Unlock() - return - } - s.touched[id] = now - s.mu.Unlock() - s.mgr.Note(func(st *state.State) { - for i := range st.Tokens { - if st.Tokens[i].ID == id { - t := now - st.Tokens[i].LastUsedAt = &t - } - } - }) -} - -// MinPairingKey is the shortest RP_PAIRING_KEY accepted: it is a bearer -// token, so it must be a random secret, not a word. -const MinPairingKey = 32 - -// RegisterPairingKey makes key a connected app called label, once: a key -// registered before (and maybe removed since) is never added again. -func RegisterPairingKey(m *app.Manager, key, label, url, clientID string) (bool, error) { - if len(key) < MinPairingKey { - return false, fmt.Errorf("the pairing key is %d characters; it needs at least %d", len(key), MinPairingKey) - } - url = cleanPairingURL(url) - clientID = cleanClientID(clientID) - h := auth.HashSecret(key) - if slices.Contains(m.State().PairedKeys, h) { - // Known already; the app's address or name may be new (fjord moved). - return false, m.Apply(func(st *state.State) error { - for i := range st.Tokens { - if st.Tokens[i].Hash != h { - continue - } - if url != "" { - st.Tokens[i].URL = url - } - if clientID != "" { - st.Tokens[i].ClientID = clientID - } - if label != "" { - st.Tokens[i].Label = label - } - } - return nil - }) - } - t := state.ApiToken{ID: newID(), Label: label, Hash: h, CreatedAt: time.Now().UTC(), Paired: true, URL: url, ClientID: clientID} - err := m.Apply(func(st *state.State) error { - st.Tokens = append(st.Tokens, t) - st.PairedKeys = append(st.PairedKeys, h) - return nil - }) - return err == nil, err -} - -// cleanClientID keeps a short id of letters, digits and dashes. -func cleanClientID(id string) string { - id = strings.ToLower(strings.TrimSpace(id)) - if len(id) > 64 { - return "" - } - for _, c := range id { - if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { - return "" - } - } - return id -} - -// cleanPairingURL keeps an http(s) address and nothing else: it is put in -// a link on the page. -func cleanPairingURL(u string) string { - u = strings.TrimSpace(u) - if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { - return "" - } - if strings.ContainsAny(u, " \"'<>\n") { - return "" - } - return strings.TrimRight(u, "/") -} - -// requireAuth guards the hosts API: the account, the command line, or a -// connected app's key. Cookie sessions must also pass the same-origin -// check on anything but reads. -func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - switch s.who(r) { - case nobody: - writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "sign in first"}) - return - case adminSession: - if r.Method != http.MethodGet && r.Method != http.MethodHead && !sameOrigin(r) { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "request did not come from this page"}) - return - } - } - next(w, r) - } -} - -// requireAdmin guards the proxy's own settings, certificates, backup, -// password and keys: a connected app manages hosts, not the proxy. -func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { - return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { - if s.who(r) == apiToken { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "an app's key manages hosts only; this needs the account"}) - return - } - next(w, r) - }) -} - -// sameOrigin accepts a browser request only when the browser says it came -// from our own page. Caddy passes the Host header through unchanged. -func sameOrigin(r *http.Request) bool { - switch r.Header.Get("Sec-Fetch-Site") { - case "same-origin", "none": - return true - } - if o := r.Header.Get("Origin"); o != "" { - if u, err := url.Parse(o); err == nil && strings.EqualFold(u.Host, r.Host) { - return true - } - } - return false -} - -// setCookie uses the plan's cookie rules: HttpOnly, Secure, SameSite=Strict. -func setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { - http.SetCookie(w, &http.Cookie{ - Name: name, Value: value, Path: "/", MaxAge: int(ttl.Seconds()), - HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, - }) -} - -func clearCookie(w http.ResponseWriter, name string) { - http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode}) -} - -func (s *Server) setupComplete() bool { - return s.mgr.State().Settings.AdminPasswordHash != "" -} - -// clientAddr is the visitor's address as Caddy reports it; "" when the -// request came straight over the unix socket. -func clientAddr(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - return strings.TrimSpace(strings.Split(xff, ",")[0]) - } - return r.RemoteAddr -} - -// tooMany answers a locked-out attempt and reports whether it did. -func (s *Server) tooMany(w http.ResponseWriter, r *http.Request) bool { - wait := s.limiter.Wait(clientAddr(r)) - if wait <= 0 { - return false - } - secs := int(wait.Round(time.Second).Seconds()) - if secs < 1 { - secs = 1 - } - w.Header().Set("Retry-After", strconv.Itoa(secs)) - writeJSON(w, http.StatusTooManyRequests, map[string]any{ - "error": "Too many attempts. Try again in " + strconv.Itoa(secs) + " seconds.", - "retryAfter": secs, - }) - return true -} - -// session tells the UI where it stands without revealing anything else. -func (s *Server) session(w http.ResponseWriter, r *http.Request) { - kind := "none" - switch s.who(r) { - case adminSession: - kind = "admin" - case apiToken: - kind = "token" - } - if kind == "none" { - if c, err := r.Cookie(setupCookie); err == nil { - if k, ok := s.sessions.Lookup(c.Value); ok && k == auth.KindSetup { - kind = "setup" - } - } - } - writeJSON(w, http.StatusOK, map[string]any{"authenticated": kind == "admin" || kind == "token", "kind": kind, "setupRequired": !s.setupComplete()}) -} - -// setupToken is step one of first run: the token from the container log. -func (s *Server) setupToken(w http.ResponseWriter, r *http.Request) { - if s.setupComplete() { - writeJSON(w, http.StatusConflict, map[string]any{"error": "setup is already done; sign in instead"}) - return - } - var in struct { - Token string `json:"token"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - if s.tooMany(w, r) { - return - } - s.mu.Lock() - want := s.setupTok - s.mu.Unlock() - if want == "" || !auth.EqualTokens(auth.NormalizeSetupToken(in.Token), auth.NormalizeSetupToken(want)) { - s.limiter.Fail(clientAddr(r)) - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "that's not the token in the log; check for mixed-up letters", "field": "token"}) - return - } - s.limiter.Succeed(clientAddr(r)) - setCookie(w, setupCookie, s.sessions.Create(auth.KindSetup), auth.SetupSessionTTL) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// setupPassword is step two: choose the password, then you are signed in. -func (s *Server) setupPassword(w http.ResponseWriter, r *http.Request) { - if s.setupComplete() { - writeJSON(w, http.StatusConflict, map[string]any{"error": "setup is already done; sign in instead"}) - return - } - c, err := r.Cookie(setupCookie) - if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "enter the setup token first"}) - return - } - if k, ok := s.sessions.Lookup(c.Value); !ok || k != auth.KindSetup { - writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "the setup token has expired; enter it again"}) - return - } - var in struct { - Password string `json:"password"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - if err := auth.CheckPasswordStrength(in.Password); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "field": "password"}) - return - } - hash, err := auth.HashPassword(in.Password) - if err != nil { - writeError(w, err) - return - } - addr := browserAddress(r) - if err := s.mgr.Apply(func(st *state.State) error { - st.Settings.AdminPasswordHash = hash - if addr != "" { - noteAddress(&st.Settings, addr) - } - return nil - }); err != nil { - writeError(w, err) - return - } - s.sessions.Delete(c.Value) - s.mu.Lock() - s.setupTok = "" - s.mu.Unlock() - clearCookie(w, setupCookie) - setCookie(w, sessionCookie, s.sessions.Create(auth.KindAdmin), auth.SessionAbsolute) - log.Printf("[admin] setup complete: password set") - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) login(w http.ResponseWriter, r *http.Request) { - if !s.setupComplete() { - writeJSON(w, http.StatusConflict, map[string]any{"error": "finish setup first", "setupRequired": true}) - return - } - var in struct { - Password string `json:"password"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - if s.tooMany(w, r) { - return - } - if !auth.VerifyPassword(s.mgr.State().Settings.AdminPasswordHash, in.Password) { - s.limiter.Fail(clientAddr(r)) - writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "that password isn't right", "field": "password"}) - return - } - s.limiter.Succeed(clientAddr(r)) - s.rememberAddress(r) - setCookie(w, sessionCookie, s.sessions.Create(auth.KindAdmin), auth.SessionAbsolute) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) logout(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(sessionCookie); err == nil { - s.sessions.Delete(c.Value) - } - clearCookie(w, sessionCookie) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// changePassword needs the current password and signs every session out. -func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { - var in struct { - Current string `json:"current"` - New string `json:"new"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - if !auth.VerifyPassword(s.mgr.State().Settings.AdminPasswordHash, in.Current) { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "the current password isn't right", "field": "current"}) - return - } - if err := auth.CheckPasswordStrength(in.New); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "field": "new"}) - return - } - hash, err := auth.HashPassword(in.New) - if err != nil { - writeError(w, err) - return - } - if err := s.mgr.Apply(func(st *state.State) error { st.Settings.AdminPasswordHash = hash; return nil }); err != nil { - writeError(w, err) - return - } - s.sessions.DeleteAll() - setCookie(w, sessionCookie, s.sessions.Create(auth.KindAdmin), auth.SessionAbsolute) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// reset is reachable only straight over the admin's unix socket (the -// reset-password command inside the container). Caddy adds X-Forwarded-For -// to everything it proxies, and its route for /internal/ answers 404 -// before reaching us; a request without the header came from the socket. -func (s *Server) reset(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Forwarded-For") != "" { - http.NotFound(w, r) - return - } - if err := s.mgr.Apply(func(st *state.State) error { st.Settings.AdminPasswordHash = ""; return nil }); err != nil { - writeError(w, err) - return - } - s.sessions.DeleteAll() - tok := auth.NewSetupToken() - s.mu.Lock() - s.setupTok = tok - s.mu.Unlock() - log.Printf("[admin] password reset; new setup token: %s", tok) - writeJSON(w, http.StatusOK, map[string]any{"setupToken": tok, "adminUrl": AdminURL(s.mgr.State())}) -} - -// --- connected apps (API tokens) --- - -func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) { - st := s.mgr.State() - counts := namesFrom(st) - out := []map[string]any{} - for _, t := range st.Tokens { - out = append(out, map[string]any{"id": t.ID, "label": t.Label, "createdAt": t.CreatedAt, "lastUsedAt": t.LastUsedAt, "paired": t.Paired, "disabled": t.Disabled, "url": t.URL, "clientId": t.ClientID, "names": counts[t.ID]}) - } - writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) -} - -// namesFrom counts the live hosts each connected app made, by token id: by -// the app's id in the ref (::), which outlives a relink to -// a new key; else by the key that made it; else, for a fjord ref from -// before ids (fjord::), the installer's. -func namesFrom(st *state.State) map[string]int { - out := map[string]int{} - byClient := map[string]string{} // app id -> token id - live := map[string]bool{} - installer := "" - for _, t := range st.Tokens { - if t.ClientID != "" { - byClient[t.ClientID] = t.ID - } - if t.Paired { - installer = t.ID - } - live[t.ID] = true - } - for _, h := range st.Live() { - owner := "" - if parts := strings.SplitN(h.Ref, ":", 3); len(parts) == 3 { - owner = byClient[parts[1]] - } - if owner == "" && live[h.By] { - owner = h.By - } - if owner == "" && strings.HasPrefix(h.Ref, "fjord:") { - owner = installer - } - if owner != "" { - out[owner]++ - } - } - return out -} - -func (s *Server) createToken(w http.ResponseWriter, r *http.Request) { - var in struct { - Label string `json:"label"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - label := strings.TrimSpace(in.Label) - if label == "" || len(label) > 64 { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "give the app a short name", "field": "label"}) - return - } - id := newID() - secret := auth.NewSecret(32) - t := state.ApiToken{ID: id, Label: label, Hash: auth.HashSecret(secret), CreatedAt: time.Now().UTC()} - if err := s.mgr.Apply(func(st *state.State) error { st.Tokens = append(st.Tokens, t); return nil }); err != nil { - writeError(w, err) - return - } - // Shown once; only the hash is kept. - writeJSON(w, http.StatusCreated, map[string]any{"id": id, "label": label, "token": "rp_" + id + "_" + secret, "createdAt": t.CreatedAt}) -} - -// setTokenDisabled is PUT /api/v1/tokens/{id} {"disabled": bool}: -// disconnect a connected app and keep it, or reconnect it. -func (s *Server) setTokenDisabled(w http.ResponseWriter, r *http.Request) { - var in struct { - Disabled bool `json:"disabled"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - id := r.PathValue("id") - found := false - err := s.mgr.Apply(func(st *state.State) error { - for i := range st.Tokens { - if st.Tokens[i].ID == id { - st.Tokens[i].Disabled = in.Disabled - found = true - } - } - return nil - }) - if err != nil { - writeError(w, err) - return - } - if !found { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such app"}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"id": id, "disabled": in.Disabled}) -} - -func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - err := s.mgr.Apply(func(st *state.State) error { - for i, t := range st.Tokens { - if t.ID == id { - st.Tokens = append(st.Tokens[:i], st.Tokens[i+1:]...) - return nil - } - } - return errNotFound - }) - if err == errNotFound { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such token"}) - return - } - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} diff --git a/admin/internal/server/backup.go b/admin/internal/server/backup.go deleted file mode 100644 index 682743d..0000000 --- a/admin/internal/server/backup.go +++ /dev/null @@ -1,100 +0,0 @@ -package server - -import ( - "bytes" - "errors" - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/backup" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// createBackup streams an encrypted backup. The passphrase comes in the -// body so it never appears in a URL. -func (s *Server) createBackup(w http.ResponseWriter, r *http.Request) { - var in struct { - Passphrase string `json:"passphrase"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - var buf bytes.Buffer - if err := backup.Create(&buf, filepath.Dir(s.secrets), in.Passphrase); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "field": "passphrase"}) - return - } - domain := strings.ReplaceAll(s.mgr.State().Settings.Domain, ".", "-") - if domain == "" { - domain = "unset" - } - name := fmt.Sprintf("reverse-proxy-%s-%s.rpbk", domain, time.Now().UTC().Format("2006-01-02")) - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) - w.Header().Set("Cache-Control", "no-store") - w.Write(buf.Bytes()) -} - -// restoreBackup takes the file and passphrase as a multipart form. Before -// setup it is open (a fresh install has nothing to protect and no account -// to sign in with); afterwards it needs an admin session. -func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) { - if s.setupComplete() && s.who(r) != adminSession { - writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "sign in first"}) - return - } - if s.setupComplete() && !sameOrigin(r) { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "request did not come from this page"}) - return - } - if err := r.ParseMultipartForm(32 << 20); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "send the backup file and the passphrase"}) - return - } - f, _, err := r.FormFile("file") - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "choose the backup file", "field": "file"}) - return - } - defer f.Close() - files, err := backup.Open(f, r.FormValue("passphrase")) - switch { - case errors.Is(err, backup.ErrWrongPassphrase): - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "field": "passphrase"}) - return - case errors.Is(err, backup.ErrNotABackup): - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "field": "file"}) - return - case err != nil: - writeError(w, err) - return - } - if err := backup.Write(filepath.Dir(s.secrets), files); err != nil { - writeError(w, err) - return - } - iconMu.Lock() - err = s.mgr.Restore(func(st *state.State) bool { - listen, icons, tokens := s.fitListen(st), s.fitIcons(st), fitTokens(st) - return listen || icons || tokens - }) - if err == nil { - s.tidyIcons() - } - iconMu.Unlock() - if err != nil { - writeError(w, err) - return - } - // Everyone is signed out: the restored password is the one that counts. - s.sessions.DeleteAll() - s.mu.Lock() - s.setupTok = "" - s.mu.Unlock() - clearCookie(w, sessionCookie) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": s.mgr.State().Settings.Domain}) -} diff --git a/admin/internal/server/certificates.go b/admin/internal/server/certificates.go deleted file mode 100644 index 9f82eb6..0000000 --- a/admin/internal/server/certificates.go +++ /dev/null @@ -1,219 +0,0 @@ -package server - -import ( - "context" - "errors" - "log" - "net/http" - "os" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/app" - "github.com/daemonless/reverse-proxy/admin/internal/certs" - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// certStatus is how the wildcard certificate is doing right now. -func (s *Server) certStatus(ctx context.Context, st *state.State) certs.Status { - var obs certs.Observation - if s.Observe != nil && st.Settings.Domain != "" && st.Settings.Certificates != nil { - obs = s.Observe(ctx, "proxy."+st.Settings.Domain) - } - var ev *certs.Event - if s.IssuanceLog != "" && st.Settings.Domain != "" { - if e, ok := certs.LastEvent(s.IssuanceLog, "*."+st.Settings.Domain); ok { - ev = &e - } - } - if s.DNSRefused != nil { - if msg := s.DNSRefused(); msg != "" { - ev = &certs.Event{At: time.Now(), Error: msg} - } - } - s.mu.Lock() - bad := s.tokenBad - if ev != nil && ev.OK && ev.At.After(s.tokenAt) { - bad = nil // renewed since: the token works again - } - s.mu.Unlock() - status := certs.Compute(st.Settings, obs, ev, bad, st.Settings.Domain, time.Now()) - if status.State == "failed" && status.Detail == "" && s.IssuanceLog != "" { - status.Detail = certs.LastLine(s.IssuanceLog, "*."+st.Settings.Domain) - } - return status -} - -// WatchToken asks the DNS provider once a day whether the saved token -// still works, so a revoked one is said when it happens, not when the -// certificate is about to run out. The first check is a minute after -// start; status reads only the cached answer. A failed token is asked -// again every hour, so mending it at the provider clears the warning. -func (s *Server) WatchToken(ctx context.Context, every time.Duration) { - t := time.NewTimer(time.Minute) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - } - s.checkToken(ctx) - s.mu.Lock() - next := every - if s.tokenBad != nil { - next = min(every, time.Hour) - } - s.mu.Unlock() - t.Reset(next) - } -} - -func (s *Server) checkToken(ctx context.Context) { - st := s.mgr.State() - c := st.Settings.Certificates - if s.CheckProvider == nil || c == nil || c.Stage != state.StageLive || st.Settings.Domain == "" { - return - } - cfg, err := dns.Load(s.secrets) - if err != nil { - return - } - var bad *dns.Problem - // Anything but a Problem (no answer, an odd one) is not news. - if !errors.As(s.CheckProvider(ctx, cfg, st.Settings.Domain), &bad) { - bad = nil - } - if bad != nil { - log.Printf("[admin] certificates: the %s token stopped working (%s): %s", cfg.Provider, bad.Kind, bad.Msg) - } - s.mu.Lock() - s.tokenBad, s.tokenAt = bad, time.Now() - s.mu.Unlock() -} - -// forgetTokenCheck drops the last answer when the token changes. -func (s *Server) forgetTokenCheck() { - s.mu.Lock() - s.tokenBad = nil - s.mu.Unlock() -} - -func (s *Server) certView(ctx context.Context, st *state.State) map[string]any { - out := map[string]any{ - "domain": st.Settings.Domain, - "mode": st.Settings.Mode(), - "providers": dns.Providers, - "status": s.certStatus(ctx, st), - } - if c := st.Settings.Certificates; c != nil { - out["provider"] = c.Provider - } - return out -} - -func (s *Server) getCertificates(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.certView(r.Context(), s.mgr.State())) -} - -// detectProvider guesses where the domain's DNS lives from its nameservers. -func (s *Server) detectProvider(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - if st.Settings.Mode() != state.ModePublic { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "set a public address first"}) - return - } - writeJSON(w, http.StatusOK, dns.Detect(r.Context(), st.Settings.Domain)) -} - -// putCertificates saves the provider's credentials and starts the trial: -// Caddy asks the staging CA for the wildcard; once that works the watcher -// switches to the real CA. -func (s *Server) putCertificates(w http.ResponseWriter, r *http.Request) { - var in dns.Config - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - if s.mgr.State().Settings.Mode() != state.ModePublic { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "automatic certificates need a public address"}) - return - } - if err := dns.Validate(&in); err != nil { - writeError(w, err) - return - } - if s.CheckProvider != nil { - if err := s.CheckProvider(r.Context(), &in, s.mgr.State().Settings.Domain); err != nil { - writeError(w, err) - return - } - } - // The render reads the secrets file, so it is written first and put - // back (or removed) if Caddy refuses the result. - old, err := dns.Load(s.secrets) - if err != nil && !errors.Is(err, os.ErrNotExist) { - writeError(w, err) - return - } - if err := dns.Save(s.secrets, &in); err != nil { - writeError(w, err) - return - } - s.forgetTokenCheck() - err = s.mgr.Apply(func(st *state.State) error { - st.Settings.Certificates = &state.Certificates{Provider: in.Provider, Stage: state.StageTrial, StartedAt: time.Now()} - return nil - }) - if err != nil { - if old != nil { - dns.Save(s.secrets, old) - } else { - dns.Remove(s.secrets) - } - // Some modules check the credential's shape when Caddy loads the - // config (Cloudflare does); that is the same "token rejected" to - // the person, said the same way. - var ce *app.CaddyError - if errors.As(err, &ce) { - label := in.Provider - if p, ok := dns.Find(in.Provider); ok { - label = p.Label - } - msg, detail := certs.Explain(ce.Err.Error(), s.mgr.State().Settings.Domain, label) - writeJSON(w, http.StatusBadRequest, map[string]any{"error": msg, "detail": detail}) - return - } - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, s.certView(r.Context(), s.mgr.State())) -} - -// deleteCertificates goes back to the internal CA and forgets the credentials. -func (s *Server) deleteCertificates(w http.ResponseWriter, r *http.Request) { - s.forgetTokenCheck() - err := s.mgr.Apply(func(st *state.State) error { - st.Settings.Certificates = nil - return nil - }) - if err != nil { - writeError(w, err) - return - } - if err := dns.Remove(s.secrets); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, s.certView(r.Context(), s.mgr.State())) -} - -// CheckProvider is the usual Server.CheckProvider: the domain's zone, then -// the provider's own check. -func CheckProvider(ctx context.Context, c *dns.Config, domain string) error { - zone := dns.Detect(ctx, domain).Zone - if zone == "" { - zone = domain - } - return dns.Check(ctx, c, zone) -} diff --git a/admin/internal/server/hosts.go b/admin/internal/server/hosts.go deleted file mode 100644 index 88d3394..0000000 --- a/admin/internal/server/hosts.go +++ /dev/null @@ -1,379 +0,0 @@ -package server - -import ( - "crypto/rand" - "encoding/hex" - "maps" - "net/http" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/probe" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// hostInput is what a client may set on a host. -type hostInput struct { - Name string `json:"name"` - ExtraNames []string `json:"extraNames"` - Target state.Target `json:"target"` - Access state.Access `json:"access"` - TrustedNetworks []string `json:"trustedNetworks"` - Ref string `json:"ref"` - // OwnOrigin is kept as it was when absent: a client that predates it - // (an app that PUTs the host back to follow a port) must not switch it off. - OwnOrigin *bool `json:"ownOrigin,omitempty"` - // Watch is kept as it was when absent, for the same reason. - Watch *bool `json:"watch,omitempty"` -} - -// hostView is a host as the API presents it, with the names resolved. -type hostView struct { - state.Host - Names []string `json:"names"` - URL string `json:"url"` -} - -func view(s state.Settings, h state.Host) hostView { - names := h.Names(s) - return hostView{Host: h, Names: names, URL: "https://" + names[0]} -} - -func newID() string { - var b [8]byte - rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} - -func (in hostInput) apply(h *state.Host) { - h.Name = strings.ToLower(strings.TrimSpace(in.Name)) - h.ExtraNames = nil - for _, n := range in.ExtraNames { - if n = strings.ToLower(strings.TrimSpace(n)); n != "" { - h.ExtraNames = append(h.ExtraNames, n) - } - } - h.Target = in.Target - h.Target.Address = strings.TrimSpace(in.Target.Address) - h.Access = in.Access - if h.Access == "" { - h.Access = state.AccessHome - } - if r := strings.TrimSpace(in.Ref); r != "" && len(r) <= 200 { - h.Ref = r - } - h.TrustedNetworks = nil - for _, n := range in.TrustedNetworks { - if n = strings.TrimSpace(n); n != "" { - h.TrustedNetworks = append(h.TrustedNetworks, n) - } - } - if in.OwnOrigin != nil { - h.OwnOrigin = *in.OwnOrigin - } - if in.Watch != nil { - h.Unwatched = !*in.Watch - } -} - -// listHosts is the live hosts; ?include=removed adds the ones removed in -// the last 24 hours (deletedAt set), for a restore. -func (s *Server) listHosts(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - hosts := st.Live() - if r.URL.Query().Get("include") == "removed" { - hosts = st.Hosts - } - out := []hostView{} - for _, h := range hosts { - out = append(out, view(st.Settings, h)) - } - writeJSON(w, http.StatusOK, map[string]any{"hosts": out}) -} - -// hostsHealth probes every live host's target at once so the list can show -// a status dot per host without one request each. -func (s *Server) hostsHealth(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - out := map[string]probe.Result{} - var mu sync.Mutex - var wg sync.WaitGroup - for _, h := range st.Live() { - wg.Add(1) - go func(h state.Host) { - defer wg.Done() - res := probe.Probe(r.Context(), h.Target.Address, h.Target.Port) - mu.Lock() - out[h.ID] = res - mu.Unlock() - }(h) - } - wg.Wait() - writeJSON(w, http.StatusOK, map[string]any{"health": out}) -} - -func (s *Server) getHost(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - i := st.Find(r.PathValue("id")) - if i < 0 || st.Hosts[i].DeletedAt != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such host"}) - return - } - writeJSON(w, http.StatusOK, view(st.Settings, st.Hosts[i])) -} - -func (s *Server) createHost(w http.ResponseWriter, r *http.Request) { - var in hostInput - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - h := state.Host{ID: newID(), CreatedAt: time.Now().UTC()} - in.apply(&h) - h.By, _ = s.callerToken(r) - var existing *state.Host - err := s.mgr.Apply(func(st *state.State) error { - if st.Settings.Domain == "" { - return &state.FieldError{Field: "", Msg: "set the address your services live under first (settings)"} - } - // One host per ref: a connected app that retries, or lost track of - // what it made, gets the host it already has. - for _, o := range st.Hosts { - if h.Ref != "" && o.Ref == h.Ref && o.DeletedAt == nil { - existing = &o - return errRefTaken - } - } - if err := state.ValidateHost(h, st.Hosts); err != nil { - return err - } - // A removed host with this ref (or, by hand, this name) comes back - // as this one: Remove and Undo, or remove and add again, used to - // leave a removed record behind each time. - for i := range st.Hosts { - o := &st.Hosts[i] - if o.DeletedAt == nil || !(h.Ref != "" && o.Ref == h.Ref || h.Ref == "" && o.Ref == "" && o.Name == h.Name) { - continue - } - h.ID, h.CreatedAt = o.ID, o.CreatedAt - *o = h - return nil - } - st.Hosts = append(st.Hosts, h) - return nil - }) - if existing != nil { - writeJSON(w, http.StatusConflict, view(s.mgr.State().Settings, *existing)) - return - } - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusCreated, view(s.mgr.State().Settings, h)) -} - -func (s *Server) updateHost(w http.ResponseWriter, r *http.Request) { - var in hostInput - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - id := r.PathValue("id") - var out state.Host - err := s.mgr.Apply(func(st *state.State) error { - i := st.Find(id) - if i < 0 || st.Hosts[i].DeletedAt != nil { - return errNotFound - } - h := st.Hosts[i] - in.apply(&h) - if err := state.ValidateHost(h, st.Hosts); err != nil { - return err - } - st.Hosts[i] = h - out = h - return nil - }) - if err == errNotFound { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such host"}) - return - } - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, view(s.mgr.State().Settings, out)) -} - -// deleteHost is a soft delete: the host leaves Caddy at once and can be -// restored for state.UndoWindow. -func (s *Server) deleteHost(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - now := time.Now().UTC() - err := s.mgr.Apply(func(st *state.State) error { - i := st.Find(id) - if i < 0 || st.Hosts[i].DeletedAt != nil { - return errNotFound - } - st.Hosts[i].DeletedAt = &now - return nil - }) - if err == errNotFound { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such host"}) - return - } - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"id": id, "undoUntil": now.Add(state.UndoWindow)}) -} - -func (s *Server) restoreHost(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - var out state.Host - err := s.mgr.Apply(func(st *state.State) error { - i := st.Find(id) - if i < 0 || st.Hosts[i].DeletedAt == nil { - return errNotFound - } - h := st.Hosts[i] - h.DeletedAt = nil - if err := state.ValidateHost(h, st.Hosts); err != nil { - return err // its name was reused meanwhile - } - st.Hosts[i] = h - out = h - return nil - }) - if err == errNotFound { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "nothing to restore"}) - return - } - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, view(s.mgr.State().Settings, out)) -} - -type settingsInput struct { - Domain string `json:"domain"` - TrustedNetworks []string `json:"trustedNetworks"` - // ShortLinks: left out keeps what is set ("" turns them off). - ShortLinks *string `json:"shortLinks"` -} - -// resolvConf is read for the network's local domain; tests point it elsewhere. -var resolvConf = "/etc/resolv.conf" - -// localDomain is the network's own domain (lan, home), as the router hands -// it out: the first search domain that is not the container engine's. The -// short-link record goes under it ("" when unknown). -func localDomain() string { - raw, err := os.ReadFile(resolvConf) - if err != nil { - return "" - } - for _, line := range strings.Split(string(raw), "\n") { - f := strings.Fields(line) - if len(f) < 2 || (f[0] != "search" && f[0] != "domain") { - continue - } - for _, d := range f[1:] { - d = strings.ToLower(strings.TrimSuffix(d, ".")) - if engineDomain(d) || d == "localdomain" { - continue - } - return d - } - } - return "" -} - -// engineDomain is a search domain the container engine adds for its own -// names (podman's dns.podman, Kubernetes' cluster.local), not the -// network's: never an ending to suggest or a place for a record. -func engineDomain(d string) bool { - return d == "dns.podman" || strings.HasSuffix(d, ".podman") || d == "cluster.local" || strings.HasSuffix(d, ".cluster.local") -} - -func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - out := settingsView(st.Settings) - maps.Copy(out, s.listenView(r, st.Settings)) - out["fallbackUrl"] = FallbackURL(r.Context(), st.Settings) - writeJSON(w, http.StatusOK, out) -} - -func settingsView(s state.Settings) map[string]any { - nets := s.TrustedNetworks - if nets == nil { - nets = []string{} - } - return map[string]any{"domain": s.Domain, "mode": s.Mode(), "trustedNetworks": nets, "shortLinks": s.ShortName(), "localDomain": localDomain()} -} - -func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { - before := s.mgr.State().Settings - var in settingsInput - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - err := s.mgr.Apply(func(st *state.State) error { - if err := state.ValidateDomain(in.Domain); err != nil { - return err - } - if err := state.ValidateNetworks("trustedNetworks", in.TrustedNetworks); err != nil { - return err - } - if in.ShortLinks != nil { - name := strings.ToLower(strings.TrimSpace(*in.ShortLinks)) - if err := state.ValidateShortLinks(name); err != nil { - return err - } - st.Settings.ShortLinks = &name - } - st.Settings.Domain = state.NormalizeDomain(in.Domain) - st.Settings.TrustedNetworks = nil - for _, n := range in.TrustedNetworks { - if n = strings.TrimSpace(n); n != "" { - st.Settings.TrustedNetworks = append(st.Settings.TrustedNetworks, n) - } - } - return nil - }) - if err != nil { - writeError(w, err) - return - } - // After a new ending the old name no longer opens; this is where the - // browser goes on from. - out := settingsView(s.mgr.State().Settings) - maps.Copy(out, s.listenView(r, s.mgr.State().Settings)) - out["fallbackUrl"] = FallbackURL(r.Context(), before) - writeJSON(w, http.StatusOK, out) -} - -func (s *Server) probe(w http.ResponseWriter, r *http.Request) { - address := strings.TrimSpace(r.URL.Query().Get("address")) - port, _ := strconv.Atoi(r.URL.Query().Get("port")) - if err := state.ValidateTarget(state.Target{Address: address, Port: port}); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, probe.Probe(r.Context(), address, port)) -} - -type sentinel string - -func (e sentinel) Error() string { return string(e) } - -const errRefTaken sentinel = "a host with this ref exists" - -const errNotFound = sentinel("not found") diff --git a/admin/internal/server/hosts_ownorigin_test.go b/admin/internal/server/hosts_ownorigin_test.go deleted file mode 100644 index 720db83..0000000 --- a/admin/internal/server/hosts_ownorigin_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package server - -import ( - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// ownOrigin is set on create, changed only when a PUT names it: fjord's -// port follow PUTs a host without it and must not switch UniFi's off. -func TestOwnOriginKeptWhenAbsent(t *testing.T) { - s, _ := newTestServer(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - if _, err := RegisterPairingKey(s.mgr, key, "fjord", "", ""); err != nil { - t.Fatal(err) - } - if err := s.mgr.Apply(func(st *state.State) error { st.Settings.Domain = "apps.lan"; return nil }); err != nil { - t.Fatal(err) - } - c := &client{t: t, s: s, bearer: key} - rr, h := c.do("POST", "/api/v1/hosts", `{"name":"unifi","target":{"address":"192.168.1.8","port":8443,"https":true},"ownOrigin":true}`) - if rr.Code != 201 || h["ownOrigin"] != true { - t.Fatalf("create: %d %v", rr.Code, h["ownOrigin"]) - } - id, _ := h["id"].(string) - if rr, h = c.do("PUT", "/api/v1/hosts/"+id, `{"name":"unifi","extraNames":[],"target":{"address":"192.168.1.8","port":8444,"https":true},"access":"home","trustedNetworks":[],"ref":""}`); rr.Code != 200 || h["ownOrigin"] != true { - t.Fatalf("PUT without it: %d %v", rr.Code, h["ownOrigin"]) - } - if rr, h = c.do("PUT", "/api/v1/hosts/"+id, `{"name":"unifi","target":{"address":"192.168.1.8","port":8444,"https":true},"access":"home","ownOrigin":false}`); rr.Code != 200 || h["ownOrigin"] != nil { - t.Fatalf("PUT turning it off: %d %v", rr.Code, h["ownOrigin"]) - } - if rr, h = c.do("POST", "/api/v1/hosts", `{"name":"paperless","target":{"address":"192.168.1.9","port":8000}}`); rr.Code != 201 || h["ownOrigin"] != nil { - t.Fatalf("a name made without it is off: %d %v", rr.Code, h["ownOrigin"]) - } -} diff --git a/admin/internal/server/icons.go b/admin/internal/server/icons.go deleted file mode 100644 index 7703c32..0000000 --- a/admin/internal/server/icons.go +++ /dev/null @@ -1,172 +0,0 @@ -package server - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// An app's icon, sent by the app that made the name (fjord sends the one -// it shows for the app), shown beside the name. Files beside state.json, -// not in it: every save rewrites state.json. - -const maxIcon = 256 << 10 - -// iconMu makes saves one at a time: a tidy between another save's file and -// its record took that file (fjord sends every app's icon at once on start). -var iconMu sync.Mutex - -var iconTypes = map[string]string{"svg": "image/svg+xml", "png": "image/png", "webp": "image/webp", "jpg": "image/jpeg"} - -// iconKind tells the image by its bytes, never by what the sender says. -func iconKind(b []byte) string { - switch { - case bytes.HasPrefix(b, []byte("\x89PNG\r\n\x1a\n")): - return "png" - case bytes.HasPrefix(b, []byte{0xff, 0xd8, 0xff}): - return "jpg" - case len(b) > 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP": - return "webp" - } - head := strings.ToLower(string(b[:min(len(b), 1024)])) - if strings.HasPrefix(strings.TrimSpace(head), "<") && strings.Contains(head, ".", so a sender compares the hash with its own and sends -// again only when its icon changed. -func (s *Server) putHostIcon(w http.ResponseWriter, r *http.Request) { - b, err := io.ReadAll(io.LimitReader(r.Body, maxIcon+1)) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "could not read the icon"}) - return - } - if len(b) > maxIcon { - writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "an icon is at most 256 KB"}) - return - } - kind := iconKind(b) - if kind == "" { - writeJSON(w, http.StatusUnsupportedMediaType, map[string]any{"error": "an icon is an SVG, PNG, WebP or JPEG image"}) - return - } - sum := sha256.Sum256(b) - icon := hex.EncodeToString(sum[:8]) + "." + kind - id := r.PathValue("id") - iconMu.Lock() - defer iconMu.Unlock() - st := s.mgr.State() - i := st.Find(id) - if i < 0 || st.Hosts[i].DeletedAt != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such host"}) - return - } - h := st.Hosts[i] - if h.Icon != icon { - if err := os.MkdirAll(s.iconDir(), 0o755); err != nil { - writeError(w, err) - return - } - h.Icon = icon - tmp := s.iconFile(h) + ".new" - if err := os.WriteFile(tmp, b, 0o644); err != nil { - writeError(w, err) - return - } - if err := os.Rename(tmp, s.iconFile(h)); err != nil { - writeError(w, err) - return - } - err = s.mgr.Note(func(st *state.State) { - if i := st.Find(id); i >= 0 { - st.Hosts[i].Icon = icon - } - }) - if err != nil { - writeError(w, err) - return - } - s.tidyIcons() - } - writeJSON(w, http.StatusOK, view(s.mgr.State().Settings, h)) -} - -// hostIcon serves it to the Names page. An SVG opened on its own runs on -// this page's origin: the policy keeps its scripts from running. -func (s *Server) hostIcon(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - i := st.Find(r.PathValue("id")) - if i < 0 || st.Hosts[i].Icon == "" { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no icon"}) - return - } - h := st.Hosts[i] - b, err := os.ReadFile(s.iconFile(h)) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no icon"}) - return - } - _, kind, _ := strings.Cut(h.Icon, ".") - w.Header().Set("Content-Type", iconTypes[kind]) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - // The page asks with ?v=: a new icon is a new address. - w.Header().Set("Cache-Control", "private, max-age=604800") - w.Write(b) -} - -// tidyIcons drops the files of hosts that are gone, and of kinds a host -// no longer has. Callers other than putHostIcon hold iconMu. -func (s *Server) tidyIcons() { - keep := map[string]bool{} - for _, h := range s.mgr.State().Hosts { - if h.Icon != "" { - keep[filepath.Base(s.iconFile(h))] = true - } - } - entries, _ := os.ReadDir(s.iconDir()) - for _, e := range entries { - if !keep[e.Name()] { - if err := os.Remove(filepath.Join(s.iconDir(), e.Name())); err != nil { - log.Printf("[admin] icon %s: %v", e.Name(), err) - } - } - } -} - -// fitIcons forgets icons whose file is not there: a restored backup holds -// state.json, not the icons. The app that sent them sends them again when -// it sees no hash. -func (s *Server) fitIcons(st *state.State) bool { - changed := false - for i, h := range st.Hosts { - if h.Icon == "" { - continue - } - if _, err := os.Stat(s.iconFile(h)); err != nil { - st.Hosts[i].Icon = "" - changed = true - } - } - return changed -} diff --git a/admin/internal/server/icons_test.go b/admin/internal/server/icons_test.go deleted file mode 100644 index af914b6..0000000 --- a/admin/internal/server/icons_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package server - -import ( - "os" - "strconv" - "strings" - "sync" - "testing" -) - -const svgIcon = `` - -// fjord sends the app's icon after it makes the name; the Names page shows it. -func TestHostIcon(t *testing.T) { - s, c, key := setUp(t) - api := &client{t: t, s: s, bearer: key} - _, h := api.do("POST", "/api/v1/hosts", `{"name":"radarr","target":{"address":"192.168.1.20","port":7878},"ref":"fjord:x:radarr"}`) - id := h["id"].(string) - if h["icon"] != nil { - t.Fatalf("a new host has an icon: %v", h) - } - if rr, _ := api.do("PUT", "/api/v1/hosts/"+id+"/icon", "#!/bin/sh\necho hi\n"); rr.Code != 415 { - t.Fatalf("not an image: %d", rr.Code) - } - if rr, _ := api.do("PUT", "/api/v1/hosts/"+id+"/icon", ""); rr.Code != 413 { - t.Fatalf("too big: %d", rr.Code) - } - rr, out := api.do("PUT", "/api/v1/hosts/"+id+"/icon", svgIcon) - icon, _ := out["icon"].(string) - if rr.Code != 200 || !strings.HasSuffix(icon, ".svg") { - t.Fatalf("icon: %d %v", rr.Code, out) - } - // An edit of the host (fjord's rename sends the editable fields) keeps it. - if _, out = api.do("PUT", "/api/v1/hosts/"+id, `{"name":"movies","target":{"address":"192.168.1.20","port":7878},"ref":"fjord:x:radarr"}`); out["icon"] != icon { - t.Fatalf("after an edit: %v", out) - } - rr, _ = c.do("GET", "/api/v1/hosts/"+id+"/icon", "") - if rr.Code != 200 || rr.Body.String() != svgIcon || rr.Header().Get("Content-Type") != "image/svg+xml" || - !strings.Contains(rr.Header().Get("Content-Security-Policy"), "default-src 'none'") { - t.Fatalf("served: %d %v", rr.Code, rr.Header()) - } - if rr, _ := (&client{t: t, s: s}).do("GET", "/api/v1/hosts/"+id+"/icon", ""); rr.Code != 401 { - t.Fatalf("signed out: %d", rr.Code) - } - - // A PNG replaces it, and the SVG file goes. - png := "\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 32) - if _, out = api.do("PUT", "/api/v1/hosts/"+id+"/icon", png); !strings.HasSuffix(out["icon"].(string), ".png") { - t.Fatalf("png: %v", out) - } - files, _ := os.ReadDir(s.iconDir()) - if len(files) != 1 || files[0].Name() != id+".png" { - t.Fatalf("files: %v", files) - } - // A backup holds state.json, not the icons: restored without the file, - // the host forgets it, so fjord sends it again. - os.Remove(s.iconFile(s.mgr.State().Hosts[0])) - st := s.mgr.State().Clone() - if !s.fitIcons(st) || st.Hosts[0].Icon != "" { - t.Fatalf("restored without the file: %v", st.Hosts[0]) - } -} - -// fjord sends every app's icon at once when it starts: none may be lost. -func TestIconsSentAtOnce(t *testing.T) { - s, _, key := setUp(t) - api := &client{t: t, s: s, bearer: key} - var ids []string - for i := range 12 { - _, h := api.do("POST", "/api/v1/hosts", `{"name":"app`+strconv.Itoa(i)+`","target":{"address":"192.168.1.20","port":80}}`) - ids = append(ids, h["id"].(string)) - } - var wg sync.WaitGroup - for i, id := range ids { - wg.Add(1) - go func() { - defer wg.Done() - (&client{t: t, s: s, bearer: key}).do("PUT", "/api/v1/hosts/"+id+"/icon", strings.Replace(svgIcon, "8", strconv.Itoa(i+1), 1)) - }() - } - wg.Wait() - for _, h := range s.mgr.State().Hosts { - if _, err := os.Stat(s.iconFile(h)); h.Icon == "" || err != nil { - t.Errorf("%s: icon %q, file %v", h.Name, h.Icon, err) - } - } - // One lost anyway (an older build's race): forgotten at the next start, - // so fjord sends it again. - os.Remove(s.iconFile(s.mgr.State().Hosts[0])) - again := New("test", upRunner{}, s.mgr, "", s.secrets) - if h := again.mgr.State().Hosts[0]; h.Icon != "" { - t.Fatalf("lost icon still named: %v", h.Icon) - } -} diff --git a/admin/internal/server/listen.go b/admin/internal/server/listen.go deleted file mode 100644 index f0e8c82..0000000 --- a/admin/internal/server/listen.go +++ /dev/null @@ -1,187 +0,0 @@ -package server - -import ( - "log" - "maps" - "net" - "net/http" - "slices" - "strings" - - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/netif" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -func (s *Server) networks() []netif.Network { - if s.Networks != nil { - return s.Networks() - } - return netif.List() -} - -// here is the network the request came in on ("" from the unix socket, or -// from loopback inside the container). -func here(r *http.Request, networks []netif.Network) string { - local := r.Header.Get(caddy.LocalHeader) - if ip := net.ParseIP(local); ip == nil || ip.IsLoopback() { - return "" - } - for _, n := range networks { - if n.Contains(local) { - return n.Network - } - } - return "" -} - -// envNetworks is RP_LISTEN: networks separated by commas or spaces. -func envNetworks(v string) []string { - return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' }) -} - -// listenView is the Networks card: the container's networks, the one the -// person is on, and the ones it answers on (none: every network). -func (s *Server) listenView(r *http.Request, set state.Settings) map[string]any { - networks := s.networks() - if networks == nil { - networks = []netif.Network{} - } - listen := set.Listen - setBy := "" - if s.ListenEnv != "" { - setBy = "RP_LISTEN" - listen = nil - for _, w := range envNetworks(s.ListenEnv) { - if n, ok := netif.Find(networks, w); ok { - w = n.Network - } - listen = append(listen, w) - } - } - if listen == nil { - listen = []string{} - } - return map[string]any{"networks": networks, "here": here(r, networks), "listen": listen, "listenSetBy": setBy} -} - -// checkListen turns a choice into the subnets to save. Each must be a -// network the container is on and, from a browser, the one that browser is -// on must be among them: leaving it out would cut the page off mid-click. -// Every network the container is on, chosen one by one, is saved as none: -// every network, including one added later. -func (s *Server) checkListen(r *http.Request, wants []string) ([]string, error) { - if s.ListenEnv != "" { - return nil, &state.FieldError{Field: "listen", Msg: "RP_LISTEN in the container's settings decides this; change it there."} - } - networks := s.networks() - var out []string - for _, w := range wants { - if w = strings.TrimSpace(w); w == "" { - continue - } - n, ok := netif.Find(networks, w) - if !ok { - return nil, &state.FieldError{Field: "listen", Msg: "The proxy is not on " + w + "."} - } - if !slices.Contains(out, n.Network) { - out = append(out, n.Network) - } - } - if len(out) == 0 || len(out) == len(networks) { - return nil, nil - } - // From a browser (Caddy set the header). An arrival that matches no - // listed network (IPv6-only, link-local) is refused too; loopback inside - // the container is not a browser's way in. - if local := net.ParseIP(r.Header.Get(caddy.LocalHeader)); local != nil && !local.IsLoopback() { - on := here(r, networks) - if on == "" { - return nil, &state.FieldError{Field: "listen", Msg: "This page reached the proxy at " + local.String() + ", which is not on any of its networks. Open it at an address on one of the networks to choose them."} - } - if !slices.Contains(out, on) { - return nil, &state.FieldError{Field: "listen", Msg: "This page reached the proxy on " + on + ". Leaving it out would cut this page off: keep it, or choose from a device on another network."} - } - } - slices.Sort(out) - return out, nil -} - -// putListen is the home-network choice from Settings and from setup -// (before an ending exists, so not part of PUT /settings). It answers with -// the settings, as PUT /settings does. -func (s *Server) putListen(w http.ResponseWriter, r *http.Request) { - var in struct { - Listen []string `json:"listen"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - listen, err := s.checkListen(r, in.Listen) - if err != nil { - writeError(w, err) - return - } - if err := s.mgr.Apply(func(st *state.State) error { st.Settings.Listen = listen; return nil }); err != nil { - writeError(w, err) - return - } - st := s.mgr.State() - out := settingsView(st.Settings) - maps.Copy(out, s.listenView(r, st.Settings)) - writeJSON(w, http.StatusOK, out) -} - -// internalListen is `reverse-proxy listen`, over the unix socket only (the -// network side never routes /internal/): the way back from a choice that -// locked everyone out. -func (s *Server) internalListen(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Forwarded-For") != "" { - http.NotFound(w, r) - return - } - var in struct { - Listen []string `json:"listen"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - listen, err := s.checkListen(r, in.Listen) - if err != nil { - writeError(w, err) - return - } - if err := s.mgr.Apply(func(st *state.State) error { st.Settings.Listen = listen; return nil }); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, s.listenView(r, s.mgr.State().Settings)) -} - -// fitListen drops restored networks this machine is not on: a backup moved -// to another host would otherwise answer only inside the container, cutting -// off the page the restore came from. -func (s *Server) fitListen(st *state.State) bool { - if len(st.Settings.Listen) == 0 { - return false - } - networks := s.networks() - var kept []string - for _, w := range st.Settings.Listen { - if _, ok := netif.Find(networks, w); ok { - kept = append(kept, w) - } - } - if len(kept) == len(st.Settings.Listen) { - return false - } - if len(kept) == 0 { - log.Printf("[admin] the backup answered only on %s, which this machine is not on: answering on every network", strings.Join(st.Settings.Listen, ", ")) - } else { - log.Printf("[admin] the backup answered on %s; this machine is on %s of them only", strings.Join(st.Settings.Listen, ", "), strings.Join(kept, ", ")) - } - st.Settings.Listen = kept - return true -} diff --git a/admin/internal/server/listen_test.go b/admin/internal/server/listen_test.go deleted file mode 100644 index 2f7fe3d..0000000 --- a/admin/internal/server/listen_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package server - -import ( - "net/http/httptest" - "slices" - "strings" - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/netif" -) - -// threeNetworks is jupiter's proxy: lan86 for people, two VLANs it reaches -// apps on. -func threeNetworks() []netif.Network { - return []netif.Network{ - {Interface: "eth0", Network: "192.168.86.0/24", Address: "192.168.86.11", Addresses: []string{"192.168.86.11"}}, - {Interface: "eth1", Network: "192.168.4.0/24", Address: "192.168.4.204", Addresses: []string{"192.168.4.204"}}, - {Interface: "eth2", Network: "192.168.5.0/24", Address: "192.168.5.145", Addresses: []string{"192.168.5.145"}}, - } -} - -func put(listen string) string { return `{"listen":` + listen + `}` } - -func TestListenOnSomeNetworks(t *testing.T) { - s, c, _ := setUp(t) - s.Networks = threeNetworks - fromLan := []string{caddy.LocalHeader, "192.168.86.11"} - saved := func() []string { return s.mgr.State().Settings.Listen } - - rr, out := c.do("GET", "/api/v1/settings", "", fromLan...) - if rr.Code != 200 || out["here"] != "192.168.86.0/24" || len(out["listen"].([]any)) != 0 || len(out["networks"].([]any)) != 3 { - t.Fatalf("settings: %d %v", rr.Code, out) - } - // Leaving out the network this page came in on would cut it off. - rr, out = c.do("PUT", "/api/v1/listen", put(`["192.168.5.0/24"]`), fromLan...) - if rr.Code != 400 || out["field"] != "listen" || !strings.Contains(out["error"].(string), "cut this page off") { - t.Fatalf("without its own: %d %v", rr.Code, out) - } - if rr, out = c.do("PUT", "/api/v1/listen", put(`["192.168.86.0/24","10.9.9.0/24"]`), fromLan...); rr.Code != 400 || out["field"] != "listen" { - t.Fatalf("a network it is not on: %d %v", rr.Code, out) - } - // lan86 and vlan5, by address and by interface: saved as subnets. - if rr, out = c.do("PUT", "/api/v1/listen", put(`["eth2","192.168.86.11"]`), fromLan...); rr.Code != 200 { - t.Fatalf("two networks: %d %v", rr.Code, out) - } - if want := []string{"192.168.5.0/24", "192.168.86.0/24"}; !slices.Equal(saved(), want) { - t.Fatalf("saved %v, want %v", saved(), want) - } - // Leaving listen out keeps it. - if rr, _ = c.do("PUT", "/api/v1/settings", `{"domain":"apps.test","trustedNetworks":[]}`, fromLan...); rr.Code != 200 || len(saved()) != 2 { - t.Fatalf("kept: %d %v", rr.Code, saved()) - } - // All three one by one is every network. - if rr, _ = c.do("PUT", "/api/v1/listen", put(`["eth0","eth1","eth2"]`), fromLan...); rr.Code != 200 || saved() != nil { - t.Fatalf("all three: %d %v", rr.Code, saved()) - } - if rr, _ = c.do("PUT", "/api/v1/listen", put(`["eth0"]`), fromLan...); rr.Code != 200 || len(saved()) != 1 { - t.Fatalf("one: %d %v", rr.Code, saved()) - } - if rr, _ = c.do("PUT", "/api/v1/listen", put(`[]`), fromLan...); rr.Code != 200 || saved() != nil { - t.Fatalf("every network again: %d %v", rr.Code, saved()) - } -} - -func TestListenRefusedFromAnUnlistedAddress(t *testing.T) { - s, c, _ := setUp(t) - s.Networks = threeNetworks - // Came in on an address no listed network has (IPv6-only interface). - rr, out := c.do("PUT", "/api/v1/listen", put(`["192.168.86.0/24"]`), caddy.LocalHeader, "2001:db8::77") - if rr.Code != 400 || out["field"] != "listen" { - t.Fatalf("unlisted arrival: %d %v", rr.Code, out) - } -} - -func TestListenCommandOverTheSocket(t *testing.T) { - s, _, _ := setUp(t) - s.Networks = threeNetworks - post := func(body string, hdr ...string) *httptest.ResponseRecorder { - req := httptest.NewRequest("POST", "http://admin/internal/listen", strings.NewReader(body)) - for i := 0; i+1 < len(hdr); i += 2 { - req.Header.Set(hdr[i], hdr[i+1]) - } - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - return rr - } - // From the socket any choice goes: it is the way back in. - if rr := post(`{"listen":["eth2"]}`); rr.Code != 200 || !slices.Equal(s.mgr.State().Settings.Listen, []string{"192.168.5.0/24"}) { - t.Fatalf("socket: %d %s", rr.Code, rr.Body) - } - if rr := post(`{"listen":[]}`); rr.Code != 200 || s.mgr.State().Settings.Listen != nil { - t.Fatalf("all: %d %s", rr.Code, rr.Body) - } - if rr := post(`{"listen":["eth0"]}`, "X-Forwarded-For", "192.168.1.50"); rr.Code != 404 { - t.Fatalf("from the network: %d", rr.Code) - } -} - -func TestListenSetByEnvironment(t *testing.T) { - s, c, _ := setUp(t) - s.Networks = threeNetworks - s.ListenEnv = "eth0, 192.168.5.0/24" - _, out := c.do("GET", "/api/v1/settings", "") - if got := out["listen"].([]any); out["listenSetBy"] != "RP_LISTEN" || len(got) != 2 || got[0] != "192.168.86.0/24" || got[1] != "192.168.5.0/24" { - t.Fatalf("settings: %v", out) - } - if rr, out := c.do("PUT", "/api/v1/listen", put(`[]`)); rr.Code != 400 || out["field"] != "listen" { - t.Fatalf("change under RP_LISTEN: %d %v", rr.Code, out) - } -} - -func TestRestoreKeepsOnlyNetworksHere(t *testing.T) { - s, _, _ := setUp(t) - s.Networks = threeNetworks - st := s.mgr.State() - st.Settings.Listen = []string{"10.20.30.0/24"} // the machine the backup came from - if !s.fitListen(st) || st.Settings.Listen != nil { - t.Fatalf("foreign network kept: %v", st.Settings.Listen) - } - st.Settings.Listen = []string{"10.20.30.0/24", "192.168.86.0/24"} - if !s.fitListen(st) || !slices.Equal(st.Settings.Listen, []string{"192.168.86.0/24"}) { - t.Fatalf("partly here: %v", st.Settings.Listen) - } - if s.fitListen(st) { - t.Fatal("changed a choice that fits") - } -} - -func TestListenChosenDuringSetupBeforeAnEnding(t *testing.T) { - s, tok := newTestServer(t) - s.Networks = threeNetworks - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"a long enough password"}`) - rr, out := c.do("PUT", "/api/v1/listen", put(`["eth0","eth2"]`), caddy.LocalHeader, "192.168.86.11") - if rr.Code != 200 || out["domain"] != "" || len(out["listen"].([]any)) != 2 { - t.Fatalf("setup step: %d %v", rr.Code, out) - } -} diff --git a/admin/internal/server/openapi.go b/admin/internal/server/openapi.go deleted file mode 100644 index 42246d0..0000000 --- a/admin/internal/server/openapi.go +++ /dev/null @@ -1,17 +0,0 @@ -package server - -import ( - _ "embed" - "net/http" -) - -// openAPISpec describes the v1 hosts API: what a connected app, a script -// or another manager may rely on. The UI's own calls are not in it. -// -//go:embed openapi.json -var openAPISpec []byte - -func openAPI(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(openAPISpec) -} diff --git a/admin/internal/server/openapi.json b/admin/internal/server/openapi.json deleted file mode 100644 index ad25048..0000000 --- a/admin/internal/server/openapi.json +++ /dev/null @@ -1,1265 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Daemonless Reverse Proxy API", - "version": "1", - "summary": "Give services on a home network HTTPS names.", - "description": "The hosts API of the daemonless reverse proxy. A host is one name (radarr.apps.lan) in front of one service (an address and a port). The proxy issues the certificate and decides who may open the name.\n\nCalls are made to the admin, https://:8443, with `Authorization: Bearer `. A key is made in Settings > Connected apps, or given to the proxy at install as `RP_PAIRING_KEY`. A key manages hosts only; the proxy's own settings, certificates, backup and keys belong to the account and answer 403 to a key.\n\nThis is version 1 of the API. Fields may be added; nothing listed here is renamed or removed within version 1. `GET /api/v1/status` says which version a proxy speaks.\n\nInside the container the same calls work over the admin's unix socket without a key, which is what the `reverse-proxy hosts` command uses.\n\n## Adding your app\n\nfjord is one app that uses this API; any app that knows where its services live (a jail or VM manager, a container UI, a script) can do the same:\n\n1. **Get a key.** Either the person links your app: they press \"Link an app…\" under Connected apps and paste the link into your app, which sends the code to `POST /api/v1/pair` with its own id, name and address. Or, when your app installs the proxy, it sets `RP_PAIRING_KEY` (and `RP_PAIRING_NAME`, `RP_PAIRING_ID`, `RP_PAIRING_URL`) and the proxy takes that key on first start. A key made by hand under Connected apps works too.\n2. **Say who you are.** `PUT /api/v1/me` with your permanent id, the name to show and where your app is opened, whenever one of them changes.\n3. **Give names.** `POST /api/v1/hosts` with the name, the service's address and port, and a `ref` of the form `::` (for example `sylve::jail-104`). One host per ref: a retry gets `409` and the host already made, so a lost answer never makes two.\n4. **Follow the service.** When its address or port changes, `PUT /api/v1/hosts/{id}` with the new target; `GET /api/v1/probe` says first whether the proxy can reach it.\n5. **Optionally,** `PUT /api/v1/hosts/{id}/icon` with the service's icon, shown next to its name.", - "license": { - "name": "BSD-2-Clause", - "identifier": "BSD-2-Clause" - } - }, - "servers": [ - { - "url": "https://{proxy}:8443", - "variables": { - "proxy": { - "default": "192.168.1.5", - "description": "Where the admin is reached: the host's address or proxy.." - } - } - } - ], - "security": [ - { - "key": [] - } - ], - "tags": [ - { - "name": "status", - "description": "What this proxy is and whether it is set up." - }, - { - "name": "hosts", - "description": "Names in front of services." - }, - { - "name": "trust", - "description": "What a device needs to trust a private ending's certificates. Public: no key." - } - ], - "paths": { - "/api/v1/status": { - "get": { - "tags": [ - "status" - ], - "summary": "Identify the proxy", - "description": "Without a key: product, API version, software version, whether setup is done. With a valid key the ending and the counts come too. A key that is refused gets 401, so an app learns it was disconnected.", - "security": [ - {}, - { - "key": [] - } - ], - "responses": { - "200": { - "description": "The proxy's status.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Status" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/api/v1/hosts": { - "get": { - "tags": [ - "hosts" - ], - "summary": "List hosts", - "description": "The live hosts. With `include=removed` the ones removed in the last 24 hours come too; they carry `deletedAt` and can be restored.", - "parameters": [ - { - "name": "include", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "removed" - ] - } - } - ], - "responses": { - "200": { - "description": "The hosts.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "hosts" - ], - "properties": { - "hosts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Host" - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - }, - "post": { - "tags": [ - "hosts" - ], - "summary": "Add a host", - "description": "Makes `.` and starts serving it. The name must be free; the target is not checked (use the probe first if you want to be sure). With a `ref` that another host already carries, nothing is made and that host comes back with 409.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HostInput" - } - } - } - }, - "responses": { - "201": { - "description": "The new host.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Host" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadField" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "409": { - "description": "A host with this `ref` exists already (the body is that host), or the proxy refused the new configuration and kept the old one (`error`, `detail`).", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/Host" - }, - { - "$ref": "#/components/schemas/Error" - } - ] - } - } - } - } - } - } - }, - "/api/v1/hosts/health": { - "get": { - "tags": [ - "hosts" - ], - "summary": "Reach every host's target", - "description": "Connects to each live host's target from inside the proxy, as the proxy itself would. Takes up to three seconds per unreachable target, in parallel.", - "responses": { - "200": { - "description": "One result per host id.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "health" - ], - "properties": { - "health": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/Probe" - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/api/v1/hosts/{id}": { - "parameters": [ - { - "$ref": "#/components/parameters/id" - } - ], - "get": { - "tags": [ - "hosts" - ], - "summary": "Get a host", - "responses": { - "200": { - "description": "The host.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Host" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - } - } - }, - "put": { - "tags": [ - "hosts" - ], - "summary": "Change a host", - "description": "Replaces the host's name, target and access with the body; fields left out are reset to their defaults, so send the whole host.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HostInput" - } - } - } - }, - "responses": { - "200": { - "description": "The changed host.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Host" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadField" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/NotApplied" - } - } - }, - "delete": { - "tags": [ - "hosts" - ], - "summary": "Remove a host", - "description": "The name stops being served at once. The host is kept for 24 hours and can be restored.", - "responses": { - "200": { - "description": "Removed.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "id", - "undoUntil" - ], - "properties": { - "id": { - "type": "string" - }, - "undoUntil": { - "type": "string", - "format": "date-time", - "description": "Until when the host can be restored." - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - } - } - } - }, - "/api/v1/hosts/{id}/restore": { - "parameters": [ - { - "$ref": "#/components/parameters/id" - } - ], - "post": { - "tags": [ - "hosts" - ], - "summary": "Restore a removed host", - "description": "Undoes a removal made in the last 24 hours.", - "responses": { - "200": { - "description": "The host, live again.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Host" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "description": "Nothing to restore.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/v1/probe": { - "get": { - "tags": [ - "hosts" - ], - "summary": "Try a target", - "description": "Connects to an address and port from inside the proxy and says what is there, so a host can be added with the right `https` and `acceptUntrusted`.", - "parameters": [ - { - "name": "address", - "in": "query", - "required": true, - "schema": { - "type": "string" - }, - "example": "192.168.1.20" - }, - { - "name": "port", - "in": "query", - "required": true, - "schema": { - "type": "integer" - }, - "example": 7878 - } - ], - "responses": { - "200": { - "description": "What answered. `reachable: false` when nothing did.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Probe" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/api/v1/trust": { - "get": { - "tags": [ - "trust" - ], - "summary": "What to trust", - "description": "Public. With a private ending the proxy is its own certificate authority; this is its root's fingerprint and where to download it. With public certificates `needed` is false.", - "security": [ - {} - ], - "responses": { - "200": { - "description": "Trust information.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Trust" - } - } - } - } - } - } - }, - "/api/v1/pair": { - "post": { - "tags": [ - "trust" - ], - "summary": "Redeem a pairing code for a key", - "description": "Public, rate-limited. An app (fjord, or any other) sends a code the admin made under Connected apps (\"Link an app…\", good for ten minutes, once) with its own id, name and address, and gets a key of its own plus what it needs to trust this proxy.", - "security": [ - {} - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "code" - ], - "properties": { - "code": { - "type": "string", - "example": "HXFY-4V5Y-4ZRV-UV43" - }, - "client": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The app's permanent id (letters, digits, dashes)." - }, - "name": { - "type": "string", - "description": "Shown in Connected apps, e.g. \"fjord on venus\"." - }, - "url": { - "type": "string", - "description": "Where the app is opened; shown as a way back." - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "The key and trust information.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "proxyName": { - "type": "string" - }, - "needed": { - "type": "boolean" - }, - "fingerprint": { - "type": "string" - }, - "rootUrl": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "The code is wrong or expired." - }, - "429": { - "description": "Too many wrong codes; retry later." - } - } - } - }, - "/trust/root.crt": { - "get": { - "tags": [ - "trust" - ], - "summary": "The root certificate (PEM)", - "description": "Public. Also served on the plain-HTTP trust listener, http:///trust/root.crt, for devices that do not trust the proxy yet.", - "security": [ - {} - ], - "responses": { - "200": { - "description": "The root certificate.", - "content": { - "application/x-pem-file": { - "schema": { - "type": "string" - } - } - } - }, - "404": { - "description": "No private ending, so no root." - } - } - } - }, - "/api/v1/me": { - "put": { - "tags": [ - "trust" - ], - "summary": "Say who the app behind this key is", - "description": "The app's permanent id, the name shown under Connected apps (for example: fjord on venus) and where it is opened. Only what is sent changes. For an app's key; the account gets 403.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Letters, digits, dashes; up to 64." - }, - "name": { - "type": "string", - "description": "One line, up to 64 characters." - }, - "url": { - "type": "string", - "description": "http(s) address of the app." - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Recorded.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "changed": { - "type": "boolean" - } - } - } - } - } - }, - "403": { - "description": "Not an app's key." - } - } - } - }, - "/api/v1/openapi.json": { - "get": { - "tags": [ - "status" - ], - "summary": "This document", - "security": [ - {} - ], - "responses": { - "200": { - "description": "This document.", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - } - } - } - }, - "/api/v1/hosts/{id}/icon": { - "parameters": [ - { - "$ref": "#/components/parameters/id" - } - ], - "get": { - "tags": [ - "hosts" - ], - "summary": "The host's icon", - "description": "The image the app that made the name sent, as it was sent.", - "responses": { - "200": { - "description": "The icon.", - "content": { - "image/svg+xml": {}, - "image/png": {}, - "image/webp": {}, - "image/jpeg": {} - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "description": "The host has no icon." - } - } - }, - "put": { - "tags": [ - "hosts" - ], - "summary": "Set the host's icon", - "description": "The image is the request body: SVG, PNG, WebP or JPEG, at most 256 KB, told apart by its bytes. The host's icon field becomes \".\": send again only when the hash of your icon differs. Editing the host keeps it.", - "requestBody": { - "required": true, - "content": { - "image/svg+xml": {}, - "image/png": {}, - "image/webp": {}, - "image/jpeg": {} - } - }, - "responses": { - "200": { - "description": "The host, with its icon.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Host" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "description": "Nothing to restore.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "413": { - "description": "Larger than 256 KB." - }, - "415": { - "description": "Not an SVG, PNG, WebP or JPEG image." - } - } - } - }, - "/api/v1/hosts/uptime": { - "get": { - "tags": [ - "hosts" - ], - "summary": "Every host's uptime", - "description": "The proxy connects to each live host's target once a minute in the background. One summary per host id, with the last 24 hours.", - "responses": { - "200": { - "description": "One summary per host id.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "uptime", - "every" - ], - "properties": { - "uptime": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/Uptime" - } - }, - "every": { - "type": "integer", - "description": "seconds between checks" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - } - } - }, - "/api/v1/hosts/{id}/uptime": { - "get": { - "tags": [ - "hosts" - ], - "summary": "One host's uptime history", - "parameters": [ - { - "$ref": "#/components/parameters/id" - } - ], - "responses": { - "200": { - "description": "The history: hourly counts for up to 90 days and the last outages.", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Uptime" - }, - { - "type": "object", - "properties": { - "hours": { - "type": "array", - "description": "up to 90 days", - "items": { - "type": "array", - "description": "[hour start (unix seconds), checks, checks answered, sum of the answered checks' connect times in ms]", - "items": { - "type": "integer" - }, - "minItems": 4, - "maxItems": 4 - } - }, - "outages": { - "type": "array", - "description": "the last 50", - "items": { - "type": "object", - "required": [ - "start" - ], - "properties": { - "start": { - "type": "string", - "format": "date-time" - }, - "end": { - "type": "string", - "format": "date-time", - "description": "absent while it lasts" - } - } - } - } - } - } - ] - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "description": "No such host." - } - } - } - } - }, - "components": { - "securitySchemes": { - "key": { - "type": "http", - "scheme": "bearer", - "description": "A connected app's key: `rp__` from Settings > Connected apps, or the `RP_PAIRING_KEY` the proxy was installed with, sent whole." - } - }, - "parameters": { - "id": { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "The host's id from the list." - } - }, - "responses": { - "Unauthorized": { - "description": "No key, or one the proxy no longer accepts.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "NotFound": { - "description": "No such host.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "BadField": { - "description": "A field the caller must fix; `field` names it (`name` when the name is taken or not a label; `target.address`, `target.port`, `access`, `extraNames`, `trustedNetworks`). An empty `field` means the proxy has no ending yet: finish its setup first.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "NotApplied": { - "description": "The proxy refused the new configuration and kept the old one; `detail` has its reason.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "schemas": { - "Error": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string", - "description": "What went wrong, in plain words." - }, - "field": { - "type": "string", - "description": "The request field to fix, when there is one." - }, - "detail": { - "type": "string", - "description": "The proxy's own words, when the configuration was refused." - } - } - }, - "Status": { - "type": "object", - "required": [ - "product", - "apiVersion", - "version", - "caddy", - "setup" - ], - "properties": { - "product": { - "type": "string", - "const": "reverse-proxy" - }, - "apiVersion": { - "type": "integer", - "const": 1 - }, - "version": { - "type": "string", - "description": "The admin's version.", - "example": "0.1.0" - }, - "caddy": { - "type": "object", - "properties": { - "running": { - "type": "boolean" - }, - "version": { - "type": "string" - } - } - }, - "setup": { - "type": "object", - "properties": { - "complete": { - "type": "boolean", - "description": "False until the first run (password and ending) is done; until then hosts cannot be served." - } - } - }, - "domain": { - "type": "string", - "description": "With a key: the ending, e.g. `apps.lan`. Empty before setup.", - "example": "apps.lan" - }, - "adminUrl": { - "type": "string", - "description": "With a key: where a person opens the proxy's own pages (`RP_ADMIN_URL`, else the address the account last signed in at). Absent until someone has.", - "example": "https://192.168.1.5:8443" - }, - "mode": { - "type": "string", - "enum": [ - "unset", - "private", - "public" - ], - "description": "With a key: `private` endings get the proxy's own certificates, `public` ones Let's Encrypt." - }, - "hosts": { - "type": "integer", - "description": "With a key: how many live hosts." - }, - "certificates": { - "type": "string", - "description": "With a key: the certificate state (`ok`, `issuing`, `trial`, `failed`, `manual`)." - }, - "proxyName": { - "type": "string", - "description": "With a key: the proxy's own name, `proxy.`.", - "example": "proxy.apps.lan" - }, - "trustUrl": { - "type": "string", - "description": "With a key: the trust page by name.", - "example": "http://proxy.apps.lan/trust" - } - } - }, - "Target": { - "type": "object", - "required": [ - "address", - "port" - ], - "properties": { - "address": { - "type": "string", - "description": "IP address or hostname the proxy connects to.", - "example": "192.168.1.20" - }, - "port": { - "type": "integer", - "example": 7878 - }, - "https": { - "type": "boolean", - "default": false, - "description": "The service itself speaks HTTPS." - }, - "acceptUntrusted": { - "type": "boolean", - "default": false, - "description": "With `https`: accept the service's self-signed certificate." - } - } - }, - "HostInput": { - "type": "object", - "required": [ - "name", - "target" - ], - "properties": { - "name": { - "type": "string", - "description": "The label under the ending: letters, digits and dashes.", - "example": "radarr" - }, - "extraNames": { - "type": "array", - "items": { - "type": "string" - }, - "description": "More labels for the same service." - }, - "target": { - "$ref": "#/components/schemas/Target" - }, - "access": { - "type": "string", - "enum": [ - "home", - "internet" - ], - "default": "home", - "description": "`home`: devices on the home network only. `internet`: anyone." - }, - "trustedNetworks": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Extra networks (CIDR) that count as home for this host only." - }, - "ref": { - "type": "string", - "description": "Mark of where the host came from, so the app that made it finds it again: `::`. The proxy counts an app's names by the id in it. One host per ref.", - "example": "fjord:4cc0fb41d2a0:radarr" - }, - "ownOrigin": { - "type": "boolean", - "default": false, - "description": "The service checks Origin and Referer against its own address (UniFi does): requests from this name's own pages reach it with its address in those headers. Off by default, since most services compare Origin with the Host they are given. Left as it was when a PUT omits it." - }, - "watch": { - "type": "boolean", - "description": "check that it is up (uptime and alerts); true when created without it. Left out of an update, it stays as it was." - } - } - }, - "Host": { - "allOf": [ - { - "$ref": "#/components/schemas/HostInput" - }, - { - "type": "object", - "required": [ - "id", - "names", - "url", - "createdAt" - ], - "properties": { - "id": { - "type": "string" - }, - "names": { - "type": "array", - "items": { - "type": "string" - }, - "description": "The full names served, first the main one.", - "example": [ - "radarr.apps.lan" - ] - }, - "url": { - "type": "string", - "description": "Where to open it.", - "example": "https://radarr.apps.lan" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "by": { - "type": "string", - "description": "Id of the key that made the host; absent when it was made by hand. Set by the proxy." - }, - "deletedAt": { - "type": "string", - "format": "date-time", - "description": "Set on a removed host while it can still be restored." - }, - "icon": { - "type": "string", - "description": "\".\" of the icon set with PUT /api/v1/hosts/{id}/icon; absent when it has none.", - "example": "3fa9c2e17b0d4e55.svg" - }, - "unwatched": { - "type": "boolean", - "description": "not checked for uptime; no alerts" - } - } - } - ] - }, - "Probe": { - "type": "object", - "required": [ - "reachable", - "https", - "untrustedCert" - ], - "properties": { - "reachable": { - "type": "boolean" - }, - "https": { - "type": "boolean", - "description": "The target speaks HTTPS." - }, - "untrustedCert": { - "type": "boolean", - "description": "Its certificate is not trusted (self-signed)." - }, - "self": { - "type": "boolean", - "description": "The address is this proxy's own port 80 or 443, so a name for it would loop." - }, - "status": { - "type": "integer", - "description": "HTTP status of a GET /." - }, - "server": { - "type": "string", - "description": "Its Server header, when it sends one." - } - } - }, - "Trust": { - "type": "object", - "required": [ - "domain", - "mode", - "needed", - "httpPort" - ], - "properties": { - "domain": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "unset", - "private", - "public" - ] - }, - "needed": { - "type": "boolean", - "description": "False once public certificates are set up: nothing to trust by hand." - }, - "httpPort": { - "type": "integer", - "description": "The host port of the plain-HTTP trust listener." - }, - "proxyName": { - "type": "string", - "example": "proxy.apps.lan" - }, - "fingerprint": { - "type": "string", - "description": "SHA-256 of the root certificate, to check against what a device shows." - }, - "rootUrl": { - "type": "string", - "const": "/trust/root.crt" - }, - "notAfter": { - "type": "string", - "format": "date-time", - "description": "When the root expires." - } - } - }, - "Uptime": { - "type": "object", - "required": [ - "state", - "hours" - ], - "properties": { - "state": { - "type": "string", - "enum": [ - "up", - "down", - "unknown" - ], - "description": "down after two failed checks in a row; unknown until it has answered once" - }, - "since": { - "type": "string", - "format": "date-time", - "description": "when the current state began (down: the first failed check)" - }, - "never": { - "type": "boolean", - "description": "down and never seen answering since it was added: no alert is sent for it" - }, - "day": { - "type": [ - "number", - "null" - ], - "description": "percent of checks answered in the last 24 hours; null when none ran" - }, - "week": { - "type": [ - "number", - "null" - ] - }, - "month": { - "type": [ - "number", - "null" - ] - }, - "avgMs": { - "type": "integer", - "description": "average connect time over the last 24 hours" - }, - "hours": { - "type": "array", - "items": { - "type": "array", - "description": "[hour start (unix seconds), checks, checks answered, sum of the answered checks' connect times in ms]", - "items": { - "type": "integer" - }, - "minItems": 4, - "maxItems": 4 - } - }, - "from": { - "type": "string", - "format": "date-time", - "description": "start of the oldest hour kept; the percentages cover no more than that" - } - } - } - } - } -} diff --git a/admin/internal/server/pair.go b/admin/internal/server/pair.go deleted file mode 100644 index 56f77bc..0000000 --- a/admin/internal/server/pair.go +++ /dev/null @@ -1,233 +0,0 @@ -package server - -import ( - "net/http" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/auth" - "github.com/daemonless/reverse-proxy/admin/internal/ca" - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// pairCodeLife is how long a code from "Link an app…" stays good. -const pairCodeLife = 10 * time.Minute - -// newPairCode is POST /api/v1/pair/code: a one-time code and the link that -// carries it (https://proxy./link#code=…), for the person to paste -// into the app that links. Ten minutes, once. -func (s *Server) newPairCode(w http.ResponseWriter, r *http.Request) { - code := auth.NewSetupToken() - now := time.Now() - s.mu.Lock() - if s.pairCodes == nil { - s.pairCodes = map[string]time.Time{} - } - for c, exp := range s.pairCodes { - if now.After(exp) { - delete(s.pairCodes, c) - } - } - s.pairCodes[code] = now.Add(pairCodeLife) - s.mu.Unlock() - // The link names this proxy the way the admin reached it (an address, - // or the name): the app may not resolve proxy. yet, and - // an address it can always reach. The proxy's own name comes back when - // the code is redeemed, for the pin. - writeJSON(w, http.StatusCreated, map[string]any{ - "code": code, - "link": "https://" + r.Host + "/link#code=" + code, - "expiresAt": now.Add(pairCodeLife).UTC(), - }) -} - -// pair is POST /api/v1/pair {code, client:{id,name,url}}: an app (fjord, -// Sylve, anything with the code) redeems a code for a key of its own, and learns what it needs to trust -// this proxy. The code proves the person had the admin page open a moment -// ago; a wrong one counts against the caller like a wrong password. -func (s *Server) pair(w http.ResponseWriter, r *http.Request) { - if s.tooMany(w, r) { - return - } - var in struct { - Code string `json:"code"` - Client struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - } `json:"client"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - code := strings.ToUpper(strings.TrimSpace(in.Code)) - s.mu.Lock() - exp, ok := s.pairCodes[code] - if ok { - delete(s.pairCodes, code) - } - s.mu.Unlock() - if !ok || time.Now().After(exp) { - s.limiter.Fail(clientAddr(r)) - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "that code is not right, or it has expired; make a new one under Connected apps", "field": "code"}) - return - } - s.limiter.Succeed(clientAddr(r)) - name := strings.TrimSpace(in.Client.Name) - if name == "" || len(name) > 64 { - name = "Linked app" - } - id := newID() - secret := auth.NewSecret(32) - t := state.ApiToken{ID: id, Label: name, Hash: auth.HashSecret(secret), CreatedAt: time.Now().UTC(), - URL: cleanPairingURL(in.Client.URL), ClientID: cleanClientID(in.Client.ID)} - if err := s.mgr.Apply(func(st *state.State) error { st.Tokens = relinked(st.Tokens, t); return nil }); err != nil { - writeError(w, err) - return - } - st := s.mgr.State() - out := map[string]any{ - "key": "rp_" + id + "_" + secret, - "domain": st.Settings.Domain, - "needed": !(st.Settings.Mode() == state.ModePublic && st.Settings.Certificates != nil), - } - if st.Settings.Domain != "" { - out["proxyName"] = caddy.ProxyName(st.Settings) - } - if root, err := ca.Load(s.secrets); err == nil && root.Domain == st.Settings.Domain { - out["fingerprint"] = root.Fingerprint - out["rootUrl"] = "/trust/root.crt" - } - writeJSON(w, http.StatusCreated, out) -} - -// relinked adds t, the key a code just gave an app, in place of the keys -// that app had (same ClientID): it holds only the new one, and an old one -// left behind showed the app as disconnected, or twice. The new key takes -// over "installed this proxy". -func relinked(tokens []state.ApiToken, t state.ApiToken) []state.ApiToken { - out := tokens[:0:0] - for _, x := range tokens { - if t.ClientID != "" && x.ClientID == t.ClientID { - t.Paired = t.Paired || x.Paired - if t.URL == "" { - t.URL = x.URL - } - continue - } - out = append(out, x) - } - return append(out, t) -} - -// fitTokens keeps one key per app: the one it used last. A relink before -// relinked, or a key that told /me an id another key had, left two. -func fitTokens(st *state.State) bool { - used := func(t state.ApiToken) time.Time { - if t.LastUsedAt != nil { - return *t.LastUsedAt - } - return t.CreatedAt - } - latest := map[string]int{} - for i, t := range st.Tokens { - if t.ClientID == "" { - continue - } - if j, ok := latest[t.ClientID]; !ok || used(t).After(used(st.Tokens[j])) { - latest[t.ClientID] = i - } - } - changed := false - for i := 0; i < len(st.Tokens); i++ { - t := st.Tokens[i] - if t.ClientID == "" || latest[t.ClientID] == i { - continue - } - k := latest[t.ClientID] - st.Tokens[k].Paired = st.Tokens[k].Paired || t.Paired - if st.Tokens[k].URL == "" { - st.Tokens[k].URL = t.URL - } - st.Tokens = append(st.Tokens[:i], st.Tokens[i+1:]...) - for id, j := range latest { - if j > i { - latest[id] = j - 1 - } - } - i-- - changed = true - } - return changed -} - -// me is PUT /api/v1/me: the app behind the key says who it is (id, name, -// address), so an installer paired before ids existed, or one that was -// renamed, shows up like every other app. Only what is sent changes. -func (s *Server) me(w http.ResponseWriter, r *http.Request) { - id, ok := s.callerToken(r) - if !ok { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "this is for an app's key; the account has nothing to say here"}) - return - } - var in struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - clientID, name, u := cleanClientID(in.ID), cleanLabel(in.Name), cleanPairingURL(in.URL) - changed := false - for _, t := range s.mgr.State().Tokens { - if t.ID == id { - changed = clientID != "" && clientID != t.ClientID || name != "" && name != t.Label || u != "" && u != t.URL - } - } - if changed { - err := s.mgr.Note(func(st *state.State) { - for i := range st.Tokens { - if st.Tokens[i].ID != id { - continue - } - if clientID != "" { - st.Tokens[i].ClientID = clientID - } - if name != "" { - st.Tokens[i].Label = name - } - if u != "" { - st.Tokens[i].URL = u - } - } - }) - if err != nil { - writeError(w, err) - return - } - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "changed": changed}) -} - -// callerToken is the id of the key a request carries. -func (s *Server) callerToken(r *http.Request) (string, bool) { - h := r.Header.Get("Authorization") - if !strings.HasPrefix(h, "Bearer ") { - return "", false - } - return s.tokenValid(strings.TrimPrefix(h, "Bearer ")) -} - -// cleanLabel is a name fit for the Connected apps list: one line, trimmed, -// at most 64 characters, or nothing. -func cleanLabel(name string) string { - name = strings.Join(strings.Fields(name), " ") - if len(name) > 64 { - return "" - } - return name -} diff --git a/admin/internal/server/pair_test.go b/admin/internal/server/pair_test.go deleted file mode 100644 index d45427a..0000000 --- a/admin/internal/server/pair_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package server - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// A code from the admin, redeemed once by a fjord elsewhere, gives it a key -// that works on the hosts API; the code is dead after that, and a wrong one -// is refused. -func TestPairCodeGivesAKeyOnce(t *testing.T) { - s, c, _ := setUp(t) - rr, made := c.do("POST", "/api/v1/pair/code", "") - if rr.Code != 201 { - t.Fatalf("code: %d %s", rr.Code, rr.Body.String()) - } - code, _ := made["code"].(string) - link, _ := made["link"].(string) - if code == "" || !strings.Contains(link, "/link#code="+code) { - t.Fatalf("code/link: %v", made) - } - body := `{"code":"` + strings.ToLower(code) + `","client":{"id":"k7f3a9","name":"fjord on venus","url":"http://venus:3567/"}}` - anon := &client{t: t, s: s} - rr, got := anon.do("POST", "/api/v1/pair", body) - if rr.Code != 201 { - t.Fatalf("pair: %d %s", rr.Code, rr.Body.String()) - } - key, _ := got["key"].(string) - if !strings.HasPrefix(key, "rp_") || got["proxyName"] != "proxy.apps.test" { - t.Fatalf("pair answer: %v", got) - } - api := &client{t: t, s: s, bearer: key} - if rr, _ := api.do("GET", "/api/v1/hosts", ""); rr.Code != 200 { - t.Fatalf("new key on hosts: %d", rr.Code) - } - var tk map[string]any - for _, x := range s.mgr.State().Tokens { - if x.ClientID == "k7f3a9" { - b, _ := json.Marshal(x) - json.Unmarshal(b, &tk) - } - } - if tk == nil || tk["label"] != "fjord on venus" || tk["url"] != "http://venus:3567" { - t.Fatalf("token: %v", tk) - } - if rr, _ := anon.do("POST", "/api/v1/pair", body); rr.Code != 400 { - t.Fatalf("code reused: %d", rr.Code) - } - if rr, _ := anon.do("POST", "/api/v1/pair", `{"code":"NOPE-NOPE","client":{}}`); rr.Code != 400 { - t.Fatalf("wrong code: %d", rr.Code) - } -} - -// The installer, disconnected and then linked again with a code (same fjord -// id): its new key replaces the old one and keeps "installed this proxy", -// so it is not shown as disconnected, or twice. -func TestRelinkReplacesTheOldKey(t *testing.T) { - s, c, _ := setUp(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - if _, err := RegisterPairingKey(s.mgr, key, "fjord on jupiter", "http://jupiter:3567", "e815d9467dce"); err != nil { - t.Fatal(err) - } - old := s.mgr.State().Tokens[1].ID - if rr, _ := c.do("PUT", "/api/v1/tokens/"+old, `{"disabled":true}`); rr.Code != 200 { - t.Fatalf("disconnect: %d", rr.Code) - } - _, made := c.do("POST", "/api/v1/pair/code", "") - anon := &client{t: t, s: s} - _, got := anon.do("POST", "/api/v1/pair", `{"code":"`+made["code"].(string)+`","client":{"id":"e815d9467dce","name":"fjord on jupiter"}}`) - newKey, _ := got["key"].(string) - var mine []state.ApiToken - for _, x := range s.mgr.State().Tokens { - if x.ClientID == "e815d9467dce" { - mine = append(mine, x) - } - } - if len(mine) != 1 || !mine[0].Paired || mine[0].Disabled || mine[0].URL != "http://jupiter:3567" { - t.Fatalf("tokens after relink: %+v", mine) - } - if rr, _ := (&client{t: t, s: s, bearer: newKey}).do("GET", "/api/v1/hosts", ""); rr.Code != 200 { - t.Fatalf("new key: %d", rr.Code) - } - if rr, _ := (&client{t: t, s: s, bearer: key}).do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("old key still works: %d", rr.Code) - } -} - -// State from before the fix: the disconnected installer key beside the key -// the same fjord relinked with. The one it uses stays, as the installer. -func TestFitTokensKeepsTheKeyInUse(t *testing.T) { - at := func(h int) *time.Time { x := time.Date(2026, 10, 28, h, 0, 0, 0, time.UTC); return &x } - st := &state.State{Tokens: []state.ApiToken{ - {ID: "script", Label: "script"}, - {ID: "old", ClientID: "e815d9467dce", Paired: true, Disabled: true, URL: "http://jupiter:3567", LastUsedAt: at(20)}, - {ID: "new", ClientID: "e815d9467dce", LastUsedAt: at(23)}, - }} - if !fitTokens(st) { - t.Fatal("nothing changed") - } - if len(st.Tokens) != 2 || st.Tokens[1].ID != "new" || !st.Tokens[1].Paired || st.Tokens[1].Disabled || st.Tokens[1].URL != "http://jupiter:3567" { - t.Fatalf("tokens: %+v", st.Tokens) - } - if fitTokens(st) { - t.Fatal("changed again") - } -} diff --git a/admin/internal/server/pairing_test.go b/admin/internal/server/pairing_test.go deleted file mode 100644 index 0d25e19..0000000 --- a/admin/internal/server/pairing_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package server - -import ( - "strings" - "testing" - - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -func TestPairingKey(t *testing.T) { - s, _ := newTestServer(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - if _, err := RegisterPairingKey(s.mgr, "short", "fjord", "", ""); err == nil { - t.Fatal("a short key was accepted") - } - added, err := RegisterPairingKey(s.mgr, key, "fjord on nas", "http://nas:3567/", "k7f3a9") - if err != nil || !added { - t.Fatalf("first registration: added=%v err=%v", added, err) - } - if added, _ := RegisterPairingKey(s.mgr, key, "", "javascript:alert(1)", "bad id!"); added { - t.Fatal("the same key was added twice") - } - // The app's address rides along, cleaned: a link, trailing slash off; - // anything that is not http(s) is dropped rather than put in a link. - if tk := s.mgr.State().Tokens[0]; tk.URL != "http://nas:3567" || tk.ClientID != "k7f3a9" || tk.Label != "fjord on nas" { - t.Fatalf("pairing token: %+v", tk) - } - - c := &client{t: t, s: s, bearer: key} - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 200 { - t.Fatalf("paired key as bearer: %d", rr.Code) - } - // Using the key is what makes it "connected" on the Settings page. - if tk := s.mgr.State().Tokens[0]; tk.LastUsedAt == nil { - t.Fatal("a key that was just used has no last use") - } - // Sent in the rp__ shape it must not match. - id := s.mgr.State().Tokens[0].ID - c.bearer = "rp_" + id + "_" + key - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("paired key in rp_ form: %d, want 401", rr.Code) - } - - // Removed by the person: the variable is still set, it stays removed. - if err := s.mgr.Apply(func(st *state.State) error { st.Tokens = nil; return nil }); err != nil { - t.Fatal(err) - } - if added, _ := RegisterPairingKey(s.mgr, key, "", "javascript:alert(1)", "bad id!"); added { - t.Fatal("a removed key came back") - } - c.bearer = key - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("removed key still works: %d", rr.Code) - } -} - -func TestCreateHostRefTaken(t *testing.T) { - s, _ := newTestServer(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - if _, err := RegisterPairingKey(s.mgr, key, "fjord", "", ""); err != nil { - t.Fatal(err) - } - if err := s.mgr.Apply(func(st *state.State) error { st.Settings.Domain = "apps.lan"; return nil }); err != nil { - t.Fatal(err) - } - c := &client{t: t, s: s, bearer: key} - body := `{"name":"radarr","target":{"address":"192.168.1.5","port":7878},"ref":"fjord:nas:radarr"}` - rr, first := c.do("POST", "/api/v1/hosts", body) - if rr.Code != 201 { - t.Fatalf("create: %d", rr.Code) - } - rr, again := c.do("POST", "/api/v1/hosts", `{"name":"radarr2","target":{"address":"192.168.1.5","port":7878},"ref":"fjord:nas:radarr"}`) - if rr.Code != 409 || again["id"] != first["id"] { - t.Fatalf("same ref: %d %v, want 409 with id %v", rr.Code, again["id"], first["id"]) - } -} - -func TestDisconnectAndReconnect(t *testing.T) { - s, _ := newTestServer(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - if _, err := RegisterPairingKey(s.mgr, key, "fjord", "", ""); err != nil { - t.Fatal(err) - } - id := s.mgr.State().Tokens[0].ID - c := &client{t: t, s: s, bearer: key} - - // A key cannot touch keys, its own included. - if rr, _ := c.do("PUT", "/api/v1/tokens/"+id, `{"disabled":true}`); rr.Code != 403 { - t.Fatalf("key disabling a key: %d, want 403", rr.Code) - } - // Disconnect (the admin, in the UI): the key is refused, and status - // says so (401, not the public view), but it is kept. - if err := s.mgr.Apply(func(st *state.State) error { st.Tokens[0].Disabled = true; return nil }); err != nil { - t.Fatal(err) - } - if rr, _ := c.do("GET", "/api/v1/status", ""); rr.Code != 401 { - t.Fatalf("status with a disconnected key: %d, want 401", rr.Code) - } - if len(s.mgr.State().Tokens) != 1 || !s.mgr.State().Tokens[0].Disabled { - t.Fatal("the key was not kept, turned off") - } - // Reconnect (the admin's session in the UI): accepted again. - if err := s.mgr.Apply(func(st *state.State) error { st.Tokens[0].Disabled = false; return nil }); err != nil { - t.Fatal(err) - } - if rr, _ := c.do("GET", "/api/v1/status", ""); rr.Code != 200 { - t.Fatalf("status after reconnect: %d", rr.Code) - } -} - -// The Settings page says how many names each connected app made: a fjord -// by its id in the refs; an installer from before ids by what is left. -func TestNamesFromCountsByClientID(t *testing.T) { - st := &state.State{} - st.Tokens = []state.ApiToken{ - {ID: "inst", Label: "fjord", Paired: true}, - {ID: "link", Label: "fjord on server-2", ClientID: "9941c5bf65b2"}, - {ID: "key", Label: "script"}, - } - st.Hosts = []state.Host{ - {ID: "1", Name: "zensical", Ref: "fjord:nas:zensical"}, - {ID: "2", Name: "spike-web", Ref: "fjord:9941c5bf65b2:spike-web"}, - {ID: "3", Name: "router", Ref: ""}, - } - got := namesFrom(st) - if got["inst"] != 1 || got["link"] != 1 || got["key"] != 0 { - t.Fatalf("names from each app: %v", got) - } - // The installer learned its id later: its old refs are still its own. - st.Tokens[0].ClientID = "4cc0fb41d2a0" - if got := namesFrom(st); got["inst"] != 1 { - t.Fatalf("installer with an id: %v", got) - } -} - -// Any app's names count, not only fjord's: by its id in the ref whatever -// the ref's first part, and by the key that made the name for an app that -// never said an id. -func TestNamesFromCountsAnyApp(t *testing.T) { - st := &state.State{} - st.Tokens = []state.ApiToken{ - {ID: "sylve", Label: "Sylve on nas", ClientID: "b7e2"}, - {ID: "script", Label: "my script"}, - } - st.Hosts = []state.Host{ - {ID: "1", Name: "jellyfin", Ref: "sylve:b7e2:jail-104"}, - {ID: "2", Name: "home-vm", Ref: "sylve:b7e2:vm-7", By: "gone"}, - {ID: "3", Name: "printer", Ref: "printer", By: "script"}, - {ID: "4", Name: "nas", By: "script"}, - {ID: "5", Name: "router", By: "deleted-key"}, - } - got := namesFrom(st) - if got["sylve"] != 2 || got["script"] != 2 || len(got) != 2 { - t.Fatalf("names from each app: %v", got) - } -} - -// An app tells the proxy who it is with its key; the account cannot. -func TestMeUpdatesTheCaller(t *testing.T) { - s, admin, _ := setUp(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - if _, err := RegisterPairingKey(s.mgr, key, "fjord", "", ""); err != nil { - t.Fatal(err) - } - c := &client{t: t, s: s, bearer: key} - rr, out := c.do("PUT", "/api/v1/me", `{"id":"4cc0fb41d2a0","name":"fjord on nas","url":"http://nas:3567/"}`) - if rr.Code != 200 || out["changed"] != true { - t.Fatalf("me: %d %v", rr.Code, out) - } - find := func() state.ApiToken { - for _, tk := range s.mgr.State().Tokens { - if tk.Paired { - return tk - } - } - t.Fatal("no paired token") - return state.ApiToken{} - } - if tk := find(); tk.ClientID != "4cc0fb41d2a0" || tk.Label != "fjord on nas" || tk.URL != "http://nas:3567" { - t.Fatalf("token after me: %+v", tk) - } - // Said again, nothing new: nothing written. - if rr, out := c.do("PUT", "/api/v1/me", `{"id":"4cc0fb41d2a0","name":"fjord on nas"}`); rr.Code != 200 || out["changed"] != false { - t.Fatalf("me again: %d %v", rr.Code, out) - } - // A bad id or an overlong name changes nothing of that field. - c.do("PUT", "/api/v1/me", `{"id":"not valid!","name":"`+strings.Repeat("x", 80)+`"}`) - if tk := find(); tk.ClientID != "4cc0fb41d2a0" || tk.Label != "fjord on nas" { - t.Fatalf("token after bad me: %+v", tk) - } - if rr, _ := admin.do("PUT", "/api/v1/me", `{"name":"x"}`); rr.Code != 403 { - t.Fatalf("the account: %d, want 403", rr.Code) - } -} - -// Before setup the public status names the app that installed the proxy, -// so Verify can send people back to it (its Finish setup opens setup -// verified); after setup it is gone, and the key never shows. -func TestStatusNamesInstallerBeforeSetup(t *testing.T) { - s, _ := newTestServer(t) - key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - if _, err := RegisterPairingKey(s.mgr, key, "fjord on server", "http://server:3567", "e815d9"); err != nil { - t.Fatal(err) - } - s.mgr.Note(func(st *state.State) { st.Settings.AdminPasswordHash = "" }) - rr, out := (&client{t: t, s: s}).do("GET", "/api/v1/status", "") - by, _ := out["installedBy"].(map[string]any) - if rr.Code != 200 || by["name"] != "fjord on server" || by["url"] != "http://server:3567" { - t.Fatalf("before setup: %d %v", rr.Code, out["installedBy"]) - } - if strings.Contains(rr.Body.String(), key) { - t.Fatal("the key is in the public status") - } - s.mgr.Note(func(st *state.State) { st.Settings.AdminPasswordHash = "x" }) - if _, out := (&client{t: t, s: s}).do("GET", "/api/v1/status", ""); out["installedBy"] != nil { - t.Fatalf("after setup: %v", out["installedBy"]) - } -} - -// A name made with a key counts for that app under Connected apps, whatever -// its ref; one made by hand counts for nobody. -func TestHostRemembersTheKeyThatMadeIt(t *testing.T) { - s, admin, key := setUp(t) - api := &client{t: t, s: s, bearer: key} - if rr, out := api.do("POST", "/api/v1/hosts", `{"name":"jellyfin","target":{"address":"10.0.0.5","port":8096},"ref":"sylve:b7e2:jail-104"}`); rr.Code != 201 { - t.Fatalf("create with a key: %d %v", rr.Code, out) - } - if rr, out := admin.do("POST", "/api/v1/hosts", `{"name":"router","target":{"address":"10.0.0.1","port":443}}`); rr.Code != 201 { - t.Fatalf("create by hand: %d %v", rr.Code, out) - } - _, out := admin.do("GET", "/api/v1/tokens", "") - toks, _ := out["tokens"].([]any) - if len(toks) != 1 { - t.Fatalf("tokens: %v", out) - } - if n := toks[0].(map[string]any)["names"]; n != float64(1) { - t.Fatalf("names from the key: %v, want 1", n) - } -} diff --git a/admin/internal/server/server.go b/admin/internal/server/server.go deleted file mode 100644 index 1b7f7d2..0000000 --- a/admin/internal/server/server.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package server is the admin's HTTP surface: UI and API, served on a unix -// socket behind Caddy. -package server - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "log" - "net" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/app" - "github.com/daemonless/reverse-proxy/admin/internal/auth" - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/certs" - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/netif" - "github.com/daemonless/reverse-proxy/admin/internal/state" - "github.com/daemonless/reverse-proxy/admin/internal/uptime" -) - -// Runner answers whether Caddy is up. -type Runner interface{ Running() bool } - -// Server serves the UI and API. -type Server struct { - // instance tells this proxy apart from another one answering for the - // same name (a second proxy with the same ending, an old DNS record). - instance string - // CheckProvider asks the DNS provider whether new credentials work - // before the trial starts; nil skips it (tests stay offline). - CheckProvider func(ctx context.Context, c *dns.Config, domain string) error - version string - caddy Runner - mgr *app.Manager - mux *http.ServeMux - sessions *auth.Sessions - limiter *auth.Limiter - - secrets string // directory holding the root CA files - // TrustPort is the host port that reaches the plain-HTTP trust listener - // (RP_TRUST_PORT). The container cannot see its own port mappings, so - // the trust page is told what to put in its commands and QR code. - TrustPort int - // CaddyVersion is what `caddy version` printed at startup (About page). - CaddyVersion string - // IssuanceLog is Caddy's certificate log (see caddy.Input); Observe - // does a TLS handshake with the proxy by name. Both are nil/empty in - // tests, which then see "no certificate yet". - IssuanceLog string - Observe func(ctx context.Context, serverName string) certs.Observation - // DNSRefused is Caddy's error when it refused the saved credentials at - // startup and the proxy runs without them; "" (or nil) otherwise. - DNSRefused func() string - // Networks lists the container's networks (netif.List when nil); - // ListenEnv is RP_LISTEN, which overrides the saved choice. - Networks func() []netif.Network - ListenEnv string - // Uptime is the background checker's history; nil in tests. - Uptime *uptime.Monitor - - mu sync.Mutex - alertErr string // the last alert that could not be sent - alertErrAt time.Time - tokenBad *dns.Problem // the last daily token check, when it failed - tokenAt time.Time // when tokenBad was found - setupTok string // one-time token from the log; empty once setup is done - touched map[string]time.Time // key id -> last time its use was written - // pairCodes are one-time codes (Connected apps, "Link an app…") with - // their expiry: an app elsewhere redeems one for a key. - pairCodes map[string]time.Time -} - -// New wires the routes. setupToken is the first-run token, or "" when a -// password already exists; secrets is where the root CA lives. -func New(version string, caddy Runner, mgr *app.Manager, setupToken, secrets string) *Server { - s := &Server{instance: newInstance(), version: version, caddy: caddy, mgr: mgr, mux: http.NewServeMux(), - sessions: auth.NewPersistentSessions(filepath.Join(secrets, "sessions.json")), limiter: auth.NewLimiter(), setupTok: setupToken, secrets: secrets, TrustPort: 80} - // Public: health, where-am-I, first run, sign in, the UI shell. - s.mux.HandleFunc("GET /healthz", s.healthz) - s.mux.HandleFunc("GET /api/v1/status", s.status) - s.mux.HandleFunc("GET /api/v1/session", s.session) - s.mux.HandleFunc("POST /api/v1/setup/token", s.setupToken) - s.mux.HandleFunc("POST /api/v1/setup/password", s.setupPassword) - s.mux.HandleFunc("POST /api/v1/login", s.login) - // Linking an app: the admin mints a code, the app redeems it for a - // key. Public like login, and limited like it. - s.mux.HandleFunc("POST /api/v1/pair/code", s.requireAdmin(s.newPairCode)) - s.mux.HandleFunc("POST /api/v1/pair", s.pair) - s.mux.HandleFunc("PUT /api/v1/me", s.requireAuth(s.me)) - s.mux.HandleFunc("POST /api/v1/logout", s.logout) - s.mux.HandleFunc("POST /api/v1/restore", s.restoreBackup) // guards itself: open only before setup - // The trust page and what it needs are public: a device that does not - // trust the proxy yet is exactly who needs them. - s.mux.HandleFunc("GET /trust/root.crt", s.rootCert) - s.mux.HandleFunc("GET /trust/root.der", s.rootDER) - s.mux.HandleFunc("GET /trust/qr.png", s.qr) - s.mux.HandleFunc("GET /trust/check", s.trustCheck) - s.mux.HandleFunc("GET /api/v1/trust", s.trustInfo) - s.mux.Handle("GET /", uiHandler()) - // Socket-only: the reset-password command. - s.mux.HandleFunc("POST /internal/reset", s.reset) - s.mux.HandleFunc("POST /internal/listen", s.internalListen) - s.mux.HandleFunc("GET /api/v1/openapi.json", openAPI) - // The hosts API (v1, see openapi.json): the account, the command line - // over the socket, or a connected app's key. - s.mux.HandleFunc("GET /api/v1/hosts", s.requireAuth(s.listHosts)) - s.mux.HandleFunc("GET /api/v1/hosts/health", s.requireAuth(s.hostsHealth)) - s.mux.HandleFunc("GET /api/v1/hosts/uptime", s.requireAuth(s.hostsUptime)) - s.mux.HandleFunc("GET /api/v1/hosts/{id}/uptime", s.requireAuth(s.hostUptime)) - s.mux.HandleFunc("POST /api/v1/hosts", s.requireAuth(s.createHost)) - s.mux.HandleFunc("GET /api/v1/hosts/{id}", s.requireAuth(s.getHost)) - s.mux.HandleFunc("PUT /api/v1/hosts/{id}", s.requireAuth(s.updateHost)) - s.mux.HandleFunc("DELETE /api/v1/hosts/{id}", s.requireAuth(s.deleteHost)) - s.mux.HandleFunc("POST /api/v1/hosts/{id}/restore", s.requireAuth(s.restoreHost)) - s.mux.HandleFunc("GET /api/v1/hosts/{id}/icon", s.requireAuth(s.hostIcon)) - s.mux.HandleFunc("PUT /api/v1/hosts/{id}/icon", s.requireAuth(s.putHostIcon)) - s.mux.HandleFunc("GET /api/v1/probe", s.requireAuth(s.probe)) - // The proxy's own settings: the account only (and the command line). - s.mux.HandleFunc("GET /api/v1/settings", s.requireAdmin(s.getSettings)) - s.mux.HandleFunc("PUT /api/v1/settings", s.requireAdmin(s.putSettings)) - s.mux.HandleFunc("PUT /api/v1/listen", s.requireAdmin(s.putListen)) - s.mux.HandleFunc("GET /api/v1/dns-check", s.requireAdmin(s.dnsCheck)) - s.mux.HandleFunc("GET /api/v1/suggest-domain", s.requireAdmin(s.suggestDomain)) - s.mux.HandleFunc("GET /api/v1/certificates", s.requireAdmin(s.getCertificates)) - s.mux.HandleFunc("GET /api/v1/certificates/detect", s.requireAdmin(s.detectProvider)) - s.mux.HandleFunc("PUT /api/v1/certificates", s.requireAdmin(s.putCertificates)) - s.mux.HandleFunc("DELETE /api/v1/certificates", s.requireAdmin(s.deleteCertificates)) - s.mux.HandleFunc("GET /api/v1/sources/fjord", s.requireAdmin(s.getFjordSource)) - s.mux.HandleFunc("PUT /api/v1/sources/fjord", s.requireAdmin(s.putFjordSource)) - s.mux.HandleFunc("GET /api/v1/sources/fjord/services", s.requireAdmin(s.fjordServices)) - s.mux.HandleFunc("GET /api/v1/alerts", s.requireAdmin(s.getAlerts)) - s.mux.HandleFunc("PUT /api/v1/alerts", s.requireAdmin(s.putAlerts)) - s.mux.HandleFunc("POST /api/v1/alerts/test", s.requireAdmin(s.testAlert)) - s.mux.HandleFunc("PUT /api/v1/uptime/enabled", s.requireAdmin(s.putUptimeEnabled)) - s.mux.HandleFunc("PUT /api/v1/password", s.requireAdmin(s.changePassword)) - s.mux.HandleFunc("POST /api/v1/backup", s.requireAdmin(s.createBackup)) - s.mux.HandleFunc("GET /api/v1/tokens", s.requireAdmin(s.listTokens)) - s.mux.HandleFunc("POST /api/v1/tokens", s.requireAdmin(s.createToken)) - s.mux.HandleFunc("DELETE /api/v1/tokens/{id}", s.requireAdmin(s.deleteToken)) - s.mux.HandleFunc("PUT /api/v1/tokens/{id}", s.requireAdmin(s.setTokenDisabled)) - // A name purged since the last start leaves its icon behind; an icon - // lost to a save race before iconMu is sent again once forgotten. - iconMu.Lock() - s.tidyIcons() - if st := s.mgr.State().Clone(); s.fitIcons(st) { - mgr.Note(func(next *state.State) { s.fitIcons(next) }) - } - iconMu.Unlock() - if st := s.mgr.State().Clone(); fitTokens(st) { - mgr.Note(func(next *state.State) { fitTokens(next) }) - } - return s -} - -// AdminAlive reports whether something answers on the admin socket: a -// running admin. Checked before anything on disk is touched, so a second -// start (the binary typed in a shell) changes nothing. -func AdminAlive(path string) bool { - c, err := net.DialTimeout("unix", path, time.Second) - if err != nil { - return false - } - c.Close() - return true -} - -// Handler exposes the mux for tests. -func (s *Server) Handler() http.Handler { return s.mux } - -// ServeUnix listens on a unix socket until ctx is done. The socket is -// group-accessible so Caddy (root, but any group member too) can reach it. -// A socket that answers belongs to a running admin: `reverse-proxy` typed -// in a shell with no command used to unlink it from under that admin and -// leave the container without one until a restart. -func (s *Server) ServeUnix(ctx context.Context, path string) error { - if AdminAlive(path) { - return fmt.Errorf("an admin is already serving %s; for the command line use `reverse-proxy hosts` or `reverse-proxy reset-password`", path) - } - _ = os.Remove(path) - ln, err := net.Listen("unix", path) - if err != nil { - return err - } - if err := os.Chmod(path, 0o660); err != nil { - ln.Close() - return err - } - srv := &http.Server{Handler: s.mux, ReadHeaderTimeout: 10 * time.Second} - errc := make(chan error, 1) - go func() { errc <- srv.Serve(ln) }() - select { - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return srv.Shutdown(shutdownCtx) - case err := <-errc: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err - } -} - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - json.NewEncoder(w).Encode(v) -} - -// writeError maps the error kinds the manager returns to plain responses: -// a field the person must fix, a configuration Caddy refused, or a fault. -func writeError(w http.ResponseWriter, err error) { - var fe *state.FieldError - var ce *app.CaddyError - switch { - case errors.As(err, &fe): - writeJSON(w, http.StatusBadRequest, map[string]any{"error": fe.Msg, "field": fe.Field}) - case errors.As(err, &ce): - log.Printf("[admin] %v", err) - writeJSON(w, http.StatusConflict, map[string]any{"error": "The change was not applied; the previous configuration is still running.", "detail": ce.Err.Error()}) - default: - log.Printf("[admin] %v", err) - writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "Something went wrong on the proxy; see its log."}) - } -} - -func decode(w http.ResponseWriter, r *http.Request, v any) error { - dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) - dec.DisallowUnknownFields() - if err := dec.Decode(v); err != nil { - return &state.FieldError{Field: "", Msg: fmt.Sprintf("request body: %v", err)} - } - return nil -} - -func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { - if !s.caddy.Running() { - http.Error(w, "caddy not running", http.StatusServiceUnavailable) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - fmt.Fprintln(w, "ok") -} - -// status is public but says little; the details need a session. -func (s *Server) status(w http.ResponseWriter, r *http.Request) { - // A key that is sent and refused is an answer the app needs (it was - // disconnected), not the public view. - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") && s.who(r) == nobody { - writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "this key is not accepted (disconnected?)"}) - return - } - st := s.mgr.State() - out := map[string]any{ - "product": "reverse-proxy", - "apiVersion": 1, - "version": s.version, - "caddy": map[string]any{"running": s.caddy.Running(), "version": s.CaddyVersion}, - "setup": map[string]any{"complete": st.Settings.AdminPasswordHash != ""}, - } - // Until setup is done, the app that installed the proxy (a paired key - // with a way back): the setup page sends people there to press Finish - // setup, which opens it verified, instead of into the log for a token. - // Its label and address only, never the key. - if st.Settings.AdminPasswordHash == "" { - for _, t := range st.Tokens { - if t.Paired && !t.Disabled && t.URL != "" { - out["installedBy"] = map[string]any{"name": t.Label, "url": t.URL} - break - } - } - } - if s.who(r) != nobody { - out["domain"] = st.Settings.Domain - if u := AdminURL(st); u != "" { - out["adminUrl"] = u - } - out["mode"] = st.Settings.Mode() - out["hosts"] = len(st.Live()) - out["certificates"] = s.certStatus(r.Context(), st).State - if st.Settings.Domain != "" { - out["proxyName"] = caddy.ProxyName(st.Settings) - port := "" - if s.TrustPort != 80 { - port = ":" + strconv.Itoa(s.TrustPort) - } - out["trustUrl"] = "http://" + caddy.ProxyName(st.Settings) + port + "/trust" - } - } - writeJSON(w, http.StatusOK, out) -} - -func newInstance() string { - b := make([]byte, 8) - if _, err := rand.Read(b); err != nil { - panic(err) - } - return hex.EncodeToString(b) -} diff --git a/admin/internal/server/server_test.go b/admin/internal/server/server_test.go deleted file mode 100644 index ff8a750..0000000 --- a/admin/internal/server/server_test.go +++ /dev/null @@ -1,496 +0,0 @@ -package server - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/app" - "github.com/daemonless/reverse-proxy/admin/internal/certs" - "github.com/daemonless/reverse-proxy/admin/internal/dns" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -type okLoader struct{} - -func (okLoader) Load(any) error { return nil } - -type upRunner struct{} - -func (upRunner) Running() bool { return true } - -func newTestServer(t *testing.T) (*Server, string) { - // Same layout as the container: state.json beside a secrets/ directory. - dir := t.TempDir() - mgr, err := app.New(state.NewStore(filepath.Join(dir, "state.json")), okLoader{}, func(*state.State) any { return map[string]any{} }) - if err != nil { - t.Fatal(err) - } - tok := "K7RT-9XPM-2WQF-HJ4N" - return New("test", upRunner{}, mgr, tok, filepath.Join(dir, "secrets")), tok -} - -type client struct { - t *testing.T - s *Server - cookies []*http.Cookie - bearer string - origin bool -} - -func (c *client) do(method, path, body string, hdr ...string) (*httptest.ResponseRecorder, map[string]any) { - req := httptest.NewRequest(method, "https://proxy.test"+path, strings.NewReader(body)) - req.Host = "proxy.test" - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Forwarded-For", "192.168.1.50") // as Caddy would - for _, ck := range c.cookies { - req.AddCookie(ck) - } - if c.bearer != "" { - req.Header.Set("Authorization", "Bearer "+c.bearer) - } - if c.origin { - req.Header.Set("Origin", "https://proxy.test") - } - for i := 0; i+1 < len(hdr); i += 2 { - req.Header.Set(hdr[i], hdr[i+1]) - } - rr := httptest.NewRecorder() - c.s.Handler().ServeHTTP(rr, req) - for _, ck := range rr.Result().Cookies() { - kept := c.cookies[:0] - for _, old := range c.cookies { - if old.Name != ck.Name { - kept = append(kept, old) - } - } - c.cookies = kept - if ck.MaxAge >= 0 && ck.Value != "" { - c.cookies = append(c.cookies, ck) - } - } - var out map[string]any - json.Unmarshal(rr.Body.Bytes(), &out) - return rr, out -} - -func TestFirstRunThenLogin(t *testing.T) { - s, tok := newTestServer(t) - c := &client{t: t, s: s, origin: true} - - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("hosts without auth: %d", rr.Code) - } - if _, out := c.do("GET", "/api/v1/session", ""); out["setupRequired"] != true { - t.Fatalf("session: %v", out) - } - if rr, _ := c.do("POST", "/api/v1/login", `{"password":"anything at all"}`); rr.Code != 409 { - t.Fatalf("login before setup: %d", rr.Code) - } - if rr, _ := c.do("POST", "/api/v1/setup/password", `{"password":"a long enough password"}`); rr.Code != 401 { - t.Fatalf("password without token: %d", rr.Code) - } - if rr, _ := c.do("POST", "/api/v1/setup/token", `{"token":"WRONG-WRONG-WRONG-WRON"}`); rr.Code != 400 { - t.Fatalf("wrong token: %d", rr.Code) - } - // Typed with spaces and lower case, as a person would. - if rr, out := c.do("POST", "/api/v1/setup/token", `{"token":"`+strings.ToLower(strings.ReplaceAll(tok, "-", " "))+`"}`); rr.Code != 200 { - t.Fatalf("right token: %d %v", rr.Code, out) - } - if rr, _ := c.do("POST", "/api/v1/setup/password", `{"password":""}`); rr.Code != 400 { - t.Fatalf("weak password: %d", rr.Code) - } - if rr, out := c.do("POST", "/api/v1/setup/password", `{"password":"a long enough password"}`); rr.Code != 200 { - t.Fatalf("set password: %d %v", rr.Code, out) - } - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 200 { - t.Fatalf("hosts after setup: %d", rr.Code) - } - if _, out := c.do("GET", "/api/v1/status", ""); out["domain"] == nil { - t.Fatalf("status for admin lacks details: %v", out) - } - if rr, _ := c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`); rr.Code != 409 { - t.Fatalf("setup token after setup: %d", rr.Code) - } - - // Sign out, sign back in. - c.do("POST", "/api/v1/logout", "") - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("hosts after logout: %d", rr.Code) - } - if rr, _ := c.do("POST", "/api/v1/login", `{"password":"nope nope nope"}`); rr.Code != 401 { - t.Fatalf("bad login: %d", rr.Code) - } - if rr, _ := c.do("POST", "/api/v1/login", `{"password":"a long enough password"}`); rr.Code != 200 { - t.Fatalf("good login: %d", rr.Code) - } - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 200 { - t.Fatalf("hosts after login: %d", rr.Code) - } - - // A cookie session must come from our own page for writes. - c.origin = false - if rr, _ := c.do("PUT", "/api/v1/settings", `{"domain":"x.lan"}`); rr.Code != 403 { - t.Fatalf("cross-site write: %d", rr.Code) - } - if rr, _ := c.do("PUT", "/api/v1/settings", `{"domain":"x.lan"}`, "Origin", "https://evil.example"); rr.Code != 403 { - t.Fatalf("foreign origin write: %d", rr.Code) - } - if rr, _ := c.do("PUT", "/api/v1/settings", `{"domain":"x.lan"}`, "Sec-Fetch-Site", "same-origin"); rr.Code != 200 { - t.Fatalf("same-origin write: %d", rr.Code) - } - if rr, _ := c.do("GET", "/api/v1/settings", ""); rr.Code != 200 { - t.Fatalf("read without origin: %d", rr.Code) - } - c.origin = true - - // Connected app tokens. - _, out := c.do("POST", "/api/v1/tokens", `{"label":"fjord"}`) - bearer, _ := out["token"].(string) - if !strings.HasPrefix(bearer, "rp_") { - t.Fatalf("token: %v", out) - } - api := &client{t: t, s: s, bearer: bearer} - if rr, _ := api.do("POST", "/api/v1/hosts", `{"name":"a","target":{"address":"1.2.3.4","port":80}}`); rr.Code != 201 { - t.Fatalf("bearer write (no origin needed): %d", rr.Code) - } - bad := &client{t: t, s: s, bearer: "rp_" + out["id"].(string) + "_wrongsecret"} - if rr, _ := bad.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("bad bearer: %d", rr.Code) - } - if rr, _ := c.do("DELETE", "/api/v1/tokens/"+out["id"].(string), ""); rr.Code != 200 { - t.Fatalf("delete token: %d", rr.Code) - } - if rr, _ := api.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("deleted bearer still works: %d", rr.Code) - } - - // Password change signs everyone out except the changer. - other := &client{t: t, s: s, origin: true} - other.do("POST", "/api/v1/login", `{"password":"a long enough password"}`) - if rr, _ := c.do("PUT", "/api/v1/password", `{"current":"wrong wrong wrong","new":"another long password"}`); rr.Code != 400 { - t.Fatalf("change with wrong current: %d", rr.Code) - } - if rr, _ := c.do("PUT", "/api/v1/password", `{"current":"a long enough password","new":"another long password"}`); rr.Code != 200 { - t.Fatalf("change password: %d", rr.Code) - } - if rr, _ := other.do("GET", "/api/v1/hosts", ""); rr.Code != 401 { - t.Fatalf("other session survived password change: %d", rr.Code) - } - if rr, _ := c.do("GET", "/api/v1/hosts", ""); rr.Code != 200 { - t.Fatalf("changer lost session: %d", rr.Code) - } - - // Reset is socket-only: a request Caddy forwarded is a 404; a direct - // one clears the password and yields a new token. - if rr, _ := c.do("POST", "/internal/reset", ""); rr.Code != 404 { - t.Fatalf("reset via caddy: %d", rr.Code) - } - req := httptest.NewRequest("POST", "http://admin/internal/reset", nil) - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - var res map[string]any - json.Unmarshal(rr.Body.Bytes(), &res) - if rr.Code != 200 || len(res["setupToken"].(string)) != 19 { - t.Fatalf("direct reset: %d %s", rr.Code, rr.Body.String()) - } - if _, out := c.do("GET", "/api/v1/session", ""); out["setupRequired"] != true || out["authenticated"] != false { - t.Fatalf("after reset: %v", out) - } -} - -func TestQRIsOnlyForTheTrustPage(t *testing.T) { - s, _ := newTestServer(t) - for u, want := range map[string]int{ - "http://192.168.1.5/trust": 200, - "https://proxy.x.lan/trust": 200, - "http://192.168.1.5/trust?x=1": 400, - "http://evil.example/anything": 400, - "javascript:alert(1)": 400, - "http://user:pw@192.168.1.5/trust": 400, - } { - req := httptest.NewRequest("GET", "https://proxy.test/trust/qr.png?u="+url.QueryEscape(u), nil) - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - if rr.Code != want { - t.Errorf("%s: %d, want %d", u, rr.Code, want) - } - if want == 200 && rr.Header().Get("Content-Type") != "image/png" { - t.Errorf("%s: content type %s", u, rr.Header().Get("Content-Type")) - } - } -} - -func TestBackupAndRestore(t *testing.T) { - a, tok := newTestServer(t) - c := &client{t: t, s: a, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"a long enough password"}`) - c.do("PUT", "/api/v1/settings", `{"domain":"x.lan"}`) - c.do("POST", "/api/v1/hosts", `{"name":"radarr","target":{"address":"1.2.3.4","port":80}}`) - if rr, _ := c.do("POST", "/api/v1/backup", `{"passphrase":"short"}`); rr.Code != 400 { - t.Fatalf("short passphrase: %d", rr.Code) - } - rr, _ := c.do("POST", "/api/v1/backup", `{"passphrase":"backup passphrase"}`) - if rr.Code != 200 || !strings.Contains(rr.Header().Get("Content-Disposition"), "reverse-proxy-x-lan-") { - t.Fatalf("backup: %d %s", rr.Code, rr.Header().Get("Content-Disposition")) - } - blob := rr.Body.Bytes() - - // A fresh server, before setup: restore is open. - b, _ := newTestServer(t) - var body bytes.Buffer - mw := multipart.NewWriter(&body) - mw.WriteField("passphrase", "backup passphrase") - fw, _ := mw.CreateFormFile("file", "x.rpbk") - fw.Write(blob) - mw.Close() - req := httptest.NewRequest("POST", "https://proxy.test/api/v1/restore", &body) - req.Header.Set("Content-Type", mw.FormDataContentType()) - rec := httptest.NewRecorder() - b.Handler().ServeHTTP(rec, req) - if rec.Code != 200 { - t.Fatalf("restore: %d %s", rec.Code, rec.Body.String()) - } - if _, out := (&client{t: t, s: b}).do("GET", "/api/v1/session", ""); out["setupRequired"] != false { - t.Fatalf("after restore: %v", out) - } - c2 := &client{t: t, s: b, origin: true} - if rr, _ := c2.do("POST", "/api/v1/login", `{"password":"a long enough password"}`); rr.Code != 200 { - t.Fatalf("login with restored password: %d", rr.Code) - } - if _, out := c2.do("GET", "/api/v1/hosts", ""); len(out["hosts"].([]any)) != 1 { - t.Fatalf("hosts after restore: %v", out) - } - // Once set up, restore needs a session. - req = httptest.NewRequest("POST", "https://proxy.test/api/v1/restore", strings.NewReader("")) - rec = httptest.NewRecorder() - b.Handler().ServeHTTP(rec, req) - if rec.Code != 401 { - t.Fatalf("restore after setup without session: %d", rec.Code) - } -} - -func TestLockout(t *testing.T) { - s, _ := newTestServer(t) - c := &client{t: t, s: s, origin: true} - var code int - for i := 0; i < 6; i++ { - rr, _ := c.do("POST", "/api/v1/setup/token", `{"token":"AAAA-AAAA-AAAA-AAAA"}`) - code = rr.Code - } - if code != 429 { - t.Fatalf("after repeated failures: %d", code) - } -} - -func TestCertificatesEndpoints(t *testing.T) { - s, tok := newTestServer(t) - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"correct horse battery"}`) - - // Private ending: nothing to set up. - c.do("PUT", "/api/v1/settings", `{"domain":"apps.lan","trustedNetworks":[]}`) - if rr, _ := c.do("PUT", "/api/v1/certificates", `{"provider":"cloudflare","fields":{"api_token":"t"}}`); rr.Code != 400 { - t.Fatalf("private ending accepted a provider: %d", rr.Code) - } - if _, out := c.do("GET", "/api/v1/trust", ""); out["needed"] != true { - t.Fatalf("trust needed on a private ending: %v", out) - } - - c.do("PUT", "/api/v1/settings", `{"domain":"home.example.com","trustedNetworks":[]}`) - if _, out := c.do("GET", "/api/v1/certificates", ""); out["status"].(map[string]any)["state"] != "manual" || out["provider"] != nil { - t.Fatalf("before: %v", out) - } - if rr, out := c.do("PUT", "/api/v1/certificates", `{"provider":"cloudflare","fields":{"api_token":" "}}`); rr.Code != 400 || out["field"] != "api_token" { - t.Fatalf("empty token: %d %v", rr.Code, out) - } - rr, out := c.do("PUT", "/api/v1/certificates", `{"provider":"cloudflare","fields":{"api_token":" s3cr3t-xyz "}}`) - if rr.Code != 200 || out["provider"] != "cloudflare" || out["status"].(map[string]any)["state"] != "trial" { - t.Fatalf("put: %d %v", rr.Code, out) - } - if _, out := c.do("GET", "/api/v1/trust", ""); out["needed"] != false { - t.Fatalf("trust still needed: %v", out) - } - if _, out := c.do("GET", "/api/v1/status", ""); out["certificates"] != "trial" { - t.Fatalf("status: %v", out) - } - saved, err := dns.Load(s.secrets) - if err != nil || saved.Fields["api_token"] != "s3cr3t-xyz" { - t.Fatalf("secrets: %v %+v", err, saved) - } - if st := s.mgr.State(); st.Settings.Certificates == nil || st.Settings.Certificates.Stage != state.StageTrial { - t.Fatalf("state: %+v", st.Settings.Certificates) - } - // The credentials never come back out. - if _, out := c.do("GET", "/api/v1/certificates", ""); strings.Contains(fmt.Sprint(out), "s3cr3t") { - t.Fatalf("token leaked: %v", out) - } - - if rr, out := c.do("DELETE", "/api/v1/certificates", ""); rr.Code != 200 || out["provider"] != nil { - t.Fatalf("delete: %d %v", rr.Code, out) - } - if _, err := dns.Load(s.secrets); err == nil { - t.Fatal("secrets kept after delete") - } - if s.mgr.State().Settings.Certificates != nil { - t.Fatal("state kept after delete") - } -} - -type rejectLoader struct{ reject bool } - -func (l *rejectLoader) Load(any) error { - if l.reject { - return errors.New("400 Bad Request: provision dns.providers.cloudflare: API token 'bad' appears invalid") - } - return nil -} - -func TestCertificatesRejectedByCaddyIsPlainAndRolledBack(t *testing.T) { - dir := t.TempDir() - loader := &rejectLoader{} - mgr, err := app.New(state.NewStore(filepath.Join(dir, "state.json")), loader, func(*state.State) any { return map[string]any{} }) - if err != nil { - t.Fatal(err) - } - tok := "K7RT-9XPM-2WQF-HJ4N" - s := New("test", upRunner{}, mgr, tok, filepath.Join(dir, "secrets")) - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"correct horse battery"}`) - c.do("PUT", "/api/v1/settings", `{"domain":"home.example.com","trustedNetworks":[]}`) - loader.reject = true - rr, out := c.do("PUT", "/api/v1/certificates", `{"provider":"cloudflare","fields":{"api_token":"bad"}}`) - if rr.Code != 400 || !strings.Contains(fmt.Sprint(out["error"]), "rejected by Cloudflare") || out["detail"] == nil { - t.Fatalf("got %d %v", rr.Code, out) - } - if _, err := dns.Load(s.secrets); err == nil { - t.Fatal("secrets left behind after Caddy refused") - } - if s.mgr.State().Settings.Certificates != nil { - t.Fatal("state changed although Caddy refused") - } -} - -// The daily check: a token that stopped working shows while the -// certificate still does, and a new token clears it. -func TestTokenCheckWarnsEarly(t *testing.T) { - s, tok := newTestServer(t) - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"correct horse battery"}`) - c.do("PUT", "/api/v1/settings", `{"domain":"home.example.com","trustedNetworks":[]}`) - c.do("PUT", "/api/v1/certificates", `{"provider":"cloudflare","fields":{"api_token":"good"}}`) - s.mgr.Apply(func(st *state.State) error { st.Settings.Certificates.Stage = state.StageLive; return nil }) - s.Observe = func(context.Context, string) certs.Observation { - return certs.Observation{Issuer: certs.IssuerLetsEncrypt, NotAfter: time.Now().Add(60 * 24 * time.Hour)} - } - revoked := false - asked := 0 - s.CheckProvider = func(_ context.Context, cfg *dns.Config, domain string) error { - asked++ - if !revoked { - return nil - } - return &dns.Problem{Kind: dns.KindRejected, FieldError: &state.FieldError{Field: "api_token", Msg: "Cloudflare doesn't accept this token."}} - } - status := func() map[string]any { - _, out := c.do("GET", "/api/v1/certificates", "") - return out["status"].(map[string]any) - } - - s.checkToken(context.Background()) - if st := status(); st["state"] != "ok" || st["message"] != nil { - t.Fatalf("token fine: %v", st) - } - revoked = true - s.checkToken(context.Background()) - if st := status(); st["state"] != "ok" || st["fix"] != "token" || !strings.Contains(fmt.Sprint(st["message"]), "stopped accepting your token") { - t.Fatalf("revoked: %v", st) - } - before := asked - status() - if asked != before { - t.Fatal("reading the status asked the provider") - } - s.CheckProvider = func(context.Context, *dns.Config, string) error { return nil } - c.do("PUT", "/api/v1/certificates", `{"provider":"cloudflare","fields":{"api_token":"new"}}`) - if s.tokenBad != nil { - t.Fatal("a new token kept the old warning") - } -} - -// Short links: "go" until changed, "" is off, one word only, and saving the -// ending or networks (which do not send it) keeps what was chosen. -func TestShortLinksSetting(t *testing.T) { - s, tok := newTestServer(t) - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"correct horse battery"}`) - if _, out := c.do("PUT", "/api/v1/settings", `{"domain":"apps.lan","trustedNetworks":[]}`); out["shortLinks"] != "go" { - t.Fatalf("default: %v", out) - } - if rr, out := c.do("PUT", "/api/v1/settings", `{"domain":"apps.lan","trustedNetworks":[],"shortLinks":"go links"}`); rr.Code != 400 || out["field"] != "shortLinks" { - t.Fatalf("two words: %d %v", rr.Code, out) - } - if _, out := c.do("PUT", "/api/v1/settings", `{"domain":"apps.lan","trustedNetworks":[],"shortLinks":" Home "}`); out["shortLinks"] != "home" { - t.Fatalf("changed: %v", out) - } - if _, out := c.do("PUT", "/api/v1/settings", `{"domain":"apps.lan","trustedNetworks":["10.8.0.0/24"]}`); out["shortLinks"] != "home" { - t.Fatalf("kept: %v", out) - } - if _, out := c.do("PUT", "/api/v1/settings", `{"domain":"apps.lan","trustedNetworks":[],"shortLinks":""}`); out["shortLinks"] != "" { - t.Fatalf("off: %v", out) - } - if _, out := c.do("GET", "/api/v1/settings", ""); out["shortLinks"] != "" { - t.Fatalf("off after reload: %v", out) - } -} - -func TestLocalDomainSkipsTheEngines(t *testing.T) { - old := resolvConf - t.Cleanup(func() { resolvConf = old }) - for content, want := range map[string]string{ - "search dns.podman lan\nnameserver 10.88.0.1\n": "lan", - "domain Home.\n": "home", - "search svc.cluster.local cluster.local\n": "", - "nameserver 1.1.1.1\n": "", - } { - resolvConf = filepath.Join(t.TempDir(), "resolv.conf") - os.WriteFile(resolvConf, []byte(content), 0o644) - if got := localDomain(); got != want { - t.Errorf("%q: %q, want %q", content, got, want) - } - } -} - -// Suggested endings come from the network's search domains, never the -// container engine's own (apps.dns.podman was offered, 2026-10-27). -func TestSuggestDomainSkipsTheEngines(t *testing.T) { - old := resolvConf - t.Cleanup(func() { resolvConf = old }) - resolvConf = filepath.Join(t.TempDir(), "resolv.conf") - os.WriteFile(resolvConf, []byte("search dns.podman lan\nnameserver 10.88.0.1\n"), 0o644) - s, tok := newTestServer(t) - c := &client{t: t, s: s, origin: true} - c.do("POST", "/api/v1/setup/token", `{"token":"`+tok+`"}`) - c.do("POST", "/api/v1/setup/password", `{"password":"correct horse battery"}`) - _, out := c.do("GET", "/api/v1/suggest-domain", "") - got := fmt.Sprint(out["suggestions"]) - if strings.Contains(got, "podman") || !strings.Contains(got, "apps.lan") { - t.Fatalf("suggestions: %s", got) - } -} diff --git a/admin/internal/server/serveunix_test.go b/admin/internal/server/serveunix_test.go deleted file mode 100644 index ca843dc..0000000 --- a/admin/internal/server/serveunix_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package server - -import ( - "context" - "net" - "path/filepath" - "strings" - "testing" - "time" -) - -// A second admin on a socket that answers is refused rather than unlinking -// the first one's socket. -func TestServeUnixRefusesALiveSocket(t *testing.T) { - path := filepath.Join(t.TempDir(), "admin.sock") - ln, err := net.Listen("unix", path) - if err != nil { - t.Fatal(err) - } - defer ln.Close() - go func() { - for { - c, err := ln.Accept() - if err != nil { - return - } - c.Close() - } - }() - s, _ := newTestServer(t) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - err = s.ServeUnix(ctx, path) - if err == nil || !strings.Contains(err.Error(), "already serving") { - t.Fatalf("second admin: %v", err) - } - if _, err := net.Dial("unix", path); err != nil { - t.Fatalf("the first admin's socket is gone: %v", err) - } -} diff --git a/admin/internal/server/sources.go b/admin/internal/server/sources.go deleted file mode 100644 index 8c0127f..0000000 --- a/admin/internal/server/sources.go +++ /dev/null @@ -1,100 +0,0 @@ -package server - -import ( - "net/http" - "net/url" - "strings" - - "github.com/daemonless/reverse-proxy/admin/internal/sources" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -func (s *Server) getFjordSource(w http.ResponseWriter, _ *http.Request) { - f := s.mgr.State().Settings.Sources.Fjord - if f == nil { - writeJSON(w, http.StatusOK, map[string]any{"url": "", "hasKey": false}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"url": f.URL, "hasKey": f.Key != ""}) -} - -// putFjordSource saves the connection. The key is write-only: an empty -// key in the request keeps the stored one; "-" clears it. -func (s *Server) putFjordSource(w http.ResponseWriter, r *http.Request) { - var in struct { - URL string `json:"url"` - Key string `json:"key"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - in.URL = strings.TrimRight(strings.TrimSpace(in.URL), "/") - err := s.mgr.Apply(func(st *state.State) error { - if in.URL == "" { - st.Settings.Sources.Fjord = nil - return nil - } - u, err := url.Parse(in.URL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return &state.FieldError{Field: "url", Msg: "enter fjord's address, like http://nas.lan:3567"} - } - f := st.Settings.Sources.Fjord - if f == nil { - f = &state.FjordSource{} - } - f.URL = in.URL - switch in.Key { - case "": - case "-": - f.Key = "" - default: - f.Key = strings.TrimSpace(in.Key) - } - st.Settings.Sources.Fjord = f - return nil - }) - if err != nil { - writeError(w, err) - return - } - s.getFjordSource(w, r) -} - -// fjordServices lists what fjord runs, marked with the host already made -// for each (by ref), so the picker can say "already added". -func (s *Server) fjordServices(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - f := st.Settings.Sources.Fjord - if f == nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "connect fjord in Settings first"}) - return - } - svcs, err := (sources.Fjord{URL: f.URL, Key: f.Key}).Services(r.Context()) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) - return - } - byRef := map[string]state.Host{} - for _, h := range st.Live() { - if h.Ref != "" { - byRef[h.Ref] = h - } - } - type row struct { - sources.Service - Added *string `json:"added,omitempty"` // host id when already added - AddedName string `json:"addedName,omitempty"` - } - out := make([]row, 0, len(svcs)) - for _, sv := range svcs { - rw := row{Service: sv} - if h, ok := byRef[sv.Ref]; ok { - id := h.ID - rw.Added = &id - rw.AddedName = h.Names(st.Settings)[0] - } - out = append(out, rw) - } - writeJSON(w, http.StatusOK, map[string]any{"services": out, "fjord": sources.HostOf(f.URL)}) -} diff --git a/admin/internal/server/static.go b/admin/internal/server/static.go deleted file mode 100644 index fd97a94..0000000 --- a/admin/internal/server/static.go +++ /dev/null @@ -1,58 +0,0 @@ -package server - -import ( - "io/fs" - "net/http" - "strings" - - "github.com/daemonless/reverse-proxy/admin/ui" -) - -// uiHandler serves the embedded Svelte app. Hashed assets are immutable; -// everything that is not a file is the app shell, so the client router owns -// the URL space outside /api. -func uiHandler() http.Handler { - dist, err := fs.Sub(ui.Files, "dist") - if err != nil { - panic(err) - } - files := http.FileServer(http.FS(dist)) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // The UI is first-party only. The trust check fetches the proxy's - // own name over both schemes, which connect-src must allow. - csp := "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' https: http:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" - w.Header().Set("Content-Security-Policy", csp) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - // Never HSTS on the admin: it is the way back in when a certificate - // changes, and HSTS would turn that warning into a wall. max-age=0 - // also clears the year earlier versions sent. - w.Header().Set("Strict-Transport-Security", "max-age=0") - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.NotFound(w, r) - return - } - if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/internal/") { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such endpoint"}) - return - } - p := strings.TrimPrefix(r.URL.Path, "/") - if p == "" { - p = "index.html" - } - if f, err := dist.Open(p); err == nil { - f.Close() - if strings.HasPrefix(p, "assets/") { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - } else { - w.Header().Set("Cache-Control", "no-cache") - } - files.ServeHTTP(w, r) - return - } - // A client-side route: hand over the shell. - w.Header().Set("Cache-Control", "no-cache") - r.URL.Path = "/" - files.ServeHTTP(w, r) - }) -} diff --git a/admin/internal/server/static_test.go b/admin/internal/server/static_test.go deleted file mode 100644 index 96e65d6..0000000 --- a/admin/internal/server/static_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package server - -import ( - "net/http/httptest" - "strings" - "testing" -) - -func TestUIShellAndRoutes(t *testing.T) { - s, _ := newTestServer(t) - for _, p := range []string{"/", "/hosts", "/hosts/abc", "/settings", "/setup"} { - req := httptest.NewRequest("GET", "https://proxy.test"+p, nil) - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - if rr.Code != 200 || !strings.Contains(rr.Body.String(), `
`) { - t.Fatalf("%s: %d %q", p, rr.Code, rr.Body.String()[:min(60, rr.Body.Len())]) - } - if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { - t.Fatalf("%s: Cache-Control %q", p, cc) - } - if csp := rr.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { - t.Fatalf("%s: CSP %q", p, csp) - } - } - req := httptest.NewRequest("GET", "https://proxy.test/favicon.svg", nil) - rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - if rr.Code != 200 || !strings.Contains(rr.Header().Get("Content-Type"), "svg") { - t.Fatalf("favicon: %d %s", rr.Code, rr.Header().Get("Content-Type")) - } - // API paths never fall through to the shell. - req = httptest.NewRequest("GET", "https://proxy.test/api/v1/nope", nil) - rr = httptest.NewRecorder() - s.Handler().ServeHTTP(rr, req) - if strings.Contains(rr.Body.String(), `
`) { - t.Fatal("unknown API path served the UI shell") - } -} diff --git a/admin/internal/server/trust.go b/admin/internal/server/trust.go deleted file mode 100644 index a9ef3e6..0000000 --- a/admin/internal/server/trust.go +++ /dev/null @@ -1,219 +0,0 @@ -package server - -import ( - "context" - "encoding/pem" - "net" - "net/http" - "net/url" - "os" - "strings" - "time" - - qrcode "github.com/skip2/go-qrcode" - - "github.com/daemonless/reverse-proxy/admin/internal/auth" - "github.com/daemonless/reverse-proxy/admin/internal/ca" - "github.com/daemonless/reverse-proxy/admin/internal/caddy" - "github.com/daemonless/reverse-proxy/admin/internal/state" -) - -// rootCert hands out the root certificate. It is public by design: trust -// is established by the fingerprint shown beside it, not by secrecy. -func (s *Server) rootCert(w http.ResponseWriter, r *http.Request) { - root, err := ca.Load(s.secrets) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no certificate yet; finish setup first"}) - return - } - pem, err := os.ReadFile(root.CertPath) - if err != nil { - writeError(w, err) - return - } - w.Header().Set("Content-Type", "application/x-x509-ca-cert") - w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(root.Domain, ".", "-")+`-proxy-root.crt"`) - w.Header().Set("Cache-Control", "no-cache") - w.Write(pem) -} - -// rootDER is the same root in binary form: its file hash equals the -// fingerprint, which is what Windows tooling can check. -func (s *Server) rootDER(w http.ResponseWriter, r *http.Request) { - root, err := ca.Load(s.secrets) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no certificate yet; finish setup first"}) - return - } - raw, err := os.ReadFile(root.CertPath) - if err != nil { - writeError(w, err) - return - } - block, _ := pem.Decode(raw) - if block == nil { - writeError(w, err) - return - } - w.Header().Set("Content-Type", "application/x-x509-ca-cert") - w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(root.Domain, ".", "-")+`-proxy-root.der"`) - w.Header().Set("Cache-Control", "no-cache") - w.Write(block.Bytes) -} - -// qr draws the trust page's address for a phone camera. Only our own trust -// page may be encoded, so this is not a QR service for anything else. -func (s *Server) qr(w http.ResponseWriter, r *http.Request) { - u, err := url.Parse(r.URL.Query().Get("u")) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/trust" || u.RawQuery != "" || u.User != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "only the trust page can be encoded"}) - return - } - png, err := qrcode.Encode(u.String(), qrcode.Medium, 320) - if err != nil { - writeError(w, err) - return - } - w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "no-cache") - w.Write(png) -} - -// trustCheck is the tiny HTTPS resource the trust page loads to learn -// whether this device trusts the proxy. Over plain HTTP it says so. -func (s *Server) trustCheck(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secure": r.Header.Get("X-Forwarded-Proto") == "https", "instance": s.instance}) -} - -// trustInfo is what the trust page shows: the domain, the proxy's name, -// the fingerprint and where to download the root. -func (s *Server) trustInfo(w http.ResponseWriter, _ *http.Request) { - st := s.mgr.State() - out := map[string]any{"domain": st.Settings.Domain, "mode": st.Settings.Mode(), "httpPort": s.TrustPort, "instance": s.instance} - // With public certificates on the way there is nothing to trust by hand. - out["needed"] = !(st.Settings.Mode() == state.ModePublic && st.Settings.Certificates != nil) - if st.Settings.Domain != "" { - out["proxyName"] = caddy.ProxyName(st.Settings) - } - if root, err := ca.Load(s.secrets); err == nil && root.Domain == st.Settings.Domain { - out["fingerprint"] = root.Fingerprint - out["rootUrl"] = "/trust/root.crt" - out["notAfter"] = root.NotAfter - } - writeJSON(w, http.StatusOK, out) -} - -// dnsCheck resolves a never-before-seen name under the domain from inside -// the container, which uses the same resolver the person's devices do on a -// typical network, and reports what came back so the UI can compare it -// with the address the person reached the proxy on. -func (s *Server) dnsCheck(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - if st.Settings.Domain == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "set the address first"}) - return - } - name := "check-" + auth.NewSecret(5) + "." + st.Settings.Domain - // Each lookup has its own limit: one slow answer (a resolver that - // forwards AAAA questions nobody answers) used to eat the whole budget - // and leave the go/ check with none (2026-10-27). - lookup := func(host string) ([]string, error) { - ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) - defer cancel() - return net.DefaultResolver.LookupHost(ctx, host) - } - addrs, err := lookup(name) - out := map[string]any{"name": name, "addresses": []string{}, "proxyAddresses": []string{}} - // The browser may have opened the UI by name; this tells it which - // address that name stands for. Before the record exists the proxy's - // own name does not resolve, so the name the page was opened by - // (netlab, from fjord's Finish setup) comes first: the record's - // address is what it shows (2026-10-27, "the proxy's IP address"). - var here []string - host := r.Host - if h, _, err := net.SplitHostPort(r.Host); err == nil { - host = h - } - if net.ParseIP(host) != nil { - here = []string{host} - } else if a, err := lookup(host); host != "" && err == nil { - here = a - } - if pa, perr := lookup(caddy.ProxyName(st.Settings)); perr == nil { - here = append(here, pa...) - } - if here != nil { - out["proxyAddresses"] = here - } - if err == nil { - out["addresses"] = addrs - } else if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { - out["notFound"] = true - } else { - out["error"] = err.Error() - } - // The short-link word as devices ask for it: under the network's own - // domain (go.lan), which is what the router answers for "go". - if word, local := st.Settings.ShortName(), localDomain(); word != "" && local != "" { - short := map[string]any{"name": word + "." + local, "addresses": []string{}} - if a, err := lookup(word + "." + local); err == nil { - short["addresses"] = a - } else if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { - short["notFound"] = true - } - out["short"] = short - } - writeJSON(w, http.StatusOK, out) -} - -// suggestDomain offers endings for the setup screen: the resolver's search -// domains (the container inherits the host's), the domain of the address -// the person opened the UI on, and home.arpa as the always-safe fallback. -func (s *Server) suggestDomain(w http.ResponseWriter, r *http.Request) { - var bases []string - seen := map[string]bool{} - add := func(d string) { - d = strings.Trim(strings.ToLower(d), ".") - if d == "" || d == "local" || d == "localdomain" || engineDomain(d) || seen[d] { - return - } - seen[d] = true - bases = append(bases, d) - } - if raw, err := os.ReadFile(resolvConf); err == nil { - for _, line := range strings.Split(string(raw), "\n") { - f := strings.Fields(line) - if len(f) > 1 && (f[0] == "search" || f[0] == "domain") { - for _, d := range f[1:] { - add(d) - } - } - } - } - if host := strings.TrimSpace(r.URL.Query().Get("host")); host != "" { - if net.ParseIP(host) != nil { - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) - defer cancel() - if names, err := net.DefaultResolver.LookupAddr(ctx, host); err == nil { - for _, n := range names { - if i := strings.Index(n, "."); i > 0 { - add(n[i+1:]) - } - } - } - } else if i := strings.Index(host, "."); i > 0 { - add(host[i+1:]) - } - } - var out []string - for _, b := range bases { - out = append(out, "apps."+b, "home."+b) - } - out = append(out, "apps.home.arpa") - if len(out) > 5 { - out = out[:5] - } - writeJSON(w, http.StatusOK, map[string]any{"suggestions": out}) -} diff --git a/admin/internal/server/uptime.go b/admin/internal/server/uptime.go deleted file mode 100644 index 01f188e..0000000 --- a/admin/internal/server/uptime.go +++ /dev/null @@ -1,185 +0,0 @@ -package server - -import ( - "context" - "log" - "net/http" - "net/url" - "strings" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/state" - "github.com/daemonless/reverse-proxy/admin/internal/uptime" -) - -// hostsUptime is the names list's view: each name's state, its percentages -// and the last 24 hours. -func (s *Server) hostsUptime(w http.ResponseWriter, _ *http.Request) { - out := map[string]uptime.Summary{} - if s.Uptime != nil { - out = s.Uptime.Summaries() - } - writeJSON(w, http.StatusOK, map[string]any{"uptime": out, "every": int(uptime.Every.Seconds()), "enabled": !s.mgr.State().Settings.UptimeOff}) -} - -func (s *Server) hostUptime(w http.ResponseWriter, r *http.Request) { - st := s.mgr.State() - if i := st.Find(r.PathValue("id")); i < 0 || st.Hosts[i].DeletedAt != nil { - writeJSON(w, http.StatusNotFound, map[string]any{"error": "no such host"}) - return - } - var d uptime.Detail - if s.Uptime != nil { - d, _ = s.Uptime.Detail(r.PathValue("id")) - } - if d.State == "" { - d.State = "unknown" - } - if d.Hours == nil { - d.Hours = []uptime.Hour{} - } - if d.Outages == nil { - d.Outages = []uptime.Outage{} - } - writeJSON(w, http.StatusOK, d) -} - -// alertsView never returns the URL itself: an ntfy topic or a webhook's -// path is as good as a password. Where it goes is enough to recognise it. -func (s *Server) alertsView() map[string]any { - st := s.mgr.State().Settings - a := st.Alerts - out := map[string]any{"kind": "", "to": "", "enabled": !st.UptimeOff} - if a != nil { - out["kind"] = a.Kind - if u, err := url.Parse(a.URL); err == nil { - out["to"] = u.Host - } - } - s.mu.Lock() - if s.alertErr != "" { - out["lastError"], out["lastErrorAt"] = s.alertErr, s.alertErrAt - } - s.mu.Unlock() - return out -} - -func (s *Server) getAlerts(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, s.alertsView()) -} - -type alertsInput struct { - Kind string `json:"kind"` - URL string `json:"url"` -} - -func (in alertsInput) check() (*state.Alerts, error) { - in.URL = strings.TrimSpace(in.URL) - if in.URL == "" { - return nil, nil - } - if in.Kind != uptime.KindNtfy && in.Kind != uptime.KindWebhook { - return nil, &state.FieldError{Field: "kind", Msg: "choose ntfy or webhook"} - } - u, err := url.Parse(in.URL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - msg := "enter the address to post to, like https://hooks.example.com/proxy" - if in.Kind == uptime.KindNtfy { - msg = "enter the topic's address, like https://ntfy.sh/my-proxy" - } - return nil, &state.FieldError{Field: "url", Msg: msg} - } - if in.Kind == uptime.KindNtfy && strings.Trim(u.Path, "/") == "" { - return nil, &state.FieldError{Field: "url", Msg: "add the topic: https://" + u.Host + "/your-topic"} - } - return &state.Alerts{Kind: in.Kind, URL: in.URL}, nil -} - -// putAlerts saves where alerts go; an empty url turns them off. -func (s *Server) putAlerts(w http.ResponseWriter, r *http.Request) { - var in alertsInput - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - a, err := in.check() - if err != nil { - writeError(w, err) - return - } - if err := s.mgr.Note(func(st *state.State) { st.Settings.Alerts = a }); err != nil { - writeError(w, err) - return - } - s.mu.Lock() - s.alertErr = "" - s.mu.Unlock() - writeJSON(w, http.StatusOK, s.alertsView()) -} - -// putUptimeEnabled turns the checks on or off for every name. -func (s *Server) putUptimeEnabled(w http.ResponseWriter, r *http.Request) { - var in struct { - Enabled bool `json:"enabled"` - } - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - if err := s.mgr.Note(func(st *state.State) { st.Settings.UptimeOff = !in.Enabled }); err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, s.alertsView()) -} - -// testAlert sends a test to the saved place, or to one being typed in -// before it is saved, and says what went wrong. -func (s *Server) testAlert(w http.ResponseWriter, r *http.Request) { - var in alertsInput - if err := decode(w, r, &in); err != nil { - writeError(w, err) - return - } - a, err := in.check() - if err != nil { - writeError(w, err) - return - } - if a == nil { - a = s.mgr.State().Settings.Alerts - } - if a == nil { - writeError(w, &state.FieldError{Field: "url", Msg: "enter where to send alerts first"}) - return - } - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - if err := uptime.Send(ctx, a.Kind, a.URL, uptime.Event{Example: true}); err != nil { - writeJSON(w, http.StatusBadGateway, map[string]any{"error": "The test was not delivered: " + err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"sent": true}) -} - -// Alert sends e to the saved place, if any, and keeps the last failure for -// Settings to show. Called by the uptime monitor, one goroutine per event. -func (s *Server) Alert(e uptime.Event) { - a := s.mgr.State().Settings.Alerts - if a == nil { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - title, _ := e.Text() - err := uptime.Send(ctx, a.Kind, a.URL, e) - s.mu.Lock() - defer s.mu.Unlock() - if err != nil { - log.Printf("[admin] alert %q not sent: %v", title, err) - s.alertErr, s.alertErrAt = err.Error(), time.Now().UTC() - return - } - log.Printf("[admin] alert %q sent", title) - s.alertErr = "" -} diff --git a/admin/internal/server/uptime_test.go b/admin/internal/server/uptime_test.go deleted file mode 100644 index 2edd973..0000000 --- a/admin/internal/server/uptime_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package server - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/daemonless/reverse-proxy/admin/internal/uptime" -) - -// The alert address is write-only: an ntfy topic is as good as a password. -func TestAlertsSettings(t *testing.T) { - s, c, _ := setUp(t) - if rr, out := c.do("PUT", "/api/v1/alerts", `{"kind":"ntfy","url":"https://ntfy.example"}`); rr.Code != 400 || out["field"] != "url" { - t.Fatalf("ntfy without a topic: %d %v", rr.Code, out) - } - if rr, out := c.do("PUT", "/api/v1/alerts", `{"kind":"mail","url":"https://x.example/y"}`); rr.Code != 400 || out["field"] != "kind" { - t.Fatalf("unknown kind: %d %v", rr.Code, out) - } - rr, out := c.do("PUT", "/api/v1/alerts", `{"kind":"ntfy","url":"https://ntfy.example/secret-topic"}`) - if rr.Code != 200 || out["to"] != "ntfy.example" || strings.Contains(rr.Body.String(), "secret-topic") { - t.Fatalf("saved: %d %s", rr.Code, rr.Body) - } - if a := s.mgr.State().Settings.Alerts; a == nil || a.URL != "https://ntfy.example/secret-topic" { - t.Fatalf("stored: %+v", a) - } - if rr, _ := c.do("GET", "/api/v1/alerts", ""); strings.Contains(rr.Body.String(), "secret-topic") { - t.Fatalf("the topic is shown: %s", rr.Body) - } - if rr, out := c.do("PUT", "/api/v1/alerts", `{"kind":"","url":""}`); rr.Code != 200 || out["kind"] != "" || s.mgr.State().Settings.Alerts != nil { - t.Fatalf("off: %d %v", rr.Code, out) - } -} - -func TestAlertTest(t *testing.T) { - _, c, _ := setUp(t) - var title, body string - hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - title = r.Header.Get("Title") - b, _ := io.ReadAll(r.Body) - body = string(b) - })) - defer hook.Close() - if rr, out := c.do("POST", "/api/v1/alerts/test", `{}`); rr.Code != 400 { - t.Fatalf("nothing saved: %d %v", rr.Code, out) - } - // Before saving: the address being typed in. - if rr, out := c.do("POST", "/api/v1/alerts/test", `{"kind":"ntfy","url":"`+hook.URL+`/topic"}`); rr.Code != 200 || title != "Test from the proxy" || !strings.HasPrefix(body, "Alerts work") { - t.Fatalf("test: %d %v %q %q", rr.Code, out, title, body) - } - refuse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "forbidden topic", 403) })) - defer refuse.Close() - if rr, out := c.do("POST", "/api/v1/alerts/test", `{"kind":"ntfy","url":"`+refuse.URL+`/topic"}`); rr.Code != 502 || !strings.Contains(out["error"].(string), "forbidden topic") { - t.Fatalf("refused: %d %v", rr.Code, out) - } -} - -// Without a monitor (tests, or before the first check) the answers are -// empty, not errors. -func TestUptimeEmpty(t *testing.T) { - _, c, _ := setUp(t) - if rr, out := c.do("GET", "/api/v1/hosts/uptime", ""); rr.Code != 200 || out["uptime"] == nil { - t.Fatalf("list: %d %v", rr.Code, out) - } - _, h := c.do("POST", "/api/v1/hosts", `{"name":"web","target":{"address":"10.0.0.5","port":80}}`) - id, _ := h["id"].(string) - if rr, out := c.do("GET", "/api/v1/hosts/"+id+"/uptime", ""); rr.Code != 200 || out["state"] != "unknown" { - t.Fatalf("one: %d %v", rr.Code, out) - } - if rr, _ := c.do("GET", "/api/v1/hosts/nope/uptime", ""); rr.Code != 404 { - t.Fatalf("no such host: %d", rr.Code) - } -} - -// Alert is what the monitor calls: it posts to the saved place, and a -// refusal shows on Settings until the next one gets through. -func TestAlertFromMonitor(t *testing.T) { - s, c, _ := setUp(t) - refuse := false - var got []string - hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if refuse { - http.Error(w, "topic full", 429) - return - } - got = append(got, r.Header.Get("Title")) - })) - defer hook.Close() - s.Alert(uptime.Event{Name: "web.apps.test", Where: "10.0.0.5:80", Down: true}) // nothing saved: nothing sent - c.do("PUT", "/api/v1/alerts", `{"kind":"ntfy","url":"`+hook.URL+`/topic"}`) - s.Alert(uptime.Event{Name: "web.apps.test", Where: "10.0.0.5:80", Down: true}) - if len(got) != 1 || got[0] != "web.apps.test is down" { - t.Fatalf("sent: %v", got) - } - refuse = true - s.Alert(uptime.Event{Name: "web.apps.test", Lasted: time.Minute}) - if _, out := c.do("GET", "/api/v1/alerts", ""); !strings.Contains(fmt.Sprint(out["lastError"]), "topic full") { - t.Fatalf("refusal not shown: %v", out) - } - refuse = false - s.Alert(uptime.Event{Name: "web.apps.test", Lasted: time.Minute}) - if _, out := c.do("GET", "/api/v1/alerts", ""); out["lastError"] != nil || len(got) != 2 || got[1] != "web.apps.test is back" { - t.Fatalf("after a delivery: %v %v", out, got) - } -} - -// Watching is kept when an update leaves it out: fjord re-sends the host -// whenever the app moves and must not switch the checks back on. -func TestWatchKeptWhenAbsent(t *testing.T) { - s, c, _ := setUp(t) - _, h := c.do("POST", "/api/v1/hosts", `{"name":"web","target":{"address":"10.0.0.5","port":80},"watch":false}`) - id, _ := h["id"].(string) - if h["unwatched"] != true { - t.Fatalf("created unwatched: %v", h) - } - c.do("PUT", "/api/v1/hosts/"+id, `{"name":"web","target":{"address":"10.0.0.6","port":80}}`) - if i := s.mgr.State().Find(id); !s.mgr.State().Hosts[i].Unwatched { - t.Fatal("an update without watch turned the checks back on") - } - c.do("PUT", "/api/v1/hosts/"+id, `{"name":"web","target":{"address":"10.0.0.6","port":80},"watch":true}`) - if i := s.mgr.State().Find(id); s.mgr.State().Hosts[i].Unwatched { - t.Fatal("watch:true did not turn them on") - } -} - -func TestUptimeEnabled(t *testing.T) { - s, c, _ := setUp(t) - if rr, out := c.do("PUT", "/api/v1/uptime/enabled", `{"enabled":false}`); rr.Code != 200 || out["enabled"] != false || !s.mgr.State().Settings.UptimeOff { - t.Fatalf("off: %d %v", rr.Code, out) - } - if _, out := c.do("GET", "/api/v1/hosts/uptime", ""); out["enabled"] != false { - t.Fatalf("the list does not say it is off: %v", out) - } - if rr, out := c.do("PUT", "/api/v1/uptime/enabled", `{"enabled":true}`); rr.Code != 200 || out["enabled"] != true || s.mgr.State().Settings.UptimeOff { - t.Fatalf("on: %d %v", rr.Code, out) - } -} diff --git a/admin/internal/sources/fjord.go b/admin/internal/sources/fjord.go deleted file mode 100644 index e1fcaa8..0000000 --- a/admin/internal/sources/fjord.go +++ /dev/null @@ -1,197 +0,0 @@ -// Package sources reads other systems that know about services, so Add -// host can be a pick instead of typing. fjord is the first; its stacks API -// already says where each app lives. -package sources - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -// Service is one thing the proxy could put a name in front of. -type Service struct { - Stack string `json:"stack"` - Label string `json:"label"` // suggested host label - Title string `json:"title"` // display name - State string `json:"state"` // running, stopped, partial… - Address string `json:"address"` // where the proxy should connect - Port int `json:"port"` - WebUI bool `json:"webUI"` // false: nothing to open (a database, a daemon) - Ref string `json:"ref"` // "fjord::", stored on the host -} - -// Fjord is a connection to one fjord instance. -type Fjord struct { - URL string // e.g. http://nas.lan:3567 - Key string // bearer token, once fjord has auth; empty is fine today -} - -var ( - webPortRe = regexp.MustCompile(`(?m)^\s*web_port:\s*["']?([^"'\s]+)`) - labelRe = regexp.MustCompile(`[^a-z0-9-]+`) -) - -// fjordStack is the slice of fjord's /api/stacks we use. -type fjordStack struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Compose string `json:"compose"` - Env string `json:"env"` - LinkHost string `json:"linkHost"` - OwnAddress bool `json:"ownAddress"` - Status struct { - State string `json:"state"` - } `json:"status"` - State struct { - Origin struct { - Type string `json:"type"` - } `json:"origin"` - } `json:"state"` - Services []struct { - Name string `json:"name"` - Ports []struct { - HostPort int `json:"hostPort"` - ContainerPort int `json:"containerPort"` - } `json:"ports"` - } `json:"services"` -} - -// Services asks fjord for its stacks and turns each into a Service. -func (f Fjord) Services(ctx context.Context) ([]Service, error) { - u, err := url.Parse(strings.TrimRight(f.URL, "/")) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return nil, errors.New("the fjord address must start with http:// or https://") - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String()+"/api/stacks", nil) - if f.Key != "" { - req.Header.Set("Authorization", "Bearer "+f.Key) - } - client := &http.Client{Timeout: 8 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("could not reach fjord at %s (%v)", u.Host, err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return nil, errors.New("fjord refused the key") - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fjord answered %s", resp.Status) - } - var stacks []fjordStack - if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&stacks); err != nil { - return nil, errors.New("fjord's answer was not a stack list") - } - host := u.Hostname() - out := make([]Service, 0, len(stacks)) - for _, st := range stacks { - out = append(out, serviceFor(st, host)) - } - return out, nil -} - -// serviceFor applies fjord's own rules (see its appUrl.ts): the address a -// browser can reach is linkHost when the container has its own, else the -// fjord host; the port is x-fjord web_port (env-expanded), else the first -// published host port; a catalog app without web_port has no web UI. -func serviceFor(st fjordStack, fjordHost string) Service { - title := st.DisplayName - if title == "" { - title = st.Name - } - s := Service{ - Stack: st.Name, - Label: Label(st.Name), - Title: title, - State: st.Status.State, - Ref: "fjord:" + fjordHost + ":" + st.Name, - } - s.Address = st.LinkHost - if s.Address == "" || !st.OwnAddress { - s.Address = fjordHost - } - env := parseEnv(st.Env) - if m := webPortRe.FindStringSubmatch(st.Compose); m != nil { - if p, err := strconv.Atoi(expand(m[1], env)); err == nil { - s.Port = p - } - } - s.WebUI = s.Port > 0 || st.State.Origin.Type != "catalog" - if s.Port == 0 { - for _, svc := range st.Services { - for _, p := range svc.Ports { - if st.OwnAddress && p.ContainerPort > 0 { - s.Port = p.ContainerPort - } else if p.HostPort > 0 { - s.Port = p.HostPort - } - if s.Port > 0 { - break - } - } - if s.Port > 0 { - break - } - } - } - return s -} - -// Label turns a stack name into a host label: lower case, letters, digits -// and hyphens, no leading or trailing hyphen. -func Label(name string) string { - l := labelRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "-") - l = strings.Trim(l, "-") - if len(l) > 63 { - l = strings.Trim(l[:63], "-") - } - return l -} - -func parseEnv(env string) map[string]string { - out := map[string]string{} - for _, line := range strings.Split(env, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if i := strings.Index(line, "="); i > 0 { - out[line[:i]] = strings.Trim(line[i+1:], `"'`) - } - } - return out -} - -var varRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`) - -func expand(s string, env map[string]string) string { - return varRe.ReplaceAllStringFunc(s, func(m string) string { - sm := varRe.FindStringSubmatch(m) - if v, ok := env[sm[1]]; ok && v != "" { - return v - } - return sm[2] - }) -} - -// HostOf returns the host part of a source URL, for refs. -func HostOf(raw string) string { - u, err := url.Parse(raw) - if err != nil { - return "" - } - if h, _, err := net.SplitHostPort(u.Host); err == nil { - return h - } - return u.Host -} diff --git a/admin/internal/sources/fjord_test.go b/admin/internal/sources/fjord_test.go deleted file mode 100644 index 6a7c879..0000000 --- a/admin/internal/sources/fjord_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package sources - -import ( - "context" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestFjordServicesFromRealSample(t *testing.T) { - sample, err := os.ReadFile("testdata/fjord-stacks.json") - if err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/stacks" { - http.NotFound(w, r) - return - } - if r.Header.Get("Authorization") != "Bearer k" { - w.WriteHeader(401) - return - } - w.Write(sample) - })) - defer srv.Close() - - if _, err := (Fjord{URL: srv.URL, Key: "wrong"}).Services(context.Background()); err == nil { - t.Fatal("wrong key accepted") - } - svcs, err := (Fjord{URL: srv.URL, Key: "k"}).Services(context.Background()) - if err != nil { - t.Fatal(err) - } - by := map[string]Service{} - for _, s := range svcs { - by[s.Stack] = s - } - host := HostOf(srv.URL) - cases := map[string]struct { - addr string - port int - webUI bool - }{ - "hugo": {host, 1313, false}, // catalog app without web_port: port guessed, no web UI - "t-vikns": {host, 3458, true}, // ${WEB_PORT}=3458, published - "t-wiz-imm": {host, 2283, true}, // literal web_port, no published ports in sample - "uptime-kuma": {"192.168.4.106", 3001, true}, // own address: linkHost + ${WEB_PORT} - "ubooquity": {host, 2202, true}, - } - for stack, want := range cases { - got, ok := by[stack] - if !ok { - t.Fatalf("%s missing", stack) - } - if got.Address != want.addr || got.Port != want.port || got.WebUI != want.webUI { - t.Errorf("%s: got %s:%d webUI=%v, want %s:%d webUI=%v", stack, got.Address, got.Port, got.WebUI, want.addr, want.port, want.webUI) - } - if got.Ref != "fjord:"+host+":"+stack { - t.Errorf("%s: ref %s", stack, got.Ref) - } - } - if Label("My Cool App_2") != "my-cool-app-2" || Label("--x--") != "x" { - t.Fatal("label rule") - } -} - -func TestFjordBadURL(t *testing.T) { - if _, err := (Fjord{URL: "nas:3567"}).Services(context.Background()); err == nil { - t.Fatal("scheme-less URL accepted") - } -} diff --git a/admin/internal/sources/testdata/fjord-stacks.json b/admin/internal/sources/testdata/fjord-stacks.json deleted file mode 100644 index 748a11e..0000000 --- a/admin/internal/sources/testdata/fjord-stacks.json +++ /dev/null @@ -1,245 +0,0 @@ -[ - { - "name": "hugo", - "displayName": "hugo", - "compose": "services:\n hugo:\n image: ghcr.io/daemonless/hugo:latest\n restart: unless-stopped\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n - HUGO_BASEURL=${HUGO_BASEURL}\n volumes:\n - \"${APP_PATH}:/app\"\n ports:\n - \"${WEB_PORT}:1313\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=hugo\nPUID=1000\nPGID=1000\nTZ=UTC\nHUGO_BASEURL=http://localhost:1313\nWEB_PORT=1313\nHUGO_APP_PATH=/var/db/fjord/containers/hugo/app\n", - "linkHost": null, - "ownAddress": null, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "hugo" - } - }, - "services": [ - { - "name": "hugo", - "ports": [ - { - "hostPort": 1313, - "containerPort": 1313, - "protocol": "tcp" - } - ] - } - ] - }, - { - "name": "t-vikns", - "displayName": "t-vikns", - "compose": "services:\n vikunja:\n image: ghcr.io/daemonless/vikunja:latest\n # \"always\", not \"unless-stopped\": FreeBSD's podman rc.d only auto-starts\n # containers with restart-policy=always at boot (needs podman_enable=YES).\n restart: always\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n # Optional: the address Vikunja puts in the links it sends.\n - VIKUNJA_SERVICE_PUBLICURL=${VIKUNJA_SERVICE_PUBLICURL}\n # The database, from the Database choice; sqlite needs nothing else.\n - VIKUNJA_DATABASE_TYPE=${VIKUNJA_DATABASE_TYPE}\n - VIKUNJA_DATABASE_HOST=${VIKUNJA_DATABASE_HOST}\n - VIKUNJA_DATABASE_USER=${VIKUNJA_DATABASE_USER}\n - VIKUNJA_DATABASE_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - VIKUNJA_DATABASE_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n volumes:\n - \"${CONFIG_DATA}:/config\"\n ports:\n - \"${WEB_PORT}:3456\"\n depends_on:\n - mariadb\n mariadb:\n image: ghcr.io/daemonless/mariadb:11.4\n restart: always\n environment:\n - MYSQL_USER=${VIKUNJA_DATABASE_USER}\n - MYSQL_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - MYSQL_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n - MYSQL_ROOT_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n volumes:\n - \"${DATABASE_LOCATION}:/config\"\nx-fjord:\n web_port: \"${WEB_PORT}\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=t-vikns\nPUID=1000\nPGID=1000\nTZ=UTC\nVIKUNJA_SERVICE_PUBLICURL=\nVIKUNJA_DATABASE_TYPE=mysql\nVIKUNJA_DATABASE_HOST=t_vikns_vikunja_mariadb\nVIKUNJA_DATABASE_USER=vikunja\nVIKUNJA_DATABASE_PASSWORD=anB3yYpf852zMTRc4AH99stU\nVIKUNJA_DATABASE_DATABASE=vikunja\nWEB_PORT=3458\nVIKUNJA_CONFIG_PATH=/var/db/fjord/containers/t-vikns/config\nDATABASE_LOCATION=/var/db/fjord/containers/t-vikns/mariadb\n", - "linkHost": null, - "ownAddress": null, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "vikunja" - } - }, - "services": [ - { - "name": "vikunja", - "ports": [ - { - "hostPort": 3458, - "containerPort": 3456, - "protocol": "tcp" - } - ] - }, - { - "name": "vikunja-mariadb", - "ports": [] - } - ] - }, - { - "name": "t-wiz-imm", - "displayName": "t-wiz-imm", - "compose": "services:\n immich-server:\n image: ghcr.io/daemonless/immich-server:${IMMICH_TAG:-latest}\n network_mode: host\n volumes:\n - ${UPLOAD_LOCATION}:/data\n - /etc/localtime:/etc/localtime:ro\n environment:\n DB_HOSTNAME: ${DB_HOSTNAME:-localhost}\n REDIS_HOSTNAME: ${REDIS_HOSTNAME:-localhost}\n IMMICH_MACHINE_LEARNING_URL: http://${IMMICH_ML_HOST:-localhost}:3003\n IMMICH_MACHINE_LEARNING_ENABLED: ${IMMICH_ML_ENABLED:-true}\n # Immich binds loopback unless told otherwise, so it answers nothing on\n # the host's address -- or on a published port, which forwards to the\n # container's own and not to its loopback.\n IMMICH_HOST: ${IMMICH_HOST:-0.0.0.0}\n env_file:\n - .env\n depends_on:\n - redis\n - database\n restart: always\n redis:\n image: ghcr.io/daemonless/redis:latest\n network_mode: host\n volumes:\n - /etc/localtime:/etc/localtime:ro\n - redis-data:/config\n restart: always\n database:\n image: ghcr.io/daemonless/immich-postgres:latest\n network_mode: host\n # FreeBSD requires sysvipc for PostgreSQL shared memory\n annotations:\n org.freebsd.jail.allow.sysvipc: \"true\"\n environment:\n POSTGRES_PASSWORD: ${DB_PASSWORD}\n POSTGRES_USER: ${DB_USERNAME}\n POSTGRES_DB: ${DB_DATABASE_NAME}\n volumes:\n - /etc/localtime:/etc/localtime:ro\n - ${DB_DATA_LOCATION}:/var/lib/postgresql/data\n restart: always\n immich-public-proxy:\n container_name: immich_public_proxy\n image: ghcr.io/daemonless/immich-public-proxy:latest\n network_mode: host\n environment:\n IMMICH_URL: http://${IMMICH_SERVER_HOST:-localhost}:2283\n PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}\n IPP_PORT: \"3000\"\n env_file:\n - .env\n depends_on:\n - immich-server\n restart: always\nvolumes:\n model-cache:\n redis-data:\n\nx-fjord:\n web_port: \"2283\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=t-wiz-imm\nDB_PASSWORD=postgres\nDB_USERNAME=postgres\nDB_DATABASE_NAME=immich\nTZ=UTC\nDB_DATA_LOCATION=/containers/immich/postgres\nUPLOAD_LOCATION=/containers/immich/library\nCACHE_LOCATION=\nPUBLIC_BASE_URL=\n", - "linkHost": null, - "ownAddress": null, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "immich" - } - }, - "services": [ - { - "name": "immich-server", - "ports": [] - }, - { - "name": "redis", - "ports": [] - }, - { - "name": "database", - "ports": [] - }, - { - "name": "immich-public-proxy", - "ports": [] - } - ] - }, - { - "name": "t-wiz-vik", - "displayName": "t-wiz-vik", - "compose": "services:\n vikunja:\n image: ghcr.io/daemonless/vikunja:latest\n # \"always\", not \"unless-stopped\": FreeBSD's podman rc.d only auto-starts\n # containers with restart-policy=always at boot (needs podman_enable=YES).\n restart: always\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n # Optional: the address Vikunja puts in the links it sends.\n - VIKUNJA_SERVICE_PUBLICURL=${VIKUNJA_SERVICE_PUBLICURL}\n # The database, from the Database choice; sqlite needs nothing else.\n - VIKUNJA_DATABASE_TYPE=${VIKUNJA_DATABASE_TYPE}\n - VIKUNJA_DATABASE_HOST=${VIKUNJA_DATABASE_HOST}\n - VIKUNJA_DATABASE_USER=${VIKUNJA_DATABASE_USER}\n - VIKUNJA_DATABASE_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - VIKUNJA_DATABASE_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n volumes:\n - \"${CONFIG_DATA}:/config\"\n ports:\n - \"${WEB_PORT}:3456\"\n depends_on:\n - mariadb\n mariadb:\n image: ghcr.io/daemonless/mariadb:11.4\n restart: always\n environment:\n - MYSQL_USER=${VIKUNJA_DATABASE_USER}\n - MYSQL_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - MYSQL_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n - MYSQL_ROOT_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n volumes:\n - \"${DATABASE_LOCATION}:/config\"\n\nx-fjord:\n web_port: \"${WEB_PORT}\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=t-wiz-vik\nPUID=1000\nPGID=1000\nTZ=UTC\nVIKUNJA_SERVICE_PUBLICURL=\nVIKUNJA_DATABASE_TYPE=mysql\nVIKUNJA_DATABASE_HOST=t_wiz_vik_vikunja_mariadb\nVIKUNJA_DATABASE_USER=vikunja\nVIKUNJA_DATABASE_PASSWORD=WGT6Xfbadk5sntxv7qc2cf3v\nVIKUNJA_DATABASE_DATABASE=vikunja\nWEB_PORT=3460\nVIKUNJA_CONFIG_PATH=/var/db/fjord/containers/t-wiz-vik/config\nDATABASE_LOCATION=/var/db/fjord/containers/t-wiz-vik/mariadb\n", - "linkHost": null, - "ownAddress": null, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "vikunja" - } - }, - "services": [ - { - "name": "vikunja", - "ports": [ - { - "hostPort": 3460, - "containerPort": 3456, - "protocol": "tcp" - } - ] - }, - { - "name": "vikunja-mariadb", - "ports": [] - } - ] - }, - { - "name": "ubooquity", - "displayName": "ubooquity", - "compose": "services:\n ubooquity:\n image: ghcr.io/daemonless/ubooquity:latest\n restart: unless-stopped\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n - MAXMEM=${MAXMEM}\n volumes:\n - \"${CONFIG_DATA}:/config\"\n - \"${BOOKS_PATH}:/books\"\n - \"${COMICS_PATH}:/comics\"\n - \"${FILES_PATH}:/files\"\n ports:\n - \"${WEB_PORT}:2202\"\n - \"${PORT_2203}:2203\"\n\nx-fjord:\n web_port: \"${WEB_PORT}\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=ubooquity\nPUID=1000\nPGID=1000\nTZ=UTC\nMAXMEM=512\nWEB_PORT=2202\nUBOOQUITY_CONFIG_PATH=/var/db/fjord/containers/ubooquity/config\nBOOKS_PATH=/var/db/fjord/containers/ubooquity/books\nUBOOQUITY_COMICS_PATH=/var/db/fjord/containers/ubooquity/comics\nUBOOQUITY_FILES_PATH=/var/db/fjord/containers/ubooquity/files\n", - "linkHost": null, - "ownAddress": null, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "ubooquity" - } - }, - "services": [ - { - "name": "ubooquity", - "ports": [ - { - "hostPort": 2202, - "containerPort": 2202, - "protocol": "tcp" - }, - { - "hostPort": 2203, - "containerPort": 2203, - "protocol": "tcp" - } - ] - } - ] - }, - { - "name": "uptime-kuma", - "displayName": "uptime-kuma", - "compose": "services:\n uptime-kuma:\n image: ghcr.io/daemonless/uptime-kuma:latest\n restart: unless-stopped\n annotations:\n org.freebsd.jail.allow.raw_sockets: \"true\"\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n - UPTIME_KUMA_IS_CONTAINER=${UPTIME_KUMA_IS_CONTAINER}\n - UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=${UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC}\n - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=${PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD}\n - DATA_DIR=${DATA_DIR}\n volumes:\n - \"${CONFIG_DATA}:/config\"\n networks:\n - lan\n hostname: uptime-kuma\n extra_hosts:\n - uptime-kuma:0.0.0.0\n x-fjord-published:\n - \"${WEB_PORT}:3001\"\nnetworks:\n lan:\n external: true\n\nx-fjord:\n web_port: \"${WEB_PORT}\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=uptime-kuma\nPUID=1000\nPGID=1000\nTZ=UTC\nUPTIME_KUMA_IS_CONTAINER=1\nUPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=1\nPLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1\nDATA_DIR=/config\nWEB_PORT=3001\nUPTIME_KUMA_CONFIG_PATH=/var/db/fjord/containers/uptime-kuma/config\n", - "linkHost": "192.168.4.106", - "ownAddress": true, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "uptime-kuma" - } - }, - "services": [ - { - "name": "uptime-kuma", - "ports": [] - } - ] - }, - { - "name": "vikunja", - "displayName": "vikunja", - "compose": "services:\n vikunja:\n image: ghcr.io/daemonless/vikunja:pkg\n # \"always\", not \"unless-stopped\": FreeBSD's podman rc.d only auto-starts\n # containers with restart-policy=always at boot (needs podman_enable=YES).\n restart: always\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n # Optional: the address Vikunja puts in the links it sends.\n - VIKUNJA_SERVICE_PUBLICURL=${VIKUNJA_SERVICE_PUBLICURL}\n # The database, from the Database choice; sqlite needs nothing else.\n - VIKUNJA_DATABASE_TYPE=${VIKUNJA_DATABASE_TYPE}\n - VIKUNJA_DATABASE_HOST=${VIKUNJA_DATABASE_HOST}\n - VIKUNJA_DATABASE_USER=${VIKUNJA_DATABASE_USER}\n - VIKUNJA_DATABASE_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - VIKUNJA_DATABASE_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n volumes:\n - \"${CONFIG_DATA}:/config\"\n ports:\n - \"${WEB_PORT}:3456\"\n\nx-fjord:\n web_port: \"${WEB_PORT}\"\n", - "env": "# Generated by fjord for appjail-director; edit values, not keys.\nDIRECTOR_PROJECT=vikunja\nPUID=1000\nPGID=1000\nTZ=UTC\nVIKUNJA_SERVICE_PUBLICURL=\nVIKUNJA_DATABASE_TYPE=sqlite\nVIKUNJA_DATABASE_HOST=\nVIKUNJA_DATABASE_USER=\nVIKUNJA_DATABASE_PASSWORD=\nVIKUNJA_DATABASE_DATABASE=\nWEB_PORT=3456\nVIKUNJA_CONFIG_PATH=/var/db/fjord/containers/vikunja/config\n", - "linkHost": null, - "ownAddress": null, - "status": { - "state": "running" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "vikunja" - } - }, - "services": [ - { - "name": "vikunja", - "ports": [ - { - "hostPort": 3456, - "containerPort": 3456, - "protocol": "tcp" - } - ] - } - ] - }, - { - "name": "vikunja-2", - "displayName": "vikunja", - "compose": "services:\n vikunja:\n image: ghcr.io/daemonless/vikunja:pkg\n # \"always\", not \"unless-stopped\": FreeBSD's podman rc.d only auto-starts\n # containers with restart-policy=always at boot (needs podman_enable=YES).\n restart: always\n environment:\n - PUID=${PUID}\n - PGID=${PGID}\n - TZ=${TZ}\n # Optional: the address Vikunja puts in the links it sends.\n - VIKUNJA_SERVICE_PUBLICURL=${VIKUNJA_SERVICE_PUBLICURL}\n # The database, from the Database choice; sqlite needs nothing else.\n - VIKUNJA_DATABASE_TYPE=${VIKUNJA_DATABASE_TYPE}\n - VIKUNJA_DATABASE_HOST=${VIKUNJA_DATABASE_HOST}\n - VIKUNJA_DATABASE_USER=${VIKUNJA_DATABASE_USER}\n - VIKUNJA_DATABASE_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - VIKUNJA_DATABASE_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n volumes:\n - \"${CONFIG_DATA}:/config\"\n depends_on:\n - mariadb\n networks:\n lan:\n mac_address: 58:9c:fc:10:5f:36\n hostname: vikunja\n extra_hosts:\n - vikunja:0.0.0.0\n x-fjord-published:\n - \"${WEB_PORT}:3456\"\n mariadb:\n image: ghcr.io/daemonless/mariadb:pkg\n restart: always\n environment:\n - MYSQL_USER=${VIKUNJA_DATABASE_USER}\n - MYSQL_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n - MYSQL_DATABASE=${VIKUNJA_DATABASE_DATABASE}\n - MYSQL_ROOT_PASSWORD=${VIKUNJA_DATABASE_PASSWORD}\n volumes:\n - \"${DATABASE_LOCATION}:/config\"\n networks:\n lan:\n mac_address: 58:9c:fc:10:de:40\n hostname: mariadb\n extra_hosts:\n - mariadb:0.0.0.0\nnetworks:\n lan:\n external: true\nx-fjord:\n web_port: \"${WEB_PORT}\"\n", - "env": "CONFIG_DATA=/var/db/fjord/containers/vikunja-2/config\nDATABASE_LOCATION=/var/db/fjord/containers/vikunja-2/mariadb\nPGID=1000\nPUID=1000\nTZ=UTC\nVIKUNJA_DATABASE_DATABASE=vikunja\nVIKUNJA_DATABASE_HOST=mariadb\nVIKUNJA_DATABASE_PASSWORD=D69DvTjg8JwabBncSFCaVmBU\nVIKUNJA_DATABASE_TYPE=mysql\nVIKUNJA_DATABASE_USER=vikunja\nWEB_PORT=3456\n", - "linkHost": "192.168.4.136", - "ownAddress": true, - "status": { - "state": "partial" - }, - "state": { - "origin": { - "type": "catalog", - "app_id": "vikunja" - } - }, - "services": [ - { - "name": "vikunja", - "ports": [] - }, - { - "name": "mariadb", - "ports": [] - } - ] - } -] \ No newline at end of file diff --git a/admin/internal/state/model.go b/admin/internal/state/model.go deleted file mode 100644 index d20efcd..0000000 --- a/admin/internal/state/model.go +++ /dev/null @@ -1,296 +0,0 @@ -// Package state is the admin's single source of truth: the typed model and -// the file it persists to. Everything Caddy runs is rendered from here. -package state - -import "time" - -// SchemaVersion is bumped whenever State changes shape; Store migrates -// older files forward and keeps a copy of the original. -const SchemaVersion = 1 - -// State is the whole persisted model. -type State struct { - SchemaVersion int `json:"schemaVersion"` - Settings Settings `json:"settings"` - Hosts []Host `json:"hosts"` - Tokens []ApiToken `json:"tokens,omitempty"` - // PairedKeys are the hashes of every key ever registered from - // RP_PAIRING_KEY, so one the person removed stays removed although the - // variable is still set. - PairedKeys []string `json:"pairedKeys,omitempty"` -} - -// Settings is everything that is not per host. -type Settings struct { - // Domain is the address services live under, e.g. "home.example.com" - // or "apps.home.lan". Empty until setup. - Domain string `json:"domain"` - // TrustedNetworks are extra CIDRs that count as the home network. - TrustedNetworks []string `json:"trustedNetworks"` - // AdminPasswordHash is set by setup (checkpoint 3). - AdminPasswordHash string `json:"adminPasswordHash,omitempty"` - // AdminAddress is host[:port] as the browser sent it the last time the - // account set up or signed in: the one address the container knows it - // is reached at (its published port is invisible from inside), used - // for the links the commands and the first-run banner print. - AdminAddress string `json:"adminAddress,omitempty"` - // AdminIPAddress is the last such address that was an IP address: the - // way back in when a name stops working (a new ending, a certificate - // change), since an IP never depends on DNS or the certificate's names. - AdminIPAddress string `json:"adminIpAddress,omitempty"` - // Sources are other systems that know about services (Add host → From …). - Sources Sources `json:"sources,omitempty"` - // Certificates is set once a public domain has a DNS provider: public - // certificates for *.Domain instead of the internal CA. The provider's - // credentials live in the secrets directory, not here. - Certificates *Certificates `json:"certificates,omitempty"` - // ShortLinks is the one-word name that makes http://go/radarr open - // radarr., like go links at work: nil means "go", "" is off. - ShortLinks *string `json:"shortLinks,omitempty"` - // Listen are the networks (subnets, 192.168.86.0/24) the proxy answers - // on, for a container on several that reaches apps on all of them; - // none answers on every network. - Listen []string `json:"answerOn,omitempty"` - // Alerts is where a message goes when a name stops responding and - // when it is back; nil sends none. - Alerts *Alerts `json:"alerts,omitempty"` - // UptimeOff stops the uptime checks (and so the alerts) for every name. - UptimeOff bool `json:"uptimeOff,omitempty"` -} - -// Alerts is one place to send down and back messages. -type Alerts struct { - Kind string `json:"kind"` // "ntfy" or "webhook" - URL string `json:"url"` -} - -// DefaultShortLinks is the short-link name when none was chosen. -const DefaultShortLinks = "go" - -// ShortName is the short-link name in use, "" when off. -func (s Settings) ShortName() string { - if s.ShortLinks == nil { - return DefaultShortLinks - } - return *s.ShortLinks -} - -// Certificates is the automatic-certificate setup for a public domain. -type Certificates struct { - Provider string `json:"provider"` // a dns.Providers id - // Stage is StageTrial until a certificate from the staging CA proves - // the credentials, then StageLive. A wrong token therefore never - // counts against the real CA's limits. - Stage string `json:"stage"` - StartedAt time.Time `json:"startedAt"` // when this stage began; log lines before it are history -} - -const ( - StageTrial = "trial" - StageLive = "live" -) - -// Sources holds the connections Add host can pick from. -type Sources struct { - Fjord *FjordSource `json:"fjord,omitempty"` -} - -// FjordSource is a fjord instance to read stacks from. -type FjordSource struct { - URL string `json:"url"` - Key string `json:"key,omitempty"` -} - -// Host is one name in front of one service. -type Host struct { - ID string `json:"id"` - Name string `json:"name"` // label under Settings.Domain - ExtraNames []string `json:"extraNames,omitempty"` // more labels for the same service - Target Target `json:"target"` - Access Access `json:"access"` - // Ref says where a host came from ("::", fjord's - // "fjord::"), so the app that made it finds it again. Empty - // for hand-made hosts. - Ref string `json:"ref,omitempty"` - // By is the id of the key that made the host, so Connected apps can - // count an app's names whatever its refs look like. Empty when made by - // hand. - By string `json:"by,omitempty"` - // TrustedNetworks overrides Settings.TrustedNetworks for this host when set. - TrustedNetworks []string `json:"trustedNetworks,omitempty"` - // OwnOrigin: the app expects requests from its own address (UniFi - // refuses a login, then every call, whose Origin or Referer names the - // proxy). Requests from this name's own pages then reach it with its - // address in those headers. Off by default: most apps compare Origin - // with Host, and the rewrite would break them (Django). - OwnOrigin bool `json:"ownOrigin,omitempty"` - // Icon is "." of the app's icon (server/icons.go); set by - // its own call, never by an edit of the host. - Icon string `json:"icon,omitempty"` - // Unwatched: not checked for uptime, no alerts. Watched by default. - Unwatched bool `json:"unwatched,omitempty"` - CreatedAt time.Time `json:"createdAt"` - DeletedAt *time.Time `json:"deletedAt,omitempty"` // soft delete; Undo clears it -} - -// Target is where the service is. -type Target struct { - Address string `json:"address"` - Port int `json:"port"` - HTTPS bool `json:"https"` - // AcceptUntrusted lets the target use a self-signed certificate. - AcceptUntrusted bool `json:"acceptUntrusted"` -} - -// Access is who can open a host. -type Access string - -const ( - AccessHome Access = "home" // devices on the home network only - AccessInternet Access = "internet" // anyone -) - -// ApiToken is a connected app's credential; only its hash is stored. -type ApiToken struct { - ID string `json:"id"` - Label string `json:"label"` - Hash string `json:"hash"` - CreatedAt time.Time `json:"createdAt"` - LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` - // Paired marks the app that installed the proxy. Its first key came - // from RP_PAIRING_KEY and is sent whole; after a relink with a code it - // holds an rp_ key like any other app. - Paired bool `json:"paired,omitempty"` - // Disabled keys are refused but kept, so Reconnect (or Undo) brings - // one back: a paired key cannot be shown again or re-entered. - Disabled bool `json:"disabled,omitempty"` - // URL is where the paired app is opened (RP_PAIRING_URL): the way back - // to it from here. Optional. - URL string `json:"url,omitempty"` - // ClientID is the app's own permanent id (a fjord's instance id): the - // refs of the hosts it makes carry it, so a rename or a move of the app - // changes nothing here. Optional. - ClientID string `json:"clientId,omitempty"` -} - -// Mode is how certificates are obtained, derived from the domain suffix. -type Mode string - -const ( - ModeUnset Mode = "" // no domain yet - ModePublic Mode = "public" // real domain: wildcard via DNS challenge - ModePrivate Mode = "private" // private suffix: internal CA -) - -// Mode derives the certificate mode from the domain. -func (s Settings) Mode() Mode { - if s.Domain == "" { - return ModeUnset - } - if IsPrivateSuffix(s.Domain) { - return ModePrivate - } - return ModePublic -} - -// FQDN is the full name of a label under the settings domain. -func (s Settings) FQDN(label string) string { - if s.Domain == "" { - return label - } - return label + "." + s.Domain -} - -// Names returns every full name a host answers to. -func (h Host) Names(s Settings) []string { - out := []string{s.FQDN(h.Name)} - for _, n := range h.ExtraNames { - out = append(out, s.FQDN(n)) - } - return out -} - -// Live hosts are the ones not soft-deleted. -func (st *State) Live() []Host { - var out []Host - for _, h := range st.Hosts { - if h.DeletedAt == nil { - out = append(out, h) - } - } - return out -} - -// Find returns the index of the host with id, or -1. -func (st *State) Find(id string) int { - for i, h := range st.Hosts { - if h.ID == id { - return i - } - } - return -1 -} - -// UndoWindow is how long a deleted host can be restored. -const UndoWindow = 24 * time.Hour - -// Purge drops soft-deleted hosts whose undo window has passed. -func (st *State) Purge(now time.Time) { - // One removed record per name, the latest: remove-and-add-again used to - // leave one behind each time, and a restore then picked any of them. - // A removed name a live host holds cannot come back either. - live := map[string]bool{} - latest := map[string]time.Time{} - for _, h := range st.Hosts { - if h.DeletedAt == nil { - live[h.Name] = true - } else if h.DeletedAt.After(latest[h.Name]) { - latest[h.Name] = *h.DeletedAt - } - } - kept := st.Hosts[:0] - for _, h := range st.Hosts { - if h.DeletedAt != nil { - if now.Sub(*h.DeletedAt) > UndoWindow { - continue - } - if h.Name != "" && (live[h.Name] || !h.DeletedAt.Equal(latest[h.Name])) { - continue - } - } - kept = append(kept, h) - } - st.Hosts = kept -} - -// Clone returns a deep copy, so a failed change never touches the live state. -func (st *State) Clone() *State { - c := *st - c.Settings.TrustedNetworks = append([]string(nil), st.Settings.TrustedNetworks...) - c.Settings.Listen = append([]string(nil), st.Settings.Listen...) - if st.Settings.Sources.Fjord != nil { - f := *st.Settings.Sources.Fjord - c.Settings.Sources.Fjord = &f - } - if st.Settings.Alerts != nil { - a := *st.Settings.Alerts - c.Settings.Alerts = &a - } - if st.Settings.Certificates != nil { - cc := *st.Settings.Certificates - c.Settings.Certificates = &cc - } - c.Hosts = make([]Host, len(st.Hosts)) - for i, h := range st.Hosts { - h.ExtraNames = append([]string(nil), h.ExtraNames...) - h.TrustedNetworks = append([]string(nil), h.TrustedNetworks...) - if h.DeletedAt != nil { - t := *h.DeletedAt - h.DeletedAt = &t - } - c.Hosts[i] = h - } - c.Tokens = append([]ApiToken(nil), st.Tokens...) - c.PairedKeys = append([]string(nil), st.PairedKeys...) - return &c -} diff --git a/admin/internal/state/store.go b/admin/internal/state/store.go deleted file mode 100644 index f9ab57a..0000000 --- a/admin/internal/state/store.go +++ /dev/null @@ -1,88 +0,0 @@ -package state - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "time" -) - -// Store persists State as one JSON file with atomic replacement. -type Store struct { - path string -} - -// NewStore returns a store for the file at path. -func NewStore(path string) *Store { return &Store{path: path} } - -// Load reads the state, creating an empty one when the file is missing and -// migrating an older schema (a copy of the original is kept beside it). -func (s *Store) Load() (*State, error) { - raw, err := os.ReadFile(s.path) - if errors.Is(err, os.ErrNotExist) { - st := &State{SchemaVersion: SchemaVersion} - return st, s.Save(st) - } - if err != nil { - return nil, err - } - var st State - if err := json.Unmarshal(raw, &st); err != nil { - return nil, fmt.Errorf("%s: %w", s.path, err) - } - if st.SchemaVersion > SchemaVersion { - return nil, fmt.Errorf("%s is schema %d, this version understands %d", s.path, st.SchemaVersion, SchemaVersion) - } - if st.SchemaVersion < SchemaVersion { - backup := fmt.Sprintf("%s.v%d-%s", s.path, st.SchemaVersion, time.Now().UTC().Format("20060102T150405Z")) - if err := os.WriteFile(backup, raw, 0o600); err != nil { - return nil, err - } - migrate(&st) - if err := s.Save(&st); err != nil { - return nil, err - } - } - return &st, nil -} - -// migrate brings an older state forward one version at a time. -func migrate(st *State) { - for st.SchemaVersion < SchemaVersion { - switch st.SchemaVersion { - case 0: - // Pre-release files carried no version. - } - st.SchemaVersion++ - } -} - -// Save writes the state atomically: temp file, fsync, rename. -func (s *Store) Save(st *State) error { - data, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if _, err := tmp.Write(append(data, '\n')); err != nil { - tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmp.Name(), 0o600); err != nil { - return err - } - return os.Rename(tmp.Name(), s.path) -} diff --git a/admin/internal/state/store_test.go b/admin/internal/state/store_test.go deleted file mode 100644 index 16bc57b..0000000 --- a/admin/internal/state/store_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package state - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestLoadCreatesEmptyState(t *testing.T) { - dir := t.TempDir() - s := NewStore(filepath.Join(dir, "state.json")) - st, err := s.Load() - if err != nil { - t.Fatal(err) - } - if st.SchemaVersion != SchemaVersion || len(st.Hosts) != 0 { - t.Fatalf("unexpected state %+v", st) - } - if _, err := os.Stat(s.path); err != nil { - t.Fatalf("file not created: %v", err) - } -} - -func TestRoundTrip(t *testing.T) { - s := NewStore(filepath.Join(t.TempDir(), "state.json")) - now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) - in := &State{SchemaVersion: SchemaVersion, Settings: Settings{Domain: "home.example.com"}, - Hosts: []Host{{ID: "a1", Name: "radarr", Target: Target{Address: "192.168.1.20", Port: 7878}, Access: AccessHome, CreatedAt: now}}} - if err := s.Save(in); err != nil { - t.Fatal(err) - } - out, err := s.Load() - if err != nil { - t.Fatal(err) - } - if out.Settings.Domain != "home.example.com" || len(out.Hosts) != 1 || out.Hosts[0].Name != "radarr" { - t.Fatalf("round trip lost data: %+v", out) - } - info, _ := os.Stat(s.path) - if info.Mode().Perm() != 0o600 { - t.Fatalf("state file mode %v, want 0600", info.Mode().Perm()) - } -} - -func TestMigrationKeepsBackup(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "state.json") - os.WriteFile(p, []byte(`{"schemaVersion":0,"settings":{"domain":"x.lan"},"hosts":[]}`), 0o600) - st, err := NewStore(p).Load() - if err != nil { - t.Fatal(err) - } - if st.SchemaVersion != SchemaVersion { - t.Fatalf("not migrated: %d", st.SchemaVersion) - } - entries, _ := os.ReadDir(dir) - var backup bool - for _, e := range entries { - if strings.HasPrefix(e.Name(), "state.json.v0-") { - backup = true - } - } - if !backup { - t.Fatal("no backup of the pre-migration file") - } -} - -func TestNewerSchemaRefused(t *testing.T) { - p := filepath.Join(t.TempDir(), "state.json") - os.WriteFile(p, []byte(`{"schemaVersion":99}`), 0o600) - if _, err := NewStore(p).Load(); err == nil { - t.Fatal("expected an error for a newer schema") - } -} - -func TestPurgeAndLive(t *testing.T) { - now := time.Now() - old := now.Add(-25 * time.Hour) - recent := now.Add(-time.Hour) - st := &State{Hosts: []Host{{ID: "keep"}, {ID: "old", DeletedAt: &old}, {ID: "recent", DeletedAt: &recent}}} - st.Purge(now) - if len(st.Hosts) != 2 || st.Find("old") != -1 { - t.Fatalf("purge wrong: %+v", st.Hosts) - } - if live := st.Live(); len(live) != 1 || live[0].ID != "keep" { - t.Fatalf("live wrong: %+v", live) - } -} - -// Removed records: one per name (the latest), none for a name a live host -// holds. Remove, add again, remove again used to pile them up. -func TestPurgeKeepsOneRemovedPerName(t *testing.T) { - now := time.Now() - t1, t2, t3 := now.Add(-3*time.Hour), now.Add(-2*time.Hour), now.Add(-time.Hour) - st := &State{Hosts: []Host{ - {ID: "d1", Name: "deckcraft", DeletedAt: &t1}, - {ID: "d2", Name: "deckcraft", DeletedAt: &t3}, - {ID: "d3", Name: "deckcraft", DeletedAt: &t2}, - {ID: "z1", Name: "zensical", DeletedAt: &t1}, - {ID: "z2", Name: "zensical"}, - {ID: "a1", Name: "actual", DeletedAt: &t2}, - }} - st.Purge(now) - ids := []string{} - for _, h := range st.Hosts { - ids = append(ids, h.ID) - } - if got := strings.Join(ids, ","); got != "d2,z2,a1" { - t.Fatalf("kept %s, want d2,z2,a1", got) - } -} - -func TestCloneIsDeep(t *testing.T) { - st := &State{Hosts: []Host{{ID: "a", ExtraNames: []string{"x"}}}} - c := st.Clone() - c.Hosts[0].ExtraNames[0] = "changed" - c.Hosts[0].Name = "changed" - if st.Hosts[0].ExtraNames[0] != "x" || st.Hosts[0].Name != "" { - t.Fatal("clone shares memory with the original") - } -} - -func TestValidation(t *testing.T) { - good := Host{ID: "h1", Name: "radarr", Target: Target{Address: "192.168.1.20", Port: 7878}, Access: AccessHome} - if err := ValidateHost(good, nil); err != nil { - t.Fatal(err) - } - bad := []struct { - name string - h Host - fld string - }{ - {"empty name", Host{Target: good.Target, Access: AccessHome}, "name"}, - {"dot in name", Host{Name: "a.b", Target: good.Target, Access: AccessHome}, "name"}, - {"bad port", Host{Name: "x", Target: Target{Address: "1.2.3.4", Port: 0}, Access: AccessHome}, "target.port"}, - {"bad address", Host{Name: "x", Target: Target{Address: "not valid!", Port: 80}, Access: AccessHome}, "target.address"}, - {"no access", Host{Name: "x", Target: good.Target}, "access"}, - {"bad network", Host{Name: "x", Target: good.Target, Access: AccessHome, TrustedNetworks: []string{"nope"}}, "trustedNetworks"}, - {"taken", Host{ID: "h2", Name: "radarr", Target: good.Target, Access: AccessHome}, "name"}, - } - for _, tc := range bad { - err := ValidateHost(tc.h, []Host{good}) - fe, ok := err.(*FieldError) - if !ok || fe.Field != tc.fld { - t.Errorf("%s: got %v, want field %s", tc.name, err, tc.fld) - } - } - for _, d := range []string{"home.example.com", "apps.home.lan"} { - if err := ValidateDomain(d); err != nil { - t.Errorf("%s rejected: %v", d, err) - } - } - for _, d := range []string{"", "nodots", "bad_char.com", "x.local"} { - if err := ValidateDomain(d); err == nil { - t.Errorf("%q accepted", d) - } - } - if (Settings{Domain: "apps.home.lan"}).Mode() != ModePrivate || (Settings{Domain: "home.example.com"}).Mode() != ModePublic || (Settings{}).Mode() != ModeUnset { - t.Fatal("mode derivation wrong") - } -} diff --git a/admin/internal/state/validate.go b/admin/internal/state/validate.go deleted file mode 100644 index 110bf80..0000000 --- a/admin/internal/state/validate.go +++ /dev/null @@ -1,184 +0,0 @@ -package state - -import ( - "errors" - "fmt" - "net" - "net/netip" - "regexp" - "strings" -) - -// FieldError names the field a rejected value belongs to, so the UI can show -// the message next to it. -type FieldError struct { - Field string - Msg string -} - -func (e *FieldError) Error() string { return e.Field + ": " + e.Msg } - -var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) - -// privateSuffixes can never get a public certificate. -var privateSuffixes = []string{".lan", ".internal", ".home.arpa", ".home", ".test", ".localdomain"} - -// IsPrivateSuffix reports whether a domain ends in a suffix no public CA -// will issue for. -func IsPrivateSuffix(domain string) bool { - d := strings.ToLower(domain) - for _, s := range privateSuffixes { - if strings.HasSuffix(d, s) || d == s[1:] { - return true - } - } - return false -} - -// IsMDNSSuffix reports a .local domain, which clashes with mDNS. -func IsMDNSSuffix(domain string) bool { - d := strings.ToLower(domain) - return strings.HasSuffix(d, ".local") || d == "local" -} - -// ValidateDomain checks the address services live under. -func ValidateDomain(domain string) error { - d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") - if d == "" { - return &FieldError{"domain", "enter the address your services should live under"} - } - if len(d) > 253 { - return &FieldError{"domain", "that address is too long"} - } - labels := strings.Split(d, ".") - if len(labels) < 2 { - return &FieldError{"domain", "the address needs at least one dot, like home.example.com"} - } - for _, l := range labels { - if !labelRe.MatchString(l) { - return &FieldError{"domain", "use only letters, digits and hyphens between the dots"} - } - } - if IsMDNSSuffix(d) { - return &FieldError{"domain", "addresses ending in .local often don't work; try .lan or .home.arpa"} - } - return nil -} - -// NormalizeDomain lower-cases and trims a validated domain. -func NormalizeDomain(domain string) string { - return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") -} - -// ValidateLabel checks one host name label. -func ValidateLabel(field, label string) error { - l := strings.ToLower(strings.TrimSpace(label)) - if l == "" { - return &FieldError{field, "enter a name"} - } - if !labelRe.MatchString(l) { - return &FieldError{field, "use letters, digits and hyphens only, like radarr or my-nas"} - } - return nil -} - -// ValidateTarget checks where a service is. -func ValidateTarget(t Target) error { - a := strings.TrimSpace(t.Address) - if a == "" { - return &FieldError{"target.address", "enter the address of the service"} - } - if _, err := netip.ParseAddr(a); err != nil { - // Not an IP: accept a hostname. - for _, l := range strings.Split(strings.TrimSuffix(strings.ToLower(a), "."), ".") { - if !labelRe.MatchString(l) { - return &FieldError{"target.address", "enter an IP address or a host name"} - } - } - } - if t.Port < 1 || t.Port > 65535 { - return &FieldError{"target.port", "enter a port between 1 and 65535"} - } - return nil -} - -// ValidateNetworks checks a list of CIDRs. -func ValidateNetworks(field string, nets []string) error { - for _, n := range nets { - if _, _, err := net.ParseCIDR(strings.TrimSpace(n)); err != nil { - return &FieldError{field, fmt.Sprintf("%q is not a network like 100.64.0.0/10", n)} - } - } - return nil -} - -// ValidateHost checks a whole host and that its names are free. -func ValidateHost(h Host, others []Host) error { - if err := ValidateLabel("name", h.Name); err != nil { - return err - } - for _, n := range h.ExtraNames { - if err := ValidateLabel("extraNames", n); err != nil { - return err - } - } - if err := ValidateTarget(h.Target); err != nil { - return err - } - switch h.Access { - case AccessHome, AccessInternet: - default: - return &FieldError{"access", "choose who can open it"} - } - if err := ValidateNetworks("trustedNetworks", h.TrustedNetworks); err != nil { - return err - } - taken := map[string]string{} - for _, o := range others { - if o.ID == h.ID || o.DeletedAt != nil { - continue - } - taken[o.Name] = o.Name - for _, n := range o.ExtraNames { - taken[n] = o.Name - } - } - for _, n := range append([]string{h.Name}, h.ExtraNames...) { - if by, ok := taken[n]; ok { - return &FieldError{"name", fmt.Sprintf("%s is already used by %s", n, by)} - } - } - return nil -} - -// IsFieldError reports whether err is a validation failure. -func IsFieldError(err error) bool { - var fe *FieldError - return errors.As(err, &fe) -} - -// ValidateShortLinks checks a short-link name: one word a browser can take -// as a host name ("go"), or "" for off. -func ValidateShortLinks(name string) error { - if name == "" { - return nil - } - if !labelRe.MatchString(name) { - return &FieldError{Field: "shortLinks", Msg: "one word: letters, digits and dashes, like go"} - } - return nil -} - -// ShortHosts are the host names a short-link word answers to: the word as -// typed (go), and the word under each home suffix, for whoever types the -// full go.lan. Nothing longer, so it never shadows an app under a domain. -func ShortHosts(word string) []string { - if word == "" { - return nil - } - hosts := []string{word} - for _, s := range privateSuffixes { - hosts = append(hosts, word+s) - } - return hosts -} diff --git a/admin/internal/uptime/alert.go b/admin/internal/uptime/alert.go deleted file mode 100644 index 21855ef..0000000 --- a/admin/internal/uptime/alert.go +++ /dev/null @@ -1,108 +0,0 @@ -package uptime - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// Alert kinds: ntfy gets a title and text it shows as is; webhook gets JSON. -const ( - KindNtfy = "ntfy" - KindWebhook = "webhook" -) - -// Text is the message for e, as a title and a line. -func (e Event) Text() (title, body string) { - if e.Example { - return "Test from the proxy", "Alerts work: you'll get a message like this when a name stops responding, and again when it's back." - } - if e.Down { - return e.Name + " is down", fmt.Sprintf("%s is not responding at %s.", e.Name, e.Where) - } - return e.Name + " is back", fmt.Sprintf("%s is responding again, after %s.", e.Name, Duration(e.Lasted)) -} - -// Duration says d the way a person would: 40 s, 12 min, 3 h 5 min, 2 days. -func Duration(d time.Duration) string { - switch { - case d < time.Minute: - return fmt.Sprintf("%d s", int(d.Seconds())) - case d < time.Hour: - return fmt.Sprintf("%d min", int(d.Minutes())) - case d < 48*time.Hour: - h, m := int(d.Hours()), int(d.Minutes())%60 - if m == 0 { - return fmt.Sprintf("%d h", h) - } - return fmt.Sprintf("%d h %d min", h, m) - default: - return fmt.Sprintf("%d days", int(d.Hours()/24)) - } -} - -// Send delivers e to url as kind. -func Send(ctx context.Context, kind, url string, e Event) error { - title, body := e.Text() - var req *http.Request - var err error - switch kind { - case KindNtfy: - req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Title", title) - switch { - case e.Example: - req.Header.Set("Tags", "bell") - case e.Down: - req.Header.Set("Tags", "rotating_light") - req.Header.Set("Priority", "high") - default: - req.Header.Set("Tags", "white_check_mark") - } - case KindWebhook: - state := "up" - if e.Down { - state = "down" - } - if e.Example { - state = "test" - } - payload := map[string]any{"state": state, "title": title, "message": body} - if e.Name != "" { - payload["name"], payload["url"], payload["target"] = e.Name, "https://"+e.Name, e.Where - } - if !e.Since.IsZero() { - payload["since"] = e.Since.UTC().Format(time.RFC3339) - } - if e.Lasted > 0 { - payload["downSeconds"] = int(e.Lasted.Seconds()) - } - data, _ := json.Marshal(payload) - req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - default: - return fmt.Errorf("unknown alert kind %q", kind) - } - req.Header.Set("User-Agent", "reverse-proxy") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) - return fmt.Errorf("%s answered %s %s", req.URL.Host, resp.Status, strings.TrimSpace(string(msg))) - } - return nil -} diff --git a/admin/internal/uptime/uptime.go b/admin/internal/uptime/uptime.go deleted file mode 100644 index 9efd799..0000000 --- a/admin/internal/uptime/uptime.go +++ /dev/null @@ -1,430 +0,0 @@ -// Package uptime checks every name's target in the background and keeps a -// small history: hourly counts for 90 days and the last outages. A very -// small Uptime Kuma: a port that accepts a connection is up. -package uptime - -import ( - "context" - "encoding/json" - "errors" - "net" - "os" - "path/filepath" - "strconv" - "sync" - "time" -) - -const ( - // Every is how often each target is checked. - Every = time.Minute - // DownAfter failed checks in a row make a target down; one success - // makes it up again. A single dropped connection is not an outage. - DownAfter = 2 - // Keep is how far back the hourly counts go. - Keep = 90 * 24 * time.Hour - keepOutage = 50 - saveEvery = 5 * time.Minute - tick = 10 * time.Second - timeout = 5 * time.Second - parallel = 16 -) - -// Target is one name to watch. -type Target struct { - ID string - Name string // the full name, radarr.apps.lan - Address string - Port int - // Skip: not checked now (removed inside its Undo window, or checks - // turned off); its history stays. - Skip bool -} - -func (t Target) where() string { return net.JoinHostPort(t.Address, strconv.Itoa(t.Port)) } - -// Hour is one hour's checks: [start (unix seconds), checks, up, sum of the -// up checks' connect times in ms]. An array, not an object: 2160 per name. -type Hour [4]int64 - -// Outage is a stretch of down; End is nil while it lasts. -type Outage struct { - Start time.Time `json:"start"` - End *time.Time `json:"end,omitempty"` -} - -type record struct { - Name string `json:"name"` - Where string `json:"where"` // address:port the history is of - Hours []Hour `json:"hours"` - Outages []Outage `json:"outages,omitempty"` - Down bool `json:"down,omitempty"` - Since time.Time `json:"since"` // when the current state began - // Seen: it was up at least once. A target the proxy never reached - // (an address its network cannot see) shows down but sends no alert. - Seen bool `json:"seen,omitempty"` - Fails int `json:"fails,omitempty"` - First *time.Time `json:"firstFail,omitempty"` - Last time.Time `json:"last"` - // Began is the first check, kept for "watching since". - Began time.Time `json:"began,omitzero"` -} - -// Event is a change worth telling someone about. -type Event struct { - HostID string - Name string - Where string - Down bool - Since time.Time // when it went down (the first failed check) - Lasted time.Duration // on recovery: how long it was down - Example bool // a test sent from Settings -} - -// Monitor holds the history and runs the checks. -type Monitor struct { - path string - // Check reports whether the target answered and how fast; a TCP - // connect unless a test replaces it. - Check func(ctx context.Context, t Target) (bool, time.Duration) - // Notify is called (in its own goroutine) for each Event; nil sends - // nothing. - Notify func(Event) - now func() time.Time - - mu sync.Mutex - hosts map[string]*record - dirty bool - done chan struct{} -} - -type file struct { - Version int `json:"version"` - Hosts map[string]*record `json:"hosts"` -} - -// Open loads the history at path; a missing or unreadable file starts empty -// (it is a history, not configuration). -func Open(path string) *Monitor { - m := &Monitor{path: path, Check: dial, now: time.Now, hosts: map[string]*record{}, done: make(chan struct{})} - if raw, err := os.ReadFile(path); err == nil { - var f file - if json.Unmarshal(raw, &f) == nil && f.Hosts != nil { - m.hosts = f.Hosts - } - } - return m -} - -func dial(ctx context.Context, t Target) (bool, time.Duration) { - start := time.Now() - d := net.Dialer{Timeout: timeout} - c, err := d.DialContext(ctx, "tcp", t.where()) - if err != nil { - return false, 0 - } - c.Close() - return true, time.Since(start) -} - -// Run checks every target once a minute until ctx is done, then saves. A -// target that is new, or now points somewhere else, is checked at once. -func (m *Monitor) Run(ctx context.Context, targets func() []Target) { - defer close(m.done) - t := time.NewTicker(tick) - defer t.Stop() - saved := m.now() - for { - m.Round(ctx, targets()) - if m.now().Sub(saved) >= saveEvery { - m.save() - saved = m.now() - } - select { - case <-ctx.Done(): - m.save() - return - case <-t.C: - } - } -} - -// Done is closed once Run has saved for the last time. -func (m *Monitor) Done() <-chan struct{} { return m.done } - -// Round checks the targets that are due and forgets names that are gone. -// A skipped name's outage ends there: a Restore later, or checks turned -// back on, is not a recovery. -func (m *Monitor) Round(ctx context.Context, targets []Target) { - now := m.now() - known := map[string]bool{} - var due []Target - m.mu.Lock() - for _, t := range targets { - known[t.ID] = true - r := m.hosts[t.ID] - if t.Skip { - if r != nil && len(r.Outages) > 0 && r.Outages[len(r.Outages)-1].End == nil { - r.Outages[len(r.Outages)-1].End = &now - m.dirty = true - } - continue - } - if r == nil || r.Where != t.where() || now.Sub(r.Last) >= Every-tick/2 { - due = append(due, t) - } - } - for id := range m.hosts { - if !known[id] { - delete(m.hosts, id) - m.dirty = true - } - } - m.mu.Unlock() - - sem := make(chan struct{}, parallel) - var wg sync.WaitGroup - for _, t := range due { - wg.Add(1) - sem <- struct{}{} - go func(t Target) { - defer func() { <-sem; wg.Done() }() - ok, took := m.Check(ctx, t) - if ctx.Err() != nil { - return // shutting down: not an answer from the target - } - if e := m.record(t, ok, took, m.now()); e != nil && m.Notify != nil { - go m.Notify(*e) - } - }(t) - } - wg.Wait() -} - -// record adds one check and returns the change it made, if any. -func (m *Monitor) record(t Target, ok bool, took time.Duration, now time.Time) *Event { - m.mu.Lock() - defer m.mu.Unlock() - m.dirty = true - r := m.hosts[t.ID] - if r == nil { - r = &record{Since: now, Began: now} - m.hosts[t.ID] = r - } - if r.Where != t.where() { - // The app moved (a recreated container gets a new address and the - // app that made the name points it there): failures at the old - // address no longer count, but an outage goes on until the new - // one answers, so "back" is still said. - r.Where, r.Fails, r.First = t.where(), 0, nil - } - r.Name = t.Name - r.Last = now - r.Hours = addCheck(r.Hours, now, ok, took) - - ev := Event{HostID: t.ID, Name: t.Name, Where: t.where()} - if ok { - r.Fails, r.First = 0, nil - was := r.Down - if was || !r.Seen { - r.Since = now - } - r.Down, r.Seen = false, true - if was && len(r.Outages) > 0 && r.Outages[len(r.Outages)-1].End == nil { - o := &r.Outages[len(r.Outages)-1] - o.End = &now - ev.Since, ev.Lasted = o.Start, now.Sub(o.Start) - return &ev - } - return nil - } - r.Fails++ - if r.First == nil { - first := now - r.First = &first - } - if r.Fails < DownAfter || r.Down { - return nil - } - r.Down, r.Since = true, *r.First - if !r.Seen { - return nil - } - r.Outages = append(r.Outages, Outage{Start: *r.First}) - if len(r.Outages) > keepOutage { - r.Outages = r.Outages[len(r.Outages)-keepOutage:] - } - ev.Down, ev.Since = true, *r.First - return &ev -} - -func addCheck(hours []Hour, now time.Time, ok bool, took time.Duration) []Hour { - h := now.Truncate(time.Hour).Unix() - if n := len(hours); n == 0 || hours[n-1][0] != h { - hours = append(hours, Hour{h, 0, 0, 0}) - } - b := &hours[len(hours)-1] - b[1]++ - if ok { - b[2]++ - b[3] += took.Milliseconds() - } - cut := now.Add(-Keep).Unix() - i := 0 - for i < len(hours) && hours[i][0] < cut { - i++ - } - return hours[i:] -} - -// Summary is what the names list shows for one name. -type Summary struct { - // State is "up", "down" or "unknown" (not checked yet). - State string `json:"state"` - Since time.Time `json:"since,omitzero"` - // Never: down, and never seen up since it was added. - Never bool `json:"never,omitempty"` - // Percent of the checks that answered; nil where nothing was checked. - Day *float64 `json:"day"` - Week *float64 `json:"week"` - Month *float64 `json:"month"` - // AvgMs is the average connect time over the last day. - AvgMs *int64 `json:"avgMs,omitempty"` - // Hours are the last 24 hours' counts, oldest first. - Hours []Hour `json:"hours"` - // From is the first check still counted: a name watched for an hour - // has no 30 days to speak of. - From time.Time `json:"from,omitzero"` -} - -// Detail is one name's whole history. -type Detail struct { - Summary - Hours []Hour `json:"hours"` - Outages []Outage `json:"outages"` -} - -// Summaries returns one Summary per known name. -func (m *Monitor) Summaries() map[string]Summary { - m.mu.Lock() - defer m.mu.Unlock() - now := m.now() - out := make(map[string]Summary, len(m.hosts)) - for id, r := range m.hosts { - out[id] = summarize(r, now) - } - return out -} - -// Detail returns one name's history; false when it was never checked. -func (m *Monitor) Detail(id string) (Detail, bool) { - m.mu.Lock() - defer m.mu.Unlock() - r := m.hosts[id] - if r == nil { - return Detail{}, false - } - d := Detail{Summary: summarize(r, m.now()), Hours: append([]Hour{}, r.Hours...), Outages: append([]Outage{}, r.Outages...)} - return d, true -} - -func summarize(r *record, now time.Time) Summary { - s := Summary{State: "up", Since: r.Since, Hours: since(r.Hours, now.Add(-24*time.Hour))} - if len(r.Hours) > 0 { - s.From = time.Unix(r.Hours[0][0], 0).UTC() - if r.Began.After(s.From) { - s.From = r.Began - } - } - switch { - case r.Down: - s.State, s.Never = "down", !r.Seen - case !r.Seen: - s.State, s.Since = "unknown", time.Time{} - } - s.Day = percent(r.Hours, now.Add(-24*time.Hour)) - s.Week = percent(r.Hours, now.Add(-7*24*time.Hour)) - s.Month = percent(r.Hours, now.Add(-30*24*time.Hour)) - var up, ms int64 - for _, h := range s.Hours { - up += h[2] - ms += h[3] - } - if up > 0 { - avg := ms / up - s.AvgMs = &avg - } - return s -} - -// since returns the hours that start at or after from's hour. -func since(hours []Hour, from time.Time) []Hour { - cut := from.Truncate(time.Hour).Unix() - i := len(hours) - for i > 0 && hours[i-1][0] > cut { - i-- - } - return append([]Hour{}, hours[i:]...) -} - -func percent(hours []Hour, from time.Time) *float64 { - var checks, up int64 - for _, h := range since(hours, from) { - checks += h[1] - up += h[2] - } - if checks == 0 { - return nil - } - p := float64(up) * 100 / float64(checks) - return &p -} - -func (m *Monitor) save() { - m.mu.Lock() - if !m.dirty { - m.mu.Unlock() - return - } - data, err := json.Marshal(file{Version: 1, Hosts: m.hosts}) - m.dirty = false - m.mu.Unlock() - if err == nil { - err = writeAtomic(m.path, data) - } - if err != nil { - m.mu.Lock() - m.dirty = true - m.mu.Unlock() - } -} - -// Save writes the history now. -func (m *Monitor) Save() error { - m.save() - m.mu.Lock() - defer m.mu.Unlock() - if m.dirty { - return errors.New("uptime: could not write " + m.path) - } - return nil -} - -func writeAtomic(path string, data []byte) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".uptime-*.json") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmp.Name(), 0o600); err != nil { - return err - } - return os.Rename(tmp.Name(), path) -} diff --git a/admin/internal/uptime/uptime_test.go b/admin/internal/uptime/uptime_test.go deleted file mode 100644 index 564447a..0000000 --- a/admin/internal/uptime/uptime_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package uptime - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "path/filepath" - "sync" - "testing" - "time" -) - -var t0 = time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) - -// open starts an empty history whose clock reads t0 plus an hour, so the -// summaries look at the checks the test made. -func open(t *testing.T) *Monitor { - m := Open(filepath.Join(t.TempDir(), "uptime.json")) - m.now = func() time.Time { return t0.Add(time.Hour) } - return m -} - -var web = Target{ID: "h1", Name: "web.apps.test", Address: "10.0.0.5", Port: 80} - -func TestDownAfterTwoFailuresAndBack(t *testing.T) { - m := open(t) - if e := m.record(web, true, 3*time.Millisecond, t0); e != nil { - t.Fatalf("first up: %+v", e) - } - if e := m.record(web, false, 0, t0.Add(time.Minute)); e != nil { - t.Fatalf("one failure is not an outage: %+v", e) - } - e := m.record(web, false, 0, t0.Add(2*time.Minute)) - if e == nil || !e.Down || !e.Since.Equal(t0.Add(time.Minute)) { - t.Fatalf("down after two, since the first: %+v", e) - } - if e := m.record(web, false, 0, t0.Add(3*time.Minute)); e != nil { - t.Fatalf("still down says nothing: %+v", e) - } - s := m.Summaries()["h1"] - if s.State != "down" || !s.Since.Equal(t0.Add(time.Minute)) || s.Never { - t.Fatalf("summary while down: %+v", s) - } - e = m.record(web, true, 2*time.Millisecond, t0.Add(13*time.Minute)) - if e == nil || e.Down || e.Lasted != 12*time.Minute { - t.Fatalf("recovery: %+v", e) - } - d, _ := m.Detail("h1") - if len(d.Outages) != 1 || d.Outages[0].End == nil { - t.Fatalf("outage closed: %+v", d.Outages) - } -} - -// A blip (one failure between successes) is neither an outage nor an alert. -func TestBlip(t *testing.T) { - m := open(t) - for i, ok := range []bool{true, false, true, false, true} { - if e := m.record(web, ok, 0, t0.Add(time.Duration(i)*time.Minute)); e != nil { - t.Fatalf("check %d: %+v", i, e) - } - } - if s := m.Summaries()["h1"]; s.State != "up" || *s.Day != 60 { - t.Fatalf("blips: %+v day %v", s, *s.Day) - } -} - -// A target never seen up (an address the proxy cannot reach) shows down -// but never alerts, and has no outage to recover from. -func TestNeverReached(t *testing.T) { - m := open(t) - for i := range 5 { - if e := m.record(web, false, 0, t0.Add(time.Duration(i)*time.Minute)); e != nil { - t.Fatalf("never reached alerts: %+v", e) - } - } - if s := m.Summaries()["h1"]; s.State != "down" || !s.Never { - t.Fatalf("never reached: %+v", s) - } - if e := m.record(web, true, 0, t0.Add(6*time.Minute)); e != nil { - t.Fatalf("first answer is not a recovery: %+v", e) - } -} - -// The app moved while down (recreated, new address): the outage goes on -// and the first answer at the new address says it's back. -func TestTargetMovedWhileDown(t *testing.T) { - m := open(t) - m.record(web, true, 0, t0) - m.record(web, false, 0, t0.Add(time.Minute)) - m.record(web, false, 0, t0.Add(2*time.Minute)) - moved := web - moved.Address = "10.0.0.9" - if e := m.record(moved, true, 0, t0.Add(3*time.Minute)); e == nil || e.Down || e.Lasted != 2*time.Minute { - t.Fatalf("back at the new address: %+v", e) - } - if d, _ := m.Detail("h1"); d.State != "up" || d.Outages[0].End == nil || d.Hours[0][1] != 4 { - t.Fatalf("after the move: %+v", d) - } -} - -// Moved before it counted as down: one failure at the old address and one -// at the new one are not two in a row. -func TestTargetMovedBeforeDown(t *testing.T) { - m := open(t) - m.record(web, true, 0, t0) - m.record(web, false, 0, t0.Add(time.Minute)) - moved := web - moved.Address = "10.0.0.9" - if e := m.record(moved, false, 0, t0.Add(2*time.Minute)); e != nil { - t.Fatalf("old and new failures added up: %+v", e) - } -} - -func TestHoursRollAndTrim(t *testing.T) { - m := open(t) - m.record(web, true, 4*time.Millisecond, t0) - m.record(web, true, 6*time.Millisecond, t0.Add(30*time.Minute)) - m.record(web, false, 0, t0.Add(70*time.Minute)) - d, _ := m.Detail("h1") - if len(d.Hours) != 2 || d.Hours[0] != (Hour{t0.Unix(), 2, 2, 10}) || d.Hours[1] != (Hour{t0.Add(time.Hour).Unix(), 1, 0, 0}) { - t.Fatalf("hours: %v", d.Hours) - } - m.record(web, true, 0, t0.Add(Keep+2*time.Hour)) - if d, _ := m.Detail("h1"); len(d.Hours) != 1 { - t.Fatalf("older than %v kept: %v", Keep, d.Hours) - } -} - -// Hours with no checks (the proxy was off) count as nothing, not as down. -func TestGapsAreNoData(t *testing.T) { - m := open(t) - m.now = func() time.Time { return t0.Add(10 * time.Hour) } - m.record(web, true, 0, t0) - m.record(web, true, 0, t0.Add(9*time.Hour)) - s := m.Summaries()["h1"] - if *s.Day != 100 || len(s.Hours) != 2 || !s.From.Equal(t0) { - t.Fatalf("gap: day %v hours %v", *s.Day, s.Hours) - } -} - -func TestSaveAndOpen(t *testing.T) { - p := filepath.Join(t.TempDir(), "uptime.json") - m := Open(p) - m.record(web, true, 0, t0) - m.record(web, false, 0, t0.Add(time.Minute)) - m.record(web, false, 0, t0.Add(2*time.Minute)) - if err := m.Save(); err != nil { - t.Fatal(err) - } - again := Open(p) - again.now = func() time.Time { return t0.Add(3 * time.Minute) } - if s := again.Summaries()["h1"]; s.State != "down" { - t.Fatalf("after a restart: %+v", s) - } - // Still down after the restart: the outage it was in, not a new alert. - if e := again.record(web, false, 0, t0.Add(3*time.Minute)); e != nil { - t.Fatalf("restart re-alerted: %+v", e) - } - if e := again.record(web, true, 0, t0.Add(4*time.Minute)); e == nil || e.Lasted != 3*time.Minute { - t.Fatalf("recovery after restart: %+v", e) - } -} - -func TestRoundForgetsRemovedAndChecksNewAtOnce(t *testing.T) { - m := open(t) - now := t0 - m.now = func() time.Time { return now } - checked := 0 - m.Check = func(context.Context, Target) (bool, time.Duration) { checked++; return true, 0 } - m.Round(context.Background(), []Target{web}) - now = now.Add(20 * time.Second) - other := Target{ID: "h2", Name: "db.apps.test", Address: "10.0.0.6", Port: 5432} - m.Round(context.Background(), []Target{web, other}) - if checked != 2 { - t.Fatalf("checks: %d, want web once and the new one at once", checked) - } - m.Round(context.Background(), []Target{other}) - if _, ok := m.Summaries()["h1"]; ok { - t.Fatal("a purged name's history is kept") - } -} - -// A removed name (Undo window) keeps its history and is not checked; the -// outage it was in ends, so a Restore later is not "back after 3 h". -func TestRemovedKeepsHistory(t *testing.T) { - m := open(t) - now := t0 - m.now = func() time.Time { return now } - var mu sync.Mutex - up := true - checked := 0 - m.Check = func(context.Context, Target) (bool, time.Duration) { - mu.Lock() - defer mu.Unlock() - checked++ - return up, 0 - } - events := make(chan Event, 4) - m.Notify = func(e Event) { events <- e } - m.Round(context.Background(), []Target{web}) - mu.Lock() - up = false - mu.Unlock() - for range 2 { - now = now.Add(time.Minute) - m.Round(context.Background(), []Target{web}) - } - gone := web - gone.Skip = true - now = now.Add(time.Minute) - m.Round(context.Background(), []Target{gone}) - mu.Lock() - defer mu.Unlock() - if checked != 3 { - t.Fatalf("a removed name was checked: %d checks", checked) - } - d, ok := m.Detail("h1") - if !ok || d.Outages[0].End == nil { - t.Fatalf("removed: kept %v, outage %+v", ok, d.Outages) - } - up = true - mu.Unlock() - now = now.Add(3 * time.Hour) - m.Round(context.Background(), []Target{web}) - mu.Lock() - if e := <-events; !e.Down { - t.Fatalf("first event: %+v, want the down", e) - } - select { - case e := <-events: - t.Fatalf("restored sent %+v", e) - case <-time.After(50 * time.Millisecond): - } -} - -// The case seen once live: down, the proxy restarts, the app is re-pointed -// at a new address and answers. The recovery is still sent. -func TestRestartThenMoved(t *testing.T) { - p := filepath.Join(t.TempDir(), "uptime.json") - m := Open(p) - m.record(web, true, 0, t0) - m.record(web, false, 0, t0.Add(time.Minute)) - m.record(web, false, 0, t0.Add(2*time.Minute)) - if err := m.Save(); err != nil { - t.Fatal(err) - } - again := Open(p) - again.record(web, false, 0, t0.Add(4*time.Minute)) - moved := web - moved.Address = "10.0.0.9" - if e := again.record(moved, true, 0, t0.Add(5*time.Minute)); e == nil || e.Down || e.Lasted != 4*time.Minute { - t.Fatalf("recovery after a restart and a move: %+v", e) - } -} - -func TestSend(t *testing.T) { - var got *http.Request - var body []byte - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = r - body, _ = io.ReadAll(r.Body) - })) - defer srv.Close() - down := Event{Name: "web.apps.test", Where: "10.0.0.5:80", Down: true, Since: t0} - if err := Send(context.Background(), KindNtfy, srv.URL, down); err != nil { - t.Fatal(err) - } - if got.Header.Get("Title") != "web.apps.test is down" || got.Header.Get("Priority") != "high" || string(body) != "web.apps.test is not responding at 10.0.0.5:80." { - t.Fatalf("ntfy: %v %q", got.Header, body) - } - back := Event{Name: "web.apps.test", Where: "10.0.0.5:80", Since: t0, Lasted: 12 * time.Minute} - if err := Send(context.Background(), KindWebhook, srv.URL, back); err != nil { - t.Fatal(err) - } - var p map[string]any - if json.Unmarshal(body, &p) != nil || p["state"] != "up" || p["downSeconds"] != float64(720) || p["message"] != "web.apps.test is responding again, after 12 min." { - t.Fatalf("webhook: %s", body) - } - if err := Send(context.Background(), KindWebhook, srv.URL, Event{Example: true}); err != nil { - t.Fatal(err) - } - p = nil - if json.Unmarshal(body, &p) != nil || p["state"] != "test" || p["name"] != nil || p["url"] != nil { - t.Fatalf("a test names no host: %s", body) - } - fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "nope", 403) })) - defer fail.Close() - if err := Send(context.Background(), KindNtfy, fail.URL, down); err == nil { - t.Fatal("a refusal is not an error") - } -} diff --git a/admin/ui/embed.go b/admin/ui/embed.go deleted file mode 100644 index 3b12f88..0000000 --- a/admin/ui/embed.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package ui embeds the built Svelte app. `npm run build` in this directory -// fills dist/; the image build does the same in its node stage. -package ui - -import "embed" - -//go:embed all:dist -var Files embed.FS diff --git a/admin/ui/index.html b/admin/ui/index.html deleted file mode 100644 index 957931f..0000000 --- a/admin/ui/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Daemonless Reverse Proxy - - - -
- - - diff --git a/admin/ui/package-lock.json b/admin/ui/package-lock.json deleted file mode 100644 index fd38120..0000000 --- a/admin/ui/package-lock.json +++ /dev/null @@ -1,1840 +0,0 @@ -{ - "name": "reverse-proxy-ui", - "version": "0.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "reverse-proxy-ui", - "version": "0.2.0", - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.2.0", - "@tailwindcss/vite": "^4.3.3", - "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.56.8", - "svelte-check": "^4.7.3", - "tailwindcss": "^4.3.3", - "typescript": "~6.0.2", - "vite": "^8.2.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.152.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", - "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", - "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", - "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", - "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", - "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", - "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", - "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", - "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", - "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", - "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", - "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", - "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", - "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", - "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", - "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", - "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", - "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/load-config": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", - "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", - "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "deepmerge": "^4.3.1", - "magic-string": "^1.0.0", - "obug": "^2.1.0", - "vitefu": "^1.1.2" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.46.4", - "vite": "^8.0.0-beta.7 || ^8.0.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/node/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tsconfig/svelte": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", - "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", - "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", - "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "dev": true, - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", - "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", - "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.19", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/rolldown": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", - "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.152.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.12", - "@rolldown/binding-android-arm64": "1.2.12", - "@rolldown/binding-darwin-arm64": "1.2.12", - "@rolldown/binding-darwin-x64": "1.2.12", - "@rolldown/binding-freebsd-x64": "1.2.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", - "@rolldown/binding-linux-arm64-gnu": "1.2.12", - "@rolldown/binding-linux-arm64-musl": "1.2.12", - "@rolldown/binding-linux-ppc64-gnu": "1.2.12", - "@rolldown/binding-linux-s390x-gnu": "1.2.12", - "@rolldown/binding-linux-x64-gnu": "1.2.12", - "@rolldown/binding-linux-x64-musl": "1.2.12", - "@rolldown/binding-openharmony-arm64": "1.2.12", - "@rolldown/binding-win32-arm64-msvc": "1.2.12", - "@rolldown/binding-win32-x64-msvc": "1.2.12" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/svelte": { - "version": "5.57.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", - "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "@jridgewell/sourcemap-codec": "^1.6.0", - "@sveltejs/acorn-typescript": "^1.0.13", - "@types/estree": "^1.0.9", - "acorn": "^8.18.0", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.9.2", - "esm-env": "^1.2.1", - "esrap": "^2.3.6", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", - "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "^0.2.3", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.0.0 || ^6.0.0" - } - }, - "node_modules/svelte/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", - "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/vite/node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/zimmerframe": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", - "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/admin/ui/package.json b/admin/ui/package.json deleted file mode 100644 index 71fcab0..0000000 --- a/admin/ui/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "reverse-proxy-ui", - "private": true, - "version": "0.2.0", - "type": "module", - "scripts": { - "dev": "vite --host", - "build": "vite build", - "check": "svelte-check --tsconfig ./tsconfig.app.json" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.2.0", - "@tailwindcss/vite": "^4.3.3", - "@tsconfig/svelte": "^5.0.8", - "svelte": "^5.56.8", - "svelte-check": "^4.7.3", - "tailwindcss": "^4.3.3", - "typescript": "~6.0.2", - "vite": "^8.2.0" - } -} diff --git a/admin/ui/public/favicon.svg b/admin/ui/public/favicon.svg deleted file mode 100644 index 5f5b512..0000000 --- a/admin/ui/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/admin/ui/public/theme.js b/admin/ui/public/theme.js deleted file mode 100644 index 25729dc..0000000 --- a/admin/ui/public/theme.js +++ /dev/null @@ -1,11 +0,0 @@ -// Resolve the theme before the stylesheet paints, so a light-mode user sees -// no dark flash. A file, not inline: the admin's Content-Security-Policy -// allows scripts from itself only. The same key as fjord. -(function () { - var t; - try { t = localStorage.getItem('fjord-theme'); } catch (e) {} - if (t !== 'light' && t !== 'dark') { - t = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; - } - document.documentElement.setAttribute('data-theme', t); -})(); diff --git a/admin/ui/source-hash b/admin/ui/source-hash deleted file mode 100755 index 58448e7..0000000 --- a/admin/ui/source-hash +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -# One hash of the UI's sources, the same on every arch: the arm64 image build -# checks it against the amd64 image it takes the built UI from. -cd "$(dirname "$0")" && find index.html package.json package-lock.json svelte.config.js tsconfig.app.json tsconfig.json vite.config.ts public src -type f | LC_ALL=C sort | xargs sha256 -r | sha256 -q diff --git a/admin/ui/src/App.svelte b/admin/ui/src/App.svelte deleted file mode 100644 index 7b0f39e..0000000 --- a/admin/ui/src/App.svelte +++ /dev/null @@ -1,192 +0,0 @@ - - -{#snippet mark(size: number)} - -{/snippet} - -{#snippet sectionIcon(href: string)} - {#if href === '/hosts'} - - {:else if href === '/trust'} - - {:else} - - {/if} -{/snippet} - -{#snippet themeButton()} - -{/snippet} - -
- {#if withNav} - - {/if} - -
- -
-
- - {@render mark(22)} - PROXY - - {#if withNav} - - {@render themeButton()} - {:else if screen === 'trust'} - - Sign in - {@render themeButton()} - {:else} - {@render themeButton()} - {/if} -
-
- - -
- {#if withNav} - - {/if} - {#if screen === 'loading'} -
Loading…
- {:else if screen === 'setup'} - - {:else if screen === 'login'} - - {:else if screen === 'hosts'} - - {:else if screen === 'host-new'} - - {:else if screen === 'host-edit'} - - {:else if screen === 'settings'} - - {:else if screen === 'trust'} - - {:else if screen === 'link'} - -
-

This link is for an app

-

Paste it into the app you are linking, where it asks for a reverse proxy (in fjord: Settings, Names, the Reverse proxy box). It lets that app give names here. It works for ten minutes, once.

- {location.href} -
- {/if} -
-
-
- - diff --git a/admin/ui/src/Toasts.svelte b/admin/ui/src/Toasts.svelte deleted file mode 100644 index 76710ec..0000000 --- a/admin/ui/src/Toasts.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
- {#each toasts.items as t (t.id)} - -
- {t.text} - {#if t.action} - - {/if} - -
- {/each} -
diff --git a/admin/ui/src/app.css b/admin/ui/src/app.css deleted file mode 100644 index 2aed355..0000000 --- a/admin/ui/src/app.css +++ /dev/null @@ -1,115 +0,0 @@ -@import "tailwindcss"; - -/* fjord's palette, token for token, so the two apps are one look: GNOME - * HIG colours (Blue 3, Green 3, Red 2, Yellow 2 in dark; their darker steps - * in light), libadwaita neutrals. index.html stamps - * before paint (the stored choice, else the OS); dark is the base. */ -:root { - --bg: #1d1d20; - --card: #28282c; - --border: #3b3b40; - --inset: #161619; - --fg: #ffffff; - --fg-body: oklch(92.9% 0.013 255.508); - --fg-secondary: oklch(86.9% 0.022 252.894); - --muted: oklch(70.4% 0.04 256.788); - --dim: oklch(55.4% 0.046 257.417); - --accent: #3584e4; - --accent-hover: #4a90e9; - --accent-soft: #1f3550; - --accent-soft-fg: #9cc3ff; - --success: #33d17a; - --success-soft: #173a28; - --success-soft-fg: #7fe0a8; - --warning: #f8e45c; - --warning-soft: #3d3418; - --warning-soft-fg: #f8e45c; - --danger: #ed333b; - --danger-soft: #45201f; - --chip: #3b3b40; - --chip-fg: oklch(86.9% 0.022 252.894); - --toast: #f4f4f2; - --toast-fg: #1d1d1f; - --toast-action: #1f5fbf; - color-scheme: dark; -} -:root[data-theme='light'] { - --bg: #f6f6f8; - --card: #ffffff; - --border: #dcdce1; - --inset: #f0f0f3; - --fg: #121217; - --fg-body: #2a2a31; - --fg-secondary: #45454e; - --muted: #61616b; - --dim: #7c7c87; - --accent: #3584e4; - --accent-hover: #1c71d8; - --accent-soft: #eaf1fb; - --accent-soft-fg: #174a96; - --success: #26a269; - --success-soft: #e9f5ee; - --success-soft-fg: #14633a; - --warning: #e5a50a; - --warning-soft: #fbf1df; - --warning-soft-fg: #5a3500; - --danger: #c01c28; - --danger-soft: #fbe9e4; - --chip: #e8e8ec; - --chip-fg: #45454e; - --toast: #1d1d1f; - --toast-fg: #ffffff; - --toast-action: #9cc3ff; - color-scheme: light; -} - -@theme inline { - --color-bg: var(--bg); - --color-card: var(--card); - --color-border: var(--border); - --color-inset: var(--inset); - --color-fg: var(--fg); - --color-fg-body: var(--fg-body); - --color-fg-secondary: var(--fg-secondary); - --color-muted: var(--muted); - --color-dim: var(--dim); - --color-accent: var(--accent); - --color-accent-hover: var(--accent-hover); - --color-accent-soft: var(--accent-soft); - --color-accent-soft-fg: var(--accent-soft-fg); - --color-success: var(--success); - --color-success-soft: var(--success-soft); - --color-success-soft-fg: var(--success-soft-fg); - --color-warning: var(--warning); - --color-warning-soft: var(--warning-soft); - --color-warning-soft-fg: var(--warning-soft-fg); - --color-danger: var(--danger); - --color-danger-soft: var(--danger-soft); - --color-chip: var(--chip); - --color-chip-fg: var(--chip-fg); - --color-toast: var(--toast); - --color-toast-fg: var(--toast-fg); - --color-toast-action: var(--toast-action); - --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, "Noto Sans", sans-serif; -} - -html, body { height: 100%; } -body { margin: 0; background: var(--bg); color: var(--fg-body); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; } - -/* Visible keyboard focus; text fields use their border instead of a ring. */ -:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -input:focus-visible, textarea:focus-visible, select:focus-visible { outline: none; } - -/* Shared controls, at fjord's scale (14 px body, 12 px hints), with - * targets that are still tall enough to tap. */ -.input { @apply w-full box-border min-h-10 px-3 py-2 text-sm rounded-md border border-[color:var(--border)] bg-inset text-fg-body outline-none focus:border-accent; } -.btn, .btn-primary, .btn-soft, .btn-plain, .btn-danger { @apply inline-flex items-center justify-center min-h-10 px-4 py-2 rounded-lg font-medium text-sm no-underline cursor-pointer select-none disabled:opacity-50 disabled:cursor-default; } -.btn-primary { @apply bg-accent text-white hover:bg-accent-hover; } -.btn-soft { @apply bg-accent-soft text-accent-soft-fg hover:brightness-95; } -.btn-plain { @apply bg-transparent text-fg-secondary hover:bg-chip hover:text-fg; } -.btn-danger { @apply bg-transparent text-danger hover:bg-danger-soft; } -.card { @apply bg-card border border-[color:var(--border)] rounded-lg; } -.label { @apply block text-sm font-semibold text-fg-secondary mb-1.5; } -.hint { @apply text-xs text-muted leading-relaxed; } -.field-error { @apply text-xs text-danger mt-1.5; } -.chip { @apply inline-flex items-center text-xs px-2 py-0.5 rounded-full bg-chip text-chip-fg; } diff --git a/admin/ui/src/components/AlertSettings.svelte b/admin/ui/src/components/AlertSettings.svelte deleted file mode 100644 index d038c6b..0000000 --- a/admin/ui/src/components/AlertSettings.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - -
-

Uptime and alerts

-
-
- - {alerts?.enabled === false ? 'Off. Names are not checked, and no alerts are sent.' : 'Every name is checked once a minute.'} - {alerts?.enabled === false ? 'Their history is kept for when you turn it on again.' : "Down means two checks in a row got no answer. A name's own page can turn it off for that name."} - - {#if alerts}{/if} -
- {#if alerts?.enabled} -
-
- - {#if alerts?.kind} - When a name stops responding, and again when it's back, a message goes to {alerts.kind === 'ntfy' ? 'ntfy' : 'your webhook'} at {alerts.to}. - {:else} - Off. Get a message on your phone when a name stops responding, and again when it's back. - {/if} - - {#if !edit} - - {#if alerts?.kind} - - - - {:else} - - {/if} - - {/if} -
- {#if alerts?.lastError && !edit} -
The last alert was not delivered: {alerts.lastError}
- {/if} - {#if edit} -
- - {#each [['ntfy', 'ntfy'], ['webhook', 'Webhook']] as [k, label] (k)} - - {/each} - - -
{#if kind === 'ntfy'}The topic's address, on ntfy.sh or your own ntfy server. Pick a topic nobody would guess: anyone who knows it can read the messages.{:else}Gets a POST with JSON: name, state (down or up), target, since, message.{/if}
- {#if error}
{error}
{/if} -
- - - -
-
- {:else if error} -
{error}
- {/if} -
- {:else if error} -
{error}
- {/if} -
-
diff --git a/admin/ui/src/components/AppIcon.svelte b/admin/ui/src/components/AppIcon.svelte deleted file mode 100644 index baa8bdf..0000000 --- a/admin/ui/src/components/AppIcon.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -{#if host.icon && !broken} - - - (broken = true)} /> - -{:else} - -{/if} diff --git a/admin/ui/src/components/CertProvider.svelte b/admin/ui/src/components/CertProvider.svelte deleted file mode 100644 index ceadfef..0000000 --- a/admin/ui/src/components/CertProvider.svelte +++ /dev/null @@ -1,205 +0,0 @@ - - -{#if view === 'loading'} -
Loading…
- -{:else if view === 'form'} -
- {#if status?.message} -
- - {status.message} -
- {/if} -
-
Why this step? Let's Encrypt gives a certificate only to the owner of a domain. To prove you own {zone}, the proxy adds a short-lived note to its DNS and removes it again. For that it needs a token, a kind of password, from the company that runs {zone}'s DNS.
-
This token is only for the certificate. Your names keep working through your own network as they do today, and nothing is opened to the internet.
-
- {#if freeName} -
Your free name {zone} is at {provider?.label}.
- {:else} -
-
Who runs the DNS for {zone}?
- {#if guess?.provider && chosen === guess.provider} -
Found from its nameservers ({guess.nameservers.slice(0, 2).join(', ')}).
- {:else if guess && !guess.provider} -
Its nameservers ({guess.nameservers.slice(0, 2).join(', ')}) aren't one of the services below. If they're your own server, pick RFC 2136.
- {/if} -
- {#each offered as p (p.id)} - - {/each} - {#if !showAll} - - {/if} -
-
- {/if} - - {#if provider} -
- {#if provider.steps?.length} -
Get a token from {provider.label}
-
    - {#each provider.steps as step, i (i)} -
  1. {step.replaceAll('{zone}', zone)}{#if i === 0 && provider.url}
    Open {provider.label} ↗{/if}
  2. - {/each} -
- {:else} -
{provider.help}{#if provider.url} Open {provider.label} ↗{/if}
- {/if} - {#each provider.fields as f (f.key)} -
- - {#if f.secret} - - {:else} - - {/if} - {#if error && errorField === f.key}
{error}
{/if} -
- {/each} - {#if error && !provider.fields.some((f) => f.key === errorField)}
{error}
{/if} -
{provider.checked ? `Connect checks the token with ${provider.label} right away, then` : 'Connect'} tries it with a test certificate, so a wrong one costs nothing. The token stays on this proxy and is never shown again.
-
- {/if} - -
- {#if onback}{/if}{#if onskip}{/if} - -
- {#if onskip}
Skipping works like a private ending: the proxy signs its own certificates and each device trusts it once.
{/if} -
- -{:else if view === 'progress'} - {@const at = status?.state === 'issuing' ? 2 : 1} - {@const steps = [ - { title: provider?.checked ? `${provider.label} accepted the token` : 'Token saved', detail: '' }, - { title: `Proving you own ${zone}`, detail: `The proxy adds a short note to ${zone}'s DNS at ${provider?.label ?? 'your DNS provider'} and Let's Encrypt checks it with a test certificate. Waiting for the note to show up takes most of the time.` }, - { title: 'Getting the real certificate', detail: "The test worked. Let's Encrypt is issuing the real one; names open with a warning until it arrives." }, - { title: 'Done', detail: '' }, - ]} -
-
    - {#each steps as st, i (i)} -
  1. - {#if i < at} - - {:else if i === at} - - {:else} - - {/if} -
    -
    {st.title}{#if i === at} · {Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, '0')}{/if}
    - {#if i === at && st.detail}
    {st.detail}
    {/if} -
    -
  2. - {/each} -
-
Usually one to three minutes.
- {#if status?.state === 'issuing' && onok} -
- {/if} -
- -{:else if view === 'done'} -
-
- - Certificates are automatic from now on.{#if until}{' '}Valid until {until}; it renews itself.{/if} No device setup needed. -
- {#if onok}
{/if} -
- -{:else if view === 'failed'} -
-
- - {status?.message} -
- {#if status?.detail} - - {#if showDetail}
{status.detail}
{/if} - {/if} -
- {#if onback}{/if}{#if onskip}{/if} - -
-
-{/if} diff --git a/admin/ui/src/components/DnsGuide.svelte b/admin/ui/src/components/DnsGuide.svelte deleted file mode 100644 index 183977c..0000000 --- a/admin/ui/src/components/DnsGuide.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - -
- {#if !guide.complete} -
-
The record, piece by piece. Click a piece to copy it.
-
- {#each [['Host', '*'], ['Domain', domain], ['Address', ip || '…']] as [label, value]} - - {/each} - -
-
- {/if} - -
-
Where do your devices get DNS?
- -
- {#each resolvers as r (r.id)} - - {/each} -
-
- {#if guide.intro}

{guide.intro}

{/if} -
    - {#each guide.steps as step, i} -
  1. - {i + 1} - {#if typeof step === 'string'} - {fill(step)} - {:else} -
    - {fill(step.text)} -
    - {#each step.fields as [label, value] (label)} - - {/each} -
    -
    - {/if} -
  2. - {/each} -
- {#if guide.paste} - {@const text = guide.paste(domain, ip)} -
-
{guide.pasteLabel}
-
{text}
-
- {/if} - {#if guide.note}
{guide.note}
{/if} -
-
-
- - - {#if ok} - Names under {domain} reach this proxy. You're set. - {:else if dns === null && checking} - Checking… - {:else if dns?.notFound || (dns && dns.addresses.length === 0)} - Waiting for the record. Once it's saved, this turns green by itself within a few seconds. - {:else if dns} - Names under {domain} resolve to {dns.addresses.join(', ')}, not {ip || 'this proxy'}. If another proxy or an old record uses that ending, change the record to point here, or pick a different ending. - {:else} - Couldn't check right now; it will try again shortly. - {/if} - - {#if !ok} - - {/if} -
- - {#if short} -
-
Short links (optional): type {short}/radarr to open radarr.{domain}. Add one more name to your router, the same place as above:
- -
- {/if} - -
diff --git a/admin/ui/src/components/NetworkChoice.svelte b/admin/ui/src/components/NetworkChoice.svelte deleted file mode 100644 index 728c0a7..0000000 --- a/admin/ui/src/components/NetworkChoice.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - -{#each networks as n (n.network)} - {@const mine = n.network === here} - -{/each} -{#if !here}
Open this page from one of these networks to change this.
{/if} -{#if shut.length} -
Devices on {shut.map(netName).join(', ')} cannot open the proxy and are never home. It still reaches your apps there.
-{/if} diff --git a/admin/ui/src/components/PasswordStrength.svelte b/admin/ui/src/components/PasswordStrength.svelte deleted file mode 100644 index 0776138..0000000 --- a/admin/ui/src/components/PasswordStrength.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -{#if st.level} -
-
- - {st.label} -
- {#if st.why}{st.why}{/if} -
-{/if} diff --git a/admin/ui/src/components/ShortLinkGuide.svelte b/admin/ui/src/components/ShortLinkGuide.svelte deleted file mode 100644 index 92d8448..0000000 --- a/admin/ui/src/components/ShortLinkGuide.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -
- {#if tabs} -
- {#each resolvers as r (r.id)} - - {/each} -
- {/if} -
    - {#each guide.steps as step, i} -
  1. - {i + 1} - {#if typeof step === 'string'} - {fill(step)} - {:else} -
    - {fill(step.text)} -
    - {#each step.fields as [label, value] (label)} - - {/each} -
    -
    - {/if} -
  2. - {/each} -
- {#if guide.note}
{fill(guide.note)}
{/if} - {#if !local}
lan is a guess: use the domain your router gives your network (in OPNsense, System → Settings → General → Domain).
{/if} - {#if current} - {@const waiting = current.notFound || current.addresses.length === 0} -
- - - {#if ok}{short} reaches this proxy. Try it: http://{short}/ - {:else if waiting}Waiting for {current.name}. Once it's saved, this turns green by itself within a few seconds. - {:else}{current.name} points at {current.addresses.join(', ')}, not this proxy{#if here.length}{' '}({here.join(', ')}){/if}. Change the record to point here.{/if} - -
- {:else} -
- - Checking {short}… -
- {/if} -
{#if !ok}Then try it: http://{short}/ should open this proxy.{/if} Type the http:// the first time; Safari and Firefox otherwise search the web for it.
-
diff --git a/admin/ui/src/components/UptimeBars.svelte b/admin/ui/src/components/UptimeBars.svelte deleted file mode 100644 index 1e0fa31..0000000 --- a/admin/ui/src/components/UptimeBars.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - {#each slots as s (s.start.getTime())} - - {/each} - diff --git a/admin/ui/src/components/UptimeCard.svelte b/admin/ui/src/components/UptimeCard.svelte deleted file mode 100644 index ab73eae..0000000 --- a/admin/ui/src/components/UptimeCard.svelte +++ /dev/null @@ -1,86 +0,0 @@ - - -
-
- - {#if !checking} - Not checked - {:else if u?.state === 'up'} - Up{u.since ? ` since ${when(u.since)}` : ''} - {:else if u?.state === 'down'} - {u.never ? 'Never reached' : `Down since ${when(u.since ?? '')}`} - {:else} - Checking… - {/if} -
- {#if allOff} -
Checks are off for every name. Turn them on in Settings.
- {:else if !watch} -
Not checked once a minute, and no alerts. {u && u.hours.length ? 'Its history is kept.' : ''} Save to apply.
- {/if} - {#if u && u.hours.length} -
- -
90 days agoToday
- {#if checking && u.from && !covers(u.from, 90)}
Watching since {when(u.from)}.
{/if} -
-
- {#each tiles as [k, v] (k)} -
{k}{v}
- {/each} -
- {#if checking && u.never} -
The proxy has not reached {address}, port {port} since this name was added. Check the address, and that the proxy's network can reach it. No alert is sent until it has answered once.
- {/if} - {#if outages.length} -
-
Outages
- {#each outages as o (o.start)} -
- {when(o.start)} - {o.end ? lasted(new Date(o.end).getTime() - new Date(o.start).getTime()) : 'still down'} -
- {/each} -
- {:else if checking && u.state !== 'unknown'} -
No outages. Checked once a minute: down means two checks in a row got no answer.
- {/if} - {/if} -
diff --git a/admin/ui/src/lib/api.ts b/admin/ui/src/lib/api.ts deleted file mode 100644 index 0de6030..0000000 --- a/admin/ui/src/lib/api.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Thin client for the admin API. Cookie sessions ride along automatically; -// a 401 anywhere sends the person to sign in. -export class ApiError extends Error { - status: number - field: string - detail: string - retryAfter: number - constructor(status: number, message: string, field = '', detail = '', retryAfter = 0) { - super(message) - this.status = status - this.field = field - this.detail = detail - this.retryAfter = retryAfter - } -} - -let onUnauthorized: () => void = () => {} - -/** Called on any 401 so the app can send the person to sign in. */ -export function setOnUnauthorized(fn: () => void) { onUnauthorized = fn } - -export async function api(method: string, path: string, body?: unknown): Promise { - // No answer at all (the proxy restarting, the network gone) is said as - // such, not as "Something went wrong". - let res: Response - try { - res = await fetch(path, { - method, - headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, - body: body === undefined ? undefined : JSON.stringify(body), - credentials: 'same-origin', - }) - } catch { - throw new ApiError(0, "Can't reach the proxy. It may be restarting: try again in a moment.") - } - const text = await res.text() - let data: any = null - try { data = text ? JSON.parse(text) : null } catch { data = null } - if (res.ok && text && data === null) { - // Not JSON: a redirect landed on a page (the port-80 trust page). - throw new ApiError(res.status, 'Unexpected answer from the proxy') - } - if (!res.ok) { - if (res.status === 401 && !path.startsWith('/api/v1/login') && !path.startsWith('/api/v1/setup/')) onUnauthorized() - throw new ApiError(res.status, data?.error ?? `Request failed (${res.status})`, data?.field ?? '', data?.detail ?? '', data?.retryAfter ?? 0) - } - return data as T -} - -export const get = (path: string) => api('GET', path) -export const post = (path: string, body?: unknown) => api('POST', path, body ?? {}) -export const put = (path: string, body: unknown) => api('PUT', path, body) -export const del = (path: string) => api('DELETE', path) - -export type Target = { address: string; port: number; https: boolean; acceptUntrusted: boolean } -export type Host = { - id: string - name: string - extraNames?: string[] - target: Target - access: 'home' | 'internet' - trustedNetworks?: string[] - ref?: string - by?: string // id of the key that made it; none when made by hand - ownOrigin?: boolean - icon?: string // ".": the app that made the name sent one - unwatched?: boolean // not checked for uptime, no alerts - createdAt: string - deletedAt?: string - names: string[] - url: string -} -export type Health = { reachable: boolean; https: boolean; untrustedCert: boolean; status?: number; self?: boolean } -export type Network = { interface: string; network: string; address: string } -export type Settings = { - domain: string; mode: '' | 'public' | 'private'; trustedNetworks: string[]; shortLinks?: string; localDomain?: string; fallbackUrl?: string - // The container's networks, the one this page came in on, and the ones it - // answers on ([] is every network; listenSetBy names RP_LISTEN when that decides). - networks?: Network[]; here?: string; listen?: string[]; listenSetBy?: string -} -export type Session = { authenticated: boolean; kind: string; setupRequired: boolean } -export type Status = { version: string; caddy: { running: boolean; version?: string }; setup: { complete: boolean }; domain?: string; mode?: string; hosts?: number; certificates?: string } -export type Token = { id: string; label: string; createdAt: string; lastUsedAt?: string; paired?: boolean; disabled?: boolean; url?: string; clientId?: string; names?: number } -export type FjordSource = { url: string; hasKey: boolean } -export type FjordService = { stack: string; label: string; title: string; state: string; address: string; port: number; webUI: boolean; ref: string; added?: string; addedName?: string } -export type TrustInfo = { domain: string; mode: string; httpPort: number; needed: boolean; instance?: string; proxyName?: string; fingerprint?: string; rootUrl?: string; notAfter?: string } -export type ProviderField = { key: string; label: string; secret: boolean; placeholder?: string; optional?: boolean } -export type FreeName = { suffix: string; signUp: string; steps: string[]; note?: string } -export type Provider = { id: string; label: string; fields: ProviderField[]; help: string; steps?: string[]; url?: string; generic?: boolean; checked?: boolean; free?: FreeName } -/** manual: own root · trial: proving the token · issuing: the real certificate is on its way · ok · failed */ -export type CertStatus = { state: 'manual' | 'trial' | 'issuing' | 'ok' | 'failed'; provider?: string; issuer?: string; notAfter?: string; daysLeft?: number; message?: string; detail?: string; since?: string; fix?: 'token' } -export type Certificates = { domain: string; mode: string; providers: Provider[]; provider?: string; status: CertStatus } -export type ProviderGuess = { zone: string; nameservers: string[]; provider?: string } -export type DnsCheck = { name: string; addresses: string[]; proxyAddresses?: string[]; notFound?: boolean; error?: string; short?: { name: string; addresses: string[]; notFound?: boolean } } -/** [hour start (unix s), checks, answered, sum of answered connect ms] */ -export type Hour = [number, number, number, number] -/** down after two failed checks in a row; never: not once reached since it was added */ -export type Uptime = { state: 'up' | 'down' | 'unknown'; since?: string; never?: boolean; day: number | null; week: number | null; month: number | null; avgMs?: number; hours: Hour[]; from?: string } -export type Outage = { start: string; end?: string } -export type UptimeDetail = Uptime & { outages: Outage[] } -export type Alerts = { enabled: boolean; kind: '' | 'ntfy' | 'webhook'; to: string; lastError?: string; lastErrorAt?: string } diff --git a/admin/ui/src/lib/copy.ts b/admin/ui/src/lib/copy.ts deleted file mode 100644 index 02d403d..0000000 --- a/admin/ui/src/lib/copy.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copy text to the clipboard. The async Clipboard API needs a secure -// context, and Chrome does not grant that to a page opened through a -// certificate warning or over plain HTTP, which is where the trust page -// lives; the old execCommand path still works there. -export async function copyText(text: string): Promise { - try { - if (window.isSecureContext && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - return true - } - } catch {} - try { - const ta = document.createElement('textarea') - ta.value = text - ta.setAttribute('readonly', '') - ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.left = '0'; ta.style.opacity = '0' - document.body.appendChild(ta) - ta.focus(); ta.select(); ta.setSelectionRange(0, text.length) - const ok = document.execCommand('copy') - document.body.removeChild(ta) - return ok - } catch { - return false - } -} diff --git a/admin/ui/src/lib/credits.ts b/admin/ui/src/lib/credits.ts deleted file mode 100644 index 0aec066..0000000 --- a/admin/ui/src/lib/credits.ts +++ /dev/null @@ -1,30 +0,0 @@ -// What ships in the image and under which license. Every entry was read -// from the component's own LICENSE file or package metadata, not assumed. -export type Credit = { name: string; role: string; license: string; url: string } - -export const ourLicense = { name: 'BSD-2-Clause', url: 'https://github.com/daemonless/reverse-proxy/blob/main/LICENSE' } - -export const credits: Credit[] = [ - { name: 'Caddy', role: 'The web server and reverse proxy doing the actual work: TLS, routing, certificates', license: 'Apache-2.0', url: 'https://github.com/caddyserver/caddy' }, - { name: 'caddy-dns/cloudflare', role: 'DNS module for Cloudflare', license: 'Apache-2.0', url: 'https://github.com/caddy-dns/cloudflare' }, - { name: 'caddy-dns/route53', role: 'DNS module for Amazon Route 53', license: 'MIT', url: 'https://github.com/caddy-dns/route53' }, - { name: 'caddy-dns/desec', role: 'DNS module for deSEC', license: 'MIT', url: 'https://github.com/caddy-dns/desec' }, - { name: 'caddy-dns/porkbun', role: 'DNS module for Porkbun', license: 'MIT', url: 'https://github.com/caddy-dns/porkbun' }, - { name: 'caddy-dns/hetzner', role: 'DNS module for Hetzner', license: 'MIT', url: 'https://github.com/caddy-dns/hetzner' }, - { name: 'caddy-dns/ovh', role: 'DNS module for OVH', license: 'MIT', url: 'https://github.com/caddy-dns/ovh' }, - { name: 'caddy-dns/gandi', role: 'DNS module for Gandi', license: 'MIT', url: 'https://github.com/caddy-dns/gandi' }, - { name: 'caddy-dns/rfc2136', role: 'DNS module for any RFC 2136 server (BIND, PowerDNS, Knot)', license: 'MIT', url: 'https://github.com/caddy-dns/rfc2136' }, - { name: 'caddy-dns/acmedns', role: 'DNS module for acme-dns', license: 'MIT', url: 'https://github.com/caddy-dns/acmedns' }, - { name: 'caddy-dns/duckdns', role: 'DNS module for DuckDNS (no license file upstream)', license: 'none stated', url: 'https://github.com/caddy-dns/duckdns' }, - { name: 'libdns/duckdns', role: 'DuckDNS client the module uses', license: 'MIT', url: 'https://github.com/libdns/duckdns' }, - { name: 'golang.org/x/crypto', role: 'argon2id password hashing and backup encryption', license: 'BSD-3-Clause', url: 'https://pkg.go.dev/golang.org/x/crypto' }, - { name: 'golang.org/x/sys', role: 'Used by x/crypto', license: 'BSD-3-Clause', url: 'https://pkg.go.dev/golang.org/x/sys' }, - { name: 'skip2/go-qrcode', role: 'The QR code on the trust page', license: 'MIT', url: 'https://github.com/skip2/go-qrcode' }, - { name: 'Svelte', role: 'This user interface', license: 'MIT', url: 'https://svelte.dev' }, - { name: 'FreeBSD', role: 'The operating system inside the image', license: 'BSD-2-Clause', url: 'https://www.freebsd.org' }, - { name: 's6, execline, skalibs', role: 'Process supervision inside the image', license: 'ISC', url: 'https://skarnet.org/software/s6/' }, - { name: 'ca_root_nss', role: 'Public certificate authorities, for the proxy to verify targets', license: 'MPL-2.0', url: 'https://www.freshports.org/security/ca_root_nss' }, -] - -// Built with (not shipped): Vite (MIT), Tailwind CSS (MIT), TypeScript (Apache-2.0), Go (BSD-3-Clause). -export const buildTools = 'Built with Vite, Tailwind CSS, TypeScript and Go, which are not part of the running image.' diff --git a/admin/ui/src/lib/dnsGuides.ts b/admin/ui/src/lib/dnsGuides.ts deleted file mode 100644 index 07c7c43..0000000 --- a/admin/ui/src/lib/dnsGuides.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Where to add the one DNS record, per resolver people actually run at -// home. Wording follows each product's own menus; anything we are not sure -// of says so rather than inventing a menu path. -export type Resolver = 'opnsense' | 'pfsense' | 'adguard' | 'pihole' | 'unifi' | 'other' - -export const resolvers: { id: Resolver; label: string }[] = [ - { id: 'opnsense', label: 'OPNsense' }, - { id: 'pfsense', label: 'pfSense' }, - { id: 'adguard', label: 'AdGuard Home' }, - { id: 'pihole', label: 'Pi-hole' }, - { id: 'unifi', label: 'UniFi' }, - { id: 'other', label: 'Something else' }, -] - -/** A step is a sentence, or a sentence and the form fields it fills in. */ -export type Step = string | { text: string; fields: [string, string][] } - -export type Guide = { - intro?: string - steps: Step[] - /** The guide fills in every value itself; no separate record box. */ - complete?: boolean - paste?: (domain: string, ip: string) => string - pasteLabel?: string - note?: string -} - -export const guides: Record = { - opnsense: { - complete: true, - steps: [ - 'Open OPNsense and go to Services → Unbound DNS → Overrides.', - 'Under Host Overrides, click + to add one.', - { text: 'Fill in the form:', fields: [['Host', '*'], ['Domain', '{DOMAIN}'], ['Type', 'A (IPv4 address)'], ['IP address', '{IP}']] }, - 'Untick Add PTR record. It is not needed here.', - 'Click Save, then Apply.', - ], - note: 'If Unbound is not your resolver (for example you forward to AdGuard Home), add the record there instead.', - }, - pfsense: { - complete: true, - intro: "pfSense's Host Overrides can't take a wildcard, so the record goes in the resolver's custom options.", - steps: [ - 'Open pfSense and go to Services → DNS Resolver → General Settings.', - 'Click Display Custom Options at the bottom and paste the lines below into the box.', - 'Save, then click Apply Changes.', - ], - paste: (domain, ip) => `server:\n local-zone: "${domain}" redirect\n local-data: "${domain} A ${ip}"`, - pasteLabel: 'Custom options', - }, - adguard: { - complete: true, - steps: [ - 'Open AdGuard Home and go to Filters → DNS rewrites.', - 'Click Add DNS rewrite.', - { text: 'Fill in:', fields: [['Domain', '*.{DOMAIN}'], ['Answer', '{IP}']] }, - 'Save.', - ], - }, - pihole: { - complete: true, - intro: "Pi-hole's Local DNS Records can't take a wildcard, so the record goes in a dnsmasq file.", - steps: [ - 'On the Pi-hole machine, create the file /etc/dnsmasq.d/99-reverse-proxy.conf with the line below.', - 'Run: pihole restartdns', - ], - paste: (domain, ip) => `address=/${domain}/${ip}`, - pasteLabel: '/etc/dnsmasq.d/99-reverse-proxy.conf', - note: 'Pi-hole 6 ignores /etc/dnsmasq.d until you turn on "misc.etc_dnsmasq_d" in Settings → All settings (expert mode).', - }, - unifi: { - intro: 'UniFi gateways can hold local DNS records in the Network app; the menu has moved between versions.', - steps: [ - 'Open the UniFi Network app and look for Local DNS Records (recent versions: Settings → Routing → DNS; older: under Networks or the Policy Table).', - 'Add a record: Domain *.{DOMAIN} pointing at {IP}.', - 'If your version refuses a wildcard (*), add two records for now, proxy.{DOMAIN} and the first host you create, both pointing at {IP}.', - ], - note: 'If your UniFi gateway forwards DNS to another server (AdGuard Home, Pi-hole), add the record there instead.', - }, - other: { - intro: 'Any resolver that lets you add a local name will do.', - steps: [ - 'In your router or DNS server, find the DNS section: Local DNS, Host names, Static DNS entries or similar.', - 'Add an A record for *.{DOMAIN} pointing at {IP}.', - "If it can't take a wildcard (*), add proxy.{DOMAIN} and each host you create, all pointing at {IP}.", - ], - note: 'For a quick test on one computer you can also add the names to its hosts file; other devices will still need the record.', - }, -} - - -// The short-link record: one plain name ({SHORT}, e.g. go) under the -// network's own domain ({LOCAL}, e.g. lan), so no wildcard tricks: every -// resolver's ordinary form takes it. -export const shortGuides: Record = { - opnsense: { - steps: [ - 'Open OPNsense and go to Services → Unbound DNS → Overrides.', - 'Under Host Overrides, click + to add one.', - { text: 'Fill in the form:', fields: [['Host', '{SHORT}'], ['Domain', '{LOCAL}'], ['Type', 'A (IPv4 address)'], ['IP address', '{IP}']] }, - 'Untick Add PTR record, click Save, then Apply.', - ], - }, - pfsense: { - steps: [ - 'Open pfSense and go to Services → DNS Resolver → General Settings.', - 'Under Host Overrides at the bottom, click Add.', - { text: 'Fill in the form:', fields: [['Host', '{SHORT}'], ['Parent domain of host', '{LOCAL}'], ['IP to return for host', '{IP}']] }, - 'Save, then click Apply Changes.', - ], - }, - adguard: { - steps: [ - 'Open AdGuard Home and go to Filters → DNS rewrites.', - 'Click Add DNS rewrite.', - { text: 'Fill in:', fields: [['Domain', '{SHORT}.{LOCAL}'], ['Answer', '{IP}']] }, - 'Save.', - ], - }, - pihole: { - steps: [ - 'Open Pi-hole and go to Local DNS Records (Pi-hole 6: Settings → Local DNS Records; older: Local DNS → DNS Records).', - { text: 'Add a record:', fields: [['Domain', '{SHORT}.{LOCAL}'], ['IP', '{IP}']] }, - 'Click Add.', - ], - }, - unifi: { - steps: [ - 'Open the UniFi Network app and go to Local DNS Records (recent versions: Settings → Routing → DNS).', - { text: 'Add a record:', fields: [['Domain', '{SHORT}.{LOCAL}'], ['IP address', '{IP}']] }, - 'Save.', - ], - }, - other: { - steps: [ - 'In your router or DNS server, find Local DNS, Host names or Static DNS entries.', - { text: 'Add a name:', fields: [['Name', '{SHORT}.{LOCAL}'], ['Address', '{IP}']] }, - ], - note: 'Some routers add their own domain by themselves: then the name is just {SHORT}.', - }, -} diff --git a/admin/ui/src/lib/domain.ts b/admin/ui/src/lib/domain.ts deleted file mode 100644 index 8c6cee4..0000000 --- a/admin/ui/src/lib/domain.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Mirrors the server's suffix rules so the setup screen can explain the -// consequence as the person types. -const privateSuffixes = ['.lan', '.internal', '.home.arpa', '.home', '.test', '.localdomain'] - -export type Kind = 'empty' | 'incomplete' | 'private' | 'public' | 'mdns' - -export function classify(domain: string): Kind { - const d = domain.trim().toLowerCase().replace(/\.$/, '') - if (!d) return 'empty' - if (d.endsWith('.local') || d === 'local') return 'mdns' - if (!d.includes('.')) return 'incomplete' - if (privateSuffixes.some((s) => d.endsWith(s) || d === s.slice(1))) return 'private' - return 'public' -} diff --git a/admin/ui/src/lib/focus.ts b/admin/ui/src/lib/focus.ts deleted file mode 100644 index 53a7d06..0000000 --- a/admin/ui/src/lib/focus.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Focus the first field of a single-purpose screen on arrival, so typing -// can start at once. An action rather than the autofocus attribute, which -// fights screen readers when a page has more than one landmark. -export function focus(node: HTMLElement) { - requestAnimationFrame(() => node.focus()) -} diff --git a/admin/ui/src/lib/paired.svelte.ts b/admin/ui/src/lib/paired.svelte.ts deleted file mode 100644 index e06565f..0000000 --- a/admin/ui/src/lib/paired.svelte.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { get, type Token } from './api' -import { session } from './session.svelte' - -// The connected apps: the one that installed this proxy (paired key) and -// any that linked with a code or a key. `value` is the installer, for the -// header's way back; `apps` is everyone, for chips and the home row. -let all = $state([]) -let loaded = $state(false) - -export const paired = { - get value() { return all.find((t) => t.paired) ?? null }, - get apps() { return all }, - /** The app that made a host: by the app's id in its ref - * ("::"), else by the key that made it, else, for a fjord - * ref from before ids, the installer; failing all, the ref's first part. - * "" for a host made by hand. */ - appFor(h: { ref?: string; by?: string }): string { - const ref = h.ref ?? '' - const [kind, id] = ref.split(':') - const byId = id ? all.find((t) => t.clientId && t.clientId === id) : undefined - if (byId) return byId.label - const byKey = h.by ? all.find((t) => t.id === h.by) : undefined - if (byKey) return byKey.label - if (kind === 'fjord') return all.find((t) => t.paired)?.label ?? kind - return kind - }, - get loaded() { return loaded }, - async load() { - if (!session.value?.authenticated) return - try { all = (await get<{ tokens: Token[] }>('/api/v1/tokens')).tokens } - catch { all = [] } - loaded = true - }, -} diff --git a/admin/ui/src/lib/password.ts b/admin/ui/src/lib/password.ts deleted file mode 100644 index 226b9a3..0000000 --- a/admin/ui/src/lib/password.ts +++ /dev/null @@ -1,38 +0,0 @@ -// How strong a new password looks, Fedora-style: the page says so and -// warns about a weak one, but the person decides. Rough on purpose: length -// counts most, then the obvious traps (common passwords, runs, repeats). - -export type Strength = { level: 0 | 1 | 2 | 3 | 4; label: string; why: string } - -// The most used passwords (public breach lists), plus this app's own words. -const common = new Set(`123456 123456789 12345678 password qwerty123 qwerty1 111111 12345 123123 -1234567 1234 1234567890 000000 555555 666666 123321 654321 7777777 123 777777 1111 987654321 -121212 abc123 112233 azerty 159753 1q2w3e4r 54321 pass@123 222222 qwertyuiop qwerty123456 0123456789 -1q2w3e 123qwe 1qaz2wsx 11111111 1234qwer aa123456 asdfghjkl 88888888 qwer1234 iloveyou password1 admin welcome -monkey dragon letmein football baseball sunshine princess master shadow superman michael trustno1 starwars -passw0rd qazwsx 123abc zaq12wsx 1qazxsw2 password123 changeme default secret hello hello123 freedom whatever -login abcdef abcd1234 computer internet killer charlie jordan23 batman maggie hunter cheese summer winter -proxy reverseproxy fjord daemonless homelab freebsd`.split(/\s+/)) - -const rows = ['abcdefghijklmnopqrstuvwxyz', '0123456789', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm'] - -function isRun(pw: string): boolean { - const p = pw.toLowerCase() - if (p.length < 4) return false - if (new Set(p).size <= 2 || /^(.+)\1+$/.test(p)) return true - return rows.some((r) => r.includes(p) || [...r].reverse().join('').includes(p)) -} - -export function strength(pw: string): Strength { - if (!pw) return { level: 0, label: '', why: '' } - const bare = pw.toLowerCase().replace(/[^a-z0-9]/g, '') - if (common.has(pw.toLowerCase()) || common.has(bare) || common.has(bare.replace(/\d+$/, ''))) - return { level: 1, label: 'Weak', why: 'It is one of the most common passwords.' } - if (isRun(pw)) return { level: 1, label: 'Weak', why: 'Runs and repeats are guessed first.' } - const kinds = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((r) => r.test(pw)).length - const words = pw.trim().split(/\s+/).length - if (pw.length < 8) return { level: 1, label: 'Weak', why: 'Short passwords are guessed quickly.' } - if (pw.length >= 16 || (pw.length >= 12 && (kinds >= 2 || words >= 3))) return { level: 4, label: 'Strong', why: '' } - if (pw.length >= 12 || (pw.length >= 10 && kinds >= 3)) return { level: 3, label: 'Good', why: '' } - return { level: 2, label: 'Fair', why: 'Longer is stronger: a short sentence is easy to remember.' } -} diff --git a/admin/ui/src/lib/router.svelte.ts b/admin/ui/src/lib/router.svelte.ts deleted file mode 100644 index 4eb1797..0000000 --- a/admin/ui/src/lib/router.svelte.ts +++ /dev/null @@ -1,29 +0,0 @@ -// A few screens, no dependency: the path is state, links call navigate(). -let path = $state(location.pathname) - -window.addEventListener('popstate', () => { path = location.pathname }) - -export function navigate(to: string, replace = false) { - if (to === path) return - if (replace) history.replaceState(null, '', to) - else history.pushState(null, '', to) - // "/settings#dns": the path is what routes; the hash is the screen's. - path = to.split('#')[0] -} - -export const route = { - get path() { return path }, -} - -/** Intercepts clicks on same-origin links so the app routes them. */ -export function link(node: HTMLAnchorElement) { - const onClick = (e: MouseEvent) => { - if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - const href = node.getAttribute('href') - if (!href || !href.startsWith('/') || node.target === '_blank') return - e.preventDefault() - navigate(href) - } - node.addEventListener('click', onClick) - return { destroy: () => node.removeEventListener('click', onClick) } -} diff --git a/admin/ui/src/lib/session.svelte.ts b/admin/ui/src/lib/session.svelte.ts deleted file mode 100644 index bae3fa4..0000000 --- a/admin/ui/src/lib/session.svelte.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { get, type Session } from './api' - -let current = $state(null) - -export const session = { - get value() { return current }, - async refresh() { - // On the plain-HTTP trust page the API is not reachable; anything that - // is not a session answer means "not signed in". - try { - const s = await get('/api/v1/session') - current = s && typeof s.authenticated === 'boolean' ? s : { authenticated: false, kind: 'none', setupRequired: false } - } catch { - current = { authenticated: false, kind: 'none', setupRequired: false } - } - return current - }, - signedOut() { current = { authenticated: false, kind: 'none', setupRequired: current?.setupRequired ?? false } }, -} diff --git a/admin/ui/src/lib/theme.svelte.ts b/admin/ui/src/lib/theme.svelte.ts deleted file mode 100644 index de50cde..0000000 --- a/admin/ui/src/lib/theme.svelte.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Theme, the way fjord does it: is what the CSS keys off, -// index.html stamps it before first paint, this keeps it in sync after. -// Storage holds only an explicit choice; none means the OS drives. -export type Theme = 'light' | 'dark' -const KEY = 'fjord-theme' - -function stored(): Theme | null { - try { const v = localStorage.getItem(KEY); return v === 'light' || v === 'dark' ? v : null } catch { return null } -} -function system(): Theme { - try { return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark' } catch { return 'dark' } -} -function apply(t: Theme) { document.documentElement.setAttribute('data-theme', t); current = t } - -let current = $state((document.documentElement.getAttribute('data-theme') as Theme) || stored() || system()) - -export const theme = { - get value() { return current }, - set(t: Theme) { try { localStorage.setItem(KEY, t) } catch {} apply(t) }, - toggle() { theme.set(current === 'dark' ? 'light' : 'dark') }, -} - -// Follow the OS while nothing is chosen (a machine that flips at sunset). -try { - window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (stored() === null) apply(system()) }) -} catch {} diff --git a/admin/ui/src/lib/toast.svelte.ts b/admin/ui/src/lib/toast.svelte.ts deleted file mode 100644 index f17e879..0000000 --- a/admin/ui/src/lib/toast.svelte.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Toasts carry the Undo for destructive actions, per the HIG: no "Are you -// sure?" dialogs. -export type Toast = { id: number; text: string; action?: { label: string; run: () => void }; timer: number } - -let items = $state([]) -let seq = 0 - -export const toasts = { - get items() { return items }, -} - -export function toast(text: string, action?: Toast['action'], ms = action ? 8000 : 4000) { - const id = ++seq - const timer = window.setTimeout(() => dismiss(id), ms) - items = [...items, { id, text, action, timer }] - return id -} - -export function dismiss(id: number) { - const t = items.find((x) => x.id === id) - if (t) window.clearTimeout(t.timer) - items = items.filter((x) => x.id !== id) -} diff --git a/admin/ui/src/lib/trust.svelte.ts b/admin/ui/src/lib/trust.svelte.ts deleted file mode 100644 index 39da094..0000000 --- a/admin/ui/src/lib/trust.svelte.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { get, type TrustInfo } from './api' -import { session } from './session.svelte' - -// Does this device trust the proxy? One answer for the header's dot and -// the home page's status row. With public certificates there is nothing -// to trust (needed = false). -/** Who answers at a proxy's /trust/check: this proxy, another proxy that - * has the same name (an old DNS record, a second proxy with the same - * ending), or nobody this device trusts (null). */ -export async function whoAnswers(url: string, instance?: string): Promise<'ours' | 'other' | null> { - try { - const r = await fetch(url, { mode: 'cors', cache: 'no-store', signal: AbortSignal.timeout(4000) }) - const j = await r.json().catch(() => ({})) - // No id to compare with (this page talks to an older admin): take its word. - if (!instance) return 'ours' - return j.instance === instance ? 'ours' : 'other' - } catch { return null } -} - -let untrusted = $state(false) -let needed = $state(true) -let checked = $state(false) - -export const deviceTrust = { - get untrusted() { return untrusted }, - get needed() { return needed }, - get checked() { return checked }, - async check() { - if (!session.value?.authenticated) return - try { - const t = await get('/api/v1/trust') - needed = t.needed !== false - if (!needed) { untrusted = false; checked = true; return } - if (!t.proxyName || !t.fingerprint) return - untrusted = (await whoAnswers(`https://${t.proxyName}/trust/check`, t.instance)) !== 'ours' - checked = true - } catch {} - }, -} diff --git a/admin/ui/src/lib/trustSteps.ts b/admin/ui/src/lib/trustSteps.ts deleted file mode 100644 index db8c791..0000000 --- a/admin/ui/src/lib/trustSteps.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Per-device instructions for trusting the root. Every command block -// downloads, checks the fingerprint, and installs only when it matches, so -// a swapped download over plain HTTP installs nothing. -export type Device = 'windows' | 'mac' | 'linux' | 'freebsd' | 'iphone' | 'android' -export type Distro = 'debian' | 'fedora' | 'arch' - -export const devices: { id: Device; label: string }[] = [ - { id: 'windows', label: 'Windows' }, - { id: 'mac', label: 'Mac' }, - { id: 'linux', label: 'Linux' }, - { id: 'freebsd', label: 'FreeBSD' }, - { id: 'iphone', label: 'iPhone / iPad' }, - { id: 'android', label: 'Android' }, -] - -export function detectDevice(ua = navigator.userAgent): Device { - if (/iPhone|iPad|iPod/.test(ua)) return 'iphone' - if (/Android/.test(ua)) return 'android' - if (/Windows/.test(ua)) return 'windows' - if (/Mac OS X|Macintosh/.test(ua)) return 'mac' - if (/FreeBSD/.test(ua)) return 'freebsd' - return 'linux' -} - -// Firefox names the distribution (X11; Ubuntu; ...); Chrome does not, so -// Debian stays the default, being the most common. -export function detectDistro(ua = navigator.userAgent): Distro { - if (/Fedora|Red Hat|CentOS|SUSE/i.test(ua)) return 'fedora' - if (/Arch|Manjaro/i.test(ua)) return 'arch' - return 'debian' -} - -export type Guide = { - clicks: string[] // click-through steps, at most four - where?: string // where the command runs - command?: (base: string, fp: string, distro: Distro) => string - browsers?: string // browsers that keep their own trust store - remove: string[] - note?: string -} - -// `base` is http:///trust; `fp` is the colon-separated SHA-256. -// FILE is the certificate's file name, one per ending (apps-lan-proxy-root -// .crt, as the download is named): two proxies on two endings never -// overwrite each other, and a system store keeps both. `dst`, when the -// install copies the file somewhere, is checked first: a different -// certificate already there (an older root of this ending) is said out -// loud before it is replaced. -const shellCheck = (fetchCmd: string, install: string, dst = '') => - `if ${fetchCmd}; then - fp=$(openssl x509 -in FILE -noout -fingerprint -sha256 | cut -d= -f2) - if [ "$fp" = "FP" ]; then${dst ? ` - if [ -e "${dst}" ] && ! cmp -s FILE "${dst}"; then echo "Replacing an older DOMAIN proxy root at ${dst}."; fi` : ''} - ${install} && echo "Installed: this device now trusts DOMAIN." - else - echo "Fingerprint mismatch: $fp"; echo "Expected: FP"; echo "Not installed." - fi -else - echo "Download failed. If this is the machine running the proxy, it cannot reach its own" - echo "published ports: run this from another device instead." -fi` - -export const guides: Record = { - windows: { - clicks: [ - 'Download the certificate and open it.', - 'Choose Install Certificate, then Local Machine (Windows asks for permission).', - 'Choose "Place all certificates in the following store", Browse, and pick Trusted Root Certification Authorities.', - 'Finish. Windows shows the fingerprint on the way; compare it with the one on this page.', - ], - where: 'In PowerShell opened with "Run as administrator"', - command: (base, fp) => `$f = "$env:TEMP\\FILE.der" -try { Invoke-WebRequest "${base}/root.der" -OutFile $f -TimeoutSec 10 } catch { "Download failed: $_"; return } -$hash = (Get-FileHash $f -Algorithm SHA256).Hash -if ($hash -eq "${fp.replaceAll(':', '')}") { Import-Certificate -FilePath $f -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null; "Installed: this device now trusts DOMAIN." } else { "Fingerprint mismatch: $hash. Not installed." }`, - browsers: 'Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import the downloaded file and tick "Trust this CA to identify websites". Edge and Chrome use the Windows store and need nothing extra.', - remove: ['Open "Manage computer certificates" from the Start menu.', 'Trusted Root Certification Authorities → Certificates.', 'Right-click the proxy root and delete it.'], - }, - mac: { - clicks: [ - 'Download the certificate and open it. Keychain Access adds it to your login keychain.', - 'Double-click it in Keychain Access and open the Trust section.', - 'Set "When using this certificate" to Always Trust, close the window, and enter your password.', - ], - where: 'In Terminal', - command: (base, fp) => shellCheck( - `curl -fsS --max-time 10 -o FILE ${base}/root.crt`, - `if security find-certificate -c "DOMAIN proxy root" /Library/Keychains/System.keychain >/dev/null 2>&1; then echo "Replacing an older DOMAIN proxy root in the keychain."; sudo security delete-certificate -c "DOMAIN proxy root" /Library/Keychains/System.keychain >/dev/null 2>&1; fi - sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain FILE`, - ).replaceAll('FP', fp), - browsers: 'Safari and Chrome use the keychain. Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import the downloaded file and tick "Trust this CA to identify websites".', - remove: ['Open Keychain Access, search for the proxy root.', 'Delete it (it may be in both login and System).'], - }, - linux: { - clicks: [ - 'Download the certificate.', - 'Open a terminal in your Downloads folder.', - 'Run the command for your distribution.', - ], - where: 'In a terminal', - command: (base, fp, distro) => { - const dst = { debian: '/usr/local/share/ca-certificates/FILE', fedora: '/etc/pki/ca-trust/source/anchors/FILE', arch: '' }[distro] - const install = { - debian: `sudo cp FILE ${dst} && sudo update-ca-certificates`, - fedora: `sudo cp FILE ${dst} && sudo update-ca-trust`, - arch: `sudo trust anchor --store FILE`, - }[distro] - return shellCheck(`curl -fsS --max-time 10 -o FILE ${base}/root.crt`, install, dst).replaceAll('FP', fp) - }, - browsers: 'Chrome and Chromium on Linux keep their own store: install libnss3-tools (or nss-tools) and run certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "DOMAIN proxy root" -i FILE . Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import and tick "Trust this CA to identify websites".', - remove: ['Delete the file you copied (FILE in the directory above) and run the same update command again.', 'Chrome: certutil -d sql:$HOME/.pki/nssdb -D -n "DOMAIN proxy root". Firefox: delete it under Authorities.'], - }, - freebsd: { - clicks: [ - 'Download the certificate.', - 'Open a terminal in your Downloads folder.', - 'Run the command below. As root it just works; otherwise it uses doas or sudo, whichever you have.', - ], - where: 'In a terminal', - command: (base, fp) => shellCheck( - `fetch -T 10 -o FILE ${base}/root.crt`, - `if [ "$(id -u)" = 0 ]; then as=""; elif command -v doas >/dev/null; then as=doas; elif command -v sudo >/dev/null; then as=sudo; else echo "Run this as root (no doas or sudo found)."; as=false; fi - $as cp FILE /usr/local/share/certs/FILE && $as certctl rehash >/dev/null`, - '/usr/local/share/certs/FILE', - ).replaceAll('FP', fp), - browsers: 'Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import and tick "Trust this CA to identify websites". Chromium: certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "DOMAIN proxy root" -i FILE (security/nss).', - remove: ['As root: rm /usr/local/share/certs/FILE && certctl rehash'], - }, - iphone: { - clicks: [ - 'Tap Download certificate below (in Safari). Tap Allow, then Close.', - 'Open Settings. Near the top tap Profile Downloaded, then Install (enter your passcode).', - 'Back in Settings go to General → About → Certificate Trust Settings, and switch on the proxy root.', - ], - remove: ['Settings → General → VPN & Device Management → the proxy root → Remove Profile.'], - note: 'Step 3 is the one people miss: installing the profile is not enough on its own.', - }, - android: { - clicks: [ - 'Tap Download certificate below.', - 'Open Settings → Security (or Security & privacy) → More security settings → Encryption & credentials → Install a certificate → CA certificate.', - 'Tap Install anyway and choose the downloaded file.', - ], - remove: ['Settings → Security → Encryption & credentials → Trusted credentials → User tab → the proxy root → Remove.'], - note: 'Browsers will trust the proxy. Some apps ignore certificates you install yourself; if an app refuses to connect, that is why.', - }, -} diff --git a/admin/ui/src/lib/uptime.ts b/admin/ui/src/lib/uptime.ts deleted file mode 100644 index b63849f..0000000 --- a/admin/ui/src/lib/uptime.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Hour } from './api' - -/** One bar: its start, the checks in it, how many answered. */ -export type Slot = { start: Date; checks: number; up: number } - -/** The last n hours, oldest first, empty where nothing was checked. */ -export function hourSlots(hours: Hour[], n: number, now = new Date()): Slot[] { - const by = new Map(hours.map((h) => [h[0], h])) - const top = Math.floor(now.getTime() / 3600000) * 3600 - const out: Slot[] = [] - for (let i = n - 1; i >= 0; i--) { - const t = top - i * 3600 - const h = by.get(t) - out.push({ start: new Date(t * 1000), checks: h?.[1] ?? 0, up: h?.[2] ?? 0 }) - } - return out -} - -/** The last n days on this device's clock, oldest first. */ -export function daySlots(hours: Hour[], n: number, now = new Date()): Slot[] { - const key = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` - const by = new Map() - for (const h of hours) { - const d = new Date(h[0] * 1000) - const k = key(d) - const s = by.get(k) ?? { start: new Date(d.getFullYear(), d.getMonth(), d.getDate()), checks: 0, up: 0 } - s.checks += h[1]; s.up += h[2] - by.set(k, s) - } - const out: Slot[] = [] - for (let i = n - 1; i >= 0; i--) { - const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i) - out.push(by.get(key(d)) ?? { start: d, checks: 0, up: 0 }) - } - return out -} - -/** 99.95 -> "99.95%", 100 -> "100%", 12.3456 -> "12.3%". */ -export function pct(p: number | null | undefined): string { - if (p == null) return '—' - if (p === 100) return '100%' - if (p === 0) return '0%' - if (p >= 99) return `${(Math.floor(p * 100) / 100).toFixed(2)}%` - return `${(Math.floor(p * 10) / 10).toFixed(1)}%` -} - -/** Whether the history goes back the whole window. */ -export function covers(from: string | undefined, days: number, now = new Date()): boolean { - return !!from && new Date(from).getTime() <= now.getTime() - days * 86400000 -} - -/** "30 days", or "since 10:06" when the history is younger than that. */ -export function span(from: string | undefined, days: number, now = new Date()): string { - if (!from || covers(from, days, now)) return `${days} days` - return `since ${when(from, now)}` -} - -/** 40 s, 12 min, 3 h 5 min, 2 days */ -export function lasted(ms: number): string { - const m = Math.floor(ms / 60000) - if (m < 1) return `${Math.max(1, Math.floor(ms / 1000))} s` - if (m < 60) return `${m} min` - if (m < 48 * 60) return m % 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m / 60} h` - return `${Math.floor(m / 1440)} days` -} - -/** "14:02" today, "Tue 03:10" this week, "12 Oct 03:10" before. */ -export function when(iso: string, now = new Date()): string { - const d = new Date(iso) - const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - if (d.toDateString() === now.toDateString()) return time - if (now.getTime() - d.getTime() < 6 * 86400000) return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}` - return `${d.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${time}` -} diff --git a/admin/ui/src/main.ts b/admin/ui/src/main.ts deleted file mode 100644 index ad4f7f8..0000000 --- a/admin/ui/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { mount } from 'svelte' -import './app.css' -import App from './App.svelte' - -export default mount(App, { target: document.getElementById('app')! }) diff --git a/admin/ui/src/screens/HostForm.svelte b/admin/ui/src/screens/HostForm.svelte deleted file mode 100644 index 83a254a..0000000 --- a/admin/ui/src/screens/HostForm.svelte +++ /dev/null @@ -1,256 +0,0 @@ - - -
-
- - - Names - -
{editing ? 'Edit name' : 'Add a name'}
- -
-
- -{#if loaded} -
- {#if editing && savedTarget}{/if} -
-
-
-
Where is it?
-
- -
Paste its address as you open it today. Port and HTTPS are worked out.
- {#if errorField.startsWith('target')}
{error}
{/if} -
- {#if probing} - Looking{triedPorts.length > 1 ? ' on 443 and 80' : ''}… - {:else if probe?.self} - {#if !errorField.startsWith('target')} - {address} leads back to this proxy, so the name would point at itself. An old DNS entry for {address} probably still points here. Use the address the service has, like 192.168.1.20:7878. - {/if} - {:else if probe?.reachable} - - Found it: {https ? 'https' : 'http'}://{address}:{port}{acceptUntrusted ? ', its own certificate, accepted' : ''}. - {:else if probe && port} - Nothing answered at {address}, port {port}. You can still save it. - {:else if probe} - Nothing answered at {address} on 443 or 80. Add the port, like {address}:8080. - {/if} -
-
-
- -
- (nameEdited = true)} autocomplete="off" spellcheck="false" placeholder="radarr" required /> - .{settings?.domain} -
- {#if errorField === 'name'}
{error}
{/if} -
-
- -
-
Who can open it?
- - - {#if access === 'internet'} -
Anyone who knows the address can reach this service's sign-in page. Make sure the service has a strong password of its own, and that your router forwards ports 80 and 443 to the proxy.
- {/if} -
- -
- Advanced -
- - {#if https} - - {/if} - {#if !editing} - - {/if} - -
- - -
Separate with commas. Each becomes another address under .{settings?.domain}.
- {#if errorField === 'extraNames'}
{error}
{/if} -
-
- - - {#if errorField === 'trustedNetworks'}
{error}
{/if} -
-
-
- - {#if error && !errorField} -
{error}
- {/if} - - {#if editing} -
- -
- {/if} - - -{/if} diff --git a/admin/ui/src/screens/Hosts.svelte b/admin/ui/src/screens/Hosts.svelte deleted file mode 100644 index e1057c9..0000000 --- a/admin/ui/src/screens/Hosts.svelte +++ /dev/null @@ -1,295 +0,0 @@ - - -
-
-

Names

- {#if hosts.length > 0} - - - - - - Add a name - - {/if} -
- - {#if loaded && settings} - {#if checking} -
Checking the network and this device…
- {:else if allGood} -
All good: {goodLine}.
- {:else} - -
- {#if dnsIssue} -
- - Nothing on your network answers for *.{settings.domain} yet: names won't open on your devices until the record exists. - Add the DNS record -
- {/if} - {#if trustIssue} -
- - This device doesn't trust the proxy yet: names open with a warning here. Once per device. - Trust it -
- {/if} - {#if certPending} -
- - {cert?.state === 'trial' ? 'Checking your DNS token with a test certificate…' : "Getting your certificate from Let's Encrypt. Names open with a warning until it arrives, usually within a couple of minutes."} -
- {/if} - {#if certIssue} -
- - {cert?.message ?? 'Could not get a certificate.'} - Fix in Settings -
- {/if} - {#if certWarn} - - {/if} - {#if pairedIssue && paired.value} -
- - {paired.value.label} is disconnected: it can't add or change names until you reconnect it. - Reconnect -
- {/if} -
- {/if} - {/if} - - {#if !loaded} -
Loading…
- {:else if hosts.length === 0 && paired.value && !paired.value.disabled} -
-

Give an app a name in {paired.value.label}

-

It shows up here, like radarr.{settings?.domain}, pointed at the app and kept pointed at it. Something not in {paired.value.label} gets a name here instead.

-
- Add a name by hand - {#if paired.value.url}Open {paired.value.label}{/if} -
-
- {:else if hosts.length === 0} -
-

Put a name in front of your first service

-

A name is one address, like radarr.{settings?.domain}, that opens one service on your network, over HTTPS.

- Add a name -
- {:else} - - {#snippet history(h: Host)} - {@const u = watched(h) ? uptime[h.id] : undefined} - {#if u && u.month != null} - - - - {pct(u.month)} · {span(u.from, 30)} - - {/if} - {/snippet} - {#snippet dot(st: { ok: boolean | null })} - - {/snippet} - - {#snippet chips(h: Host)} - {#if paired.appFor(h)} - - From {paired.appFor(h)} - {/if} - {#if h.access === 'internet'} - Open to the internet - {/if} - {/snippet} - {#snippet actions(h: Host)} - - Open ↗ - - - - - {/snippet} - {#if cards} -
- {#each hosts as h (h.id)} - {@const st = statusText(h)} - {@const [label, ...rest] = h.names[0].split('.')} - - {/each} -
- {:else} -
- {#each hosts as h, i (h.id)} - {@const st = statusText(h)} - -
- {@render dot(st)} - - - {h.names[0]} - {st.text} - {@render history(h)} - - - {@render chips(h)} - {@render actions(h)} - -
- {/each} -
- {/if} - {/if} - - {#if loaded && removed.length} -
- - {#if showRemoved} -
- {#each removed as h, i (h.id)} -
- - {h.names[0]} - Went to {h.target.address}, port {h.target.port} - - -
- {/each} -
- {/if} -
- {/if} -
diff --git a/admin/ui/src/screens/Login.svelte b/admin/ui/src/screens/Login.svelte deleted file mode 100644 index df274a5..0000000 --- a/admin/ui/src/screens/Login.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
-
-
-
-
- -
-
-

Sign in to the reverse proxy

-

{location.host}

-
-
-
- - - {#if error}
{wait > 0 ? `Too many attempts. Try again in ${wait} seconds.` : error}
{/if} -
-
-
- Forgot it? -

On the machine running the proxy, run podman exec reverse-proxy reverse-proxy reset-password. It prints a new setup token.

-
- -
-
-
-
diff --git a/admin/ui/src/screens/Settings.svelte b/admin/ui/src/screens/Settings.svelte deleted file mode 100644 index f0dd964..0000000 --- a/admin/ui/src/screens/Settings.svelte +++ /dev/null @@ -1,595 +0,0 @@ - - -
-

Settings

- - {#if settings} -
- {#each [['network', 'Network'], ['proxy', 'This proxy']] as [id, label]} - - {/each} -
- - {#if tab === 'network'} -
-

Ending and certificates

-
-
-
- Ending for service names{settings.domain || 'Not set'} - -
- {#if settings.fallbackUrl && !editDomain} -
- If names ever stop openingThis address always opens the proxy. Keep it somewhere safe. - -
- {/if} - {#if editDomain} -
- - {#if suggestions.length} -
- Suggestions: - {#each suggestions as sug (sug)} - - {/each} -
- {/if} -
Every service becomes name.{newDomain || '…'}
- {#if newDomain && newDomain !== settings.domain} -
Changing the ending renames every host and creates a new root certificate. Your devices will need to trust the proxy again, and the DNS record must point the new ending here.{#if !proxyIp && settings.fallbackUrl}{' '}This page then continues at {settings.fallbackUrl}, where your browser warns about the certificate once. That is expected.{/if}
- {/if} - {#if domainError}
{domainError}
{/if} -
- -
-
- {/if} -
- {#if settings.domain} -
-
- - - - Names point at the proxy - {dnsOk ? `Your network's DNS sends *.${settings.domain} here.` : `Your network's DNS does not send *.${settings.domain} here yet.`} - - - - -
- {#if dnsOpen} (dnsOk = true)} />{/if} -
- {/if} -
-
- Secure connection - {#if settings.mode === 'private'} - Your own root certificate; devices trust it once. - {:else if settings.mode === 'public' && certs?.provider} - Automatic certificates via {certLabel}. - {#if certLine}{certLine}{/if} - {:else if settings.mode === 'public'} - Your own root certificate for now. Connect your domain's DNS and certificates become automatic, with no device setup. - {:else} - — - {/if} - - - {#if settings.mode === 'public'} - - {/if} - {#if trust?.rootUrl && trust.needed}Download certificate{/if} - -
- {#if certsOpen && settings.mode === 'public'} -
- { certsOpen = false; loadCerts(); loadTrust() }} /> - {#if certs?.provider} - - {/if} -
- {/if} - {#if trust?.fingerprint && trust.needed} -
-
{trust.fingerprint}
-
Valid until {new Date(trust.notAfter ?? '').toLocaleDateString()}. Keep a backup: a new root means every device trusts the proxy again.
-
- {/if} -
-
-
- - {#if settings.domain} -
-

Short links

-
-
- - {#if settings.shortLinks} - Type {settings.shortLinks}/radarr in a browser to open radarr.{settings.domain}. - Any of your names works, with its path: {settings.shortLinks}/radarr/settings. {settings.shortLinks}/ alone lists them. - {:else} - Off. Short links open a name from a short word, like go/radarr. - {/if} - - - {#if !shortEdit} - {#if settings.shortLinks}{/if} - - {/if} - -
- {#if shortEdit} -
{ e.preventDefault(); saveShort(shortWord.trim().toLowerCase()) }}> -
/radarr
- - -
- {/if} - {#if shortError}
{shortError}
{/if} - {#if settings.shortLinks} -
- - - {settings.shortLinks} points at the proxy - {#if shortOk}{dns?.short?.name} reaches this proxy.{:else}Your router needs one more name: {settings.shortLinks} pointing at this proxy{#if proxyAddr}{' '}({proxyAddr}){/if}.{/if} - - - -
- {#if shortHow}{/if} - {/if} -
-
- {/if} - -
-

Home network

-
- {#if (settings.networks?.length ?? 0) > 1 || settings.listen?.length} -
This proxy is on {settings.networks?.length} networks and reaches your apps on all of them. Devices on the ticked ones can open it and your home-only names.
- {#if settings.listenSetBy} -
Only {answering.map(netName).join(', ')}. {settings.listenSetBy} in the container's settings decides this.
- {:else} - - {/if} - {#if listenError}
{listenError}
{/if} -
Also homeNetworks that reach the proxy through your router, like a VPN.
- {:else} -
Devices on your network count as home. Add your VPN here so it counts too.
- {/if} - {#if settings.trustedNetworks.length} -
- {#each settings.trustedNetworks as n (n)} - {n} - {/each} -
- {/if} -
- - -
- {#if netError}
{netError}
{/if} -
-
- - {:else} - - -
-

Password

-
-
- Your passwordFor signing in to this proxy. - -
- {#if editPassword} -
-
- - -
-
- - - -
-
- - - {#if pwError}
{pwError}
{/if} -
- {#if strength(next).level === 1 && weakOk === next} -
This password is weak: {strength(next).why.toLowerCase().replace(/\.$/, '')}. Anyone who can open this page could guess it. Choose another, or use it anyway.
- {/if} -
-
- {/if} -
-
- -
-

Connected apps

-
- -
An app with a key adds, changes and removes names here, and nothing else. An app that installs this proxy, like fjord, connects by itself. API reference
- {#if paired || keys.length} -
    - {#if paired} -
  • - - {#if paired.disabled} - {paired.label}Disconnected - Installed this proxy. Can't add or change names until reconnected; the {fromPaired === 1 ? 'name' : `${fromPaired} names`} it made still work. - {:else} - {paired.label}Connected - Installed this proxy{fromPaired ? `; ${came(fromPaired)}` : ''}.{#if paired.url} · Open {paired.label}{/if} - {/if} - - {#if paired.disabled} - - {:else} - - {/if} -
  • - {/if} - {#each keys as t (t.id)} -
  • - - - {#if t.lastUsedAt} - {t.label}Connected - {#if t.clientId}Linked by code; {came(namesFrom(t))}.{:else}By key{namesFrom(t) ? `; ${came(namesFrom(t))}` : ''}, last used {new Date(t.lastUsedAt).toLocaleDateString()}.{/if}{#if t.url} · Open {t.label}{/if} - {:else} - {t.label}Not used yet - {#if t.clientId}Linked by code; it has not called yet.{:else}A key was made for it; nothing has called with it so far.{/if} - {/if} - - -
  • - {/each} -
- {:else} -
Nothing connected yet.
- {/if} - {#if freshToken} -
-
The key for {freshLabel}. Paste it where that app asks for this proxy's key; it won't be shown again.
- {freshToken} -
-
- {/if} - {#if pairLink} -
-
For the app you are linking.
-
Paste this link where the app asks for a reverse proxy (in fjord: Settings, Names, the Reverse proxy box) and press Link. Works once, within ten minutes.
- {pairLink} -
- - -
-
- {/if} -
- - An app that can link, like fjord on another machine, so its apps get names here too. For a script or anything else, make a key: -
-
- - -
- {#if appError}
{appError}
{/if} -
-
- -
-

Backup

-
-
-
Names, settings, password and root certificate in one file. A passphrase locks it; the file holds the root's private key.
-
- - -
- {#if backupError}
{backupError}
{/if} -
-
-
- Restore a backupReplaces everything here and signs you out. - -
- {#if restoreOpen} -
-
- - -
-
- - -
- {#if restoreError}
{restoreError}
{/if} -
- {/if} -
-
-
- - {/if} - -
- Daemonless Reverse Proxy {status?.version ?? ''} · Caddy {status?.caddy.version ?? ''} - - - - -
- - - -
-

Stop automatic certificates?

-

{liveNames === 1 ? 'Your name' : liveNames ? `Your ${liveNames} names` : 'Your names'} will use the proxy's own certificate. Every phone and computer will show a security warning until you set each one up to trust the proxy.

-

Your {certLabel} token will be deleted. To go back, you'll make a new one.

-
- - - -
-
-
- -
-
-
- -

Daemonless Reverse Proxy

{status?.version ?? ''} · Caddy {status?.caddy.version ?? ''}
-
- -
-

A daemonless appliance, released under the {ourLicense.name} license. It is built from the following software; each name links to its project.

-
    - {#each credits as c (c.name)} -
  • - {c.name}{c.role} - {c.license} -
  • - {/each} -
-

{buildTools}

-
-
- - {/if} -
diff --git a/admin/ui/src/screens/Setup.svelte b/admin/ui/src/screens/Setup.svelte deleted file mode 100644 index 3e5da5f..0000000 --- a/admin/ui/src/screens/Setup.svelte +++ /dev/null @@ -1,451 +0,0 @@ - - -
- {#if railIndex >= 0} -
    - {#each rail as r, i (r.id)} -
  1. - - {i < railIndex ? '✓' : i + 1} - - - {#if i < rail.length - 1}{/if} -
  2. - {/each} -
- {/if} - -
- {#if step === 'welcome'} -

Welcome

-

Daemonless Reverse Proxy puts friendly, secure addresses in front of the services on your network: radarr.home.example.com instead of an IP and a port, over HTTPS, with no warnings.

-
    - {#each ['Pick the ending for your service names.', 'Point names at the proxy with one DNS record (we show you exactly where).', 'Trust the proxy on your devices, once.'] as t, i} -
  1. - {i + 1} - {t} -
  2. - {/each} -
-

Takes about five minutes. Everything can be changed later in Settings.

-
- {#if verified}Verified from your link{:else}{/if} - - - - - -
- - {:else if step === 'restore'} -
-

Restore a backup

-

Names, settings, password and the root your devices trust come back.

-
- - -
-
- - - {#if error}
{error}
{/if} -
-
- - -
-
- - {:else if step === 'token' && installedBy && !pasteToken} -

Finish setup from {installedBy.name}

-

{installedBy.name} installed this proxy. Open it and press Finish setup on the proxy's row: this page opens again, already verified.

-
- - Open {installedBy.name} -
- - {:else if step === 'token'} -
-

First, let's make sure it's you

-

The proxy printed a setup token in its log when it started. Paste it here.

-
- - - {#if error}
{error}
{/if} -
-
- Where is the log? -

On the machine running the proxy: podman logs reverse-proxy (or docker logs). The token follows "setup token:" near the top.

-
-
- {#if installedBy}{:else}{/if} - -
-
- - {:else if step === 'password'} -
-

Choose a password

-

A short sentence is easy to remember and hard to guess.

-
- - - -
-
- - - {#if error}
{error}
{/if} -
- {#if weak && weakOk === password} -
This password is weak: {strength(password).why.toLowerCase().replace(/\.$/, '')}. Anyone who can open this page could guess it. Choose another, or use it anyway.
- {/if} -
-
- - {:else if step === 'networks'} -
-

Which is your home network?

-

This proxy is on {nets?.networks?.length} networks. It reaches your apps on all of them; devices on the ticked ones can open it and your home-only names.

-
- { chosen = on ? [...chosen, n] : chosen.filter((x) => x !== n) }} /> -
-

Not sure? Leave them all ticked. You can change this later in Settings.

- {#if error}
{error}
{/if} -
-
- - {:else if step === 'kind'} -
-

How should your names look?

-

Every service gets a name, like radarr.home.lan, that opens it securely. Pick the kind that suits you; you can change it later in Settings.

-
- {#each [ - { id: 'home', title: 'Just for home', example: 'radarr.home.lan', text: 'Free, nothing to sign up for, works without the internet. Each phone and computer trusts the proxy once; we walk you through it, with a QR code for phones. Visitors\' devices show a warning.' }, - { id: 'free', title: 'A free name from DuckDNS', example: 'radarr.you.duckdns.org', text: 'Every device trusts it out of the box: no setup on phones, and visitors\' devices just work. One free sign-in; we show you each click.' }, - { id: 'own', title: 'A domain I own', example: 'radarr.home.example.com', text: 'The same as a free name, on your own domain. Works with Cloudflare, Route 53, Porkbun, Hetzner and more.' }, - ] as o (o.id)} - - {/each} -
- {#if error}
{error}
{/if} -
- Not sure? Start with "Just for home". - -
-
- - {:else if step === 'free'} -
-

Get your free name

- {#if freeProviders.length > 1} -
- {#each freeProviders as p (p.id)} - - {/each} -
- {/if} - {#if free} - {#if free.note}

{free.note}

{/if} -
- -
- (freeName = freeName.toLowerCase().trim())} autocomplete="off" spellcheck="false" placeholder="you" required use:focus /> - .{free.suffix} -
- {#if freeName && !nameOk}
Letters, digits and dashes only, not starting or ending with a dash.
{/if} -
Your services will be at radarr.{freeName || 'you'}.{free.suffix}
-
-
    - {#each free.steps as st, i (i)} -
  1. {st.replace('{name}', freeName || 'your name')}{#if i === 0}{' '}Open {freeLabel}{/if}
  2. - {/each} -
  3. Come back here and click Continue. The next step asks for its token.
  4. -
- {/if} - {#if error}
{error}
{/if} -
- - -
-
- - {:else if step === 'address'} -
- {#if choice === 'own'} -

Which domain?

-

Names go under a part of your domain that nothing else uses. For example.com, home.example.com gives radarr.home.example.com.

- {:else} -

Pick an ending for your service names

-

Every service gets a name that ends the same way. You choose the ending now; each service's first part comes when you add it.

- {/if} -
-
Your services will be reached at
- {#each ['radarr', 'photos', 'home'] as ex} -
{ex}.{domain || 'apps.home.arpa'}
- {/each} -
-
- - - {#if error}
{error}
{/if} - {#if homeSuggestions.length && choice === 'home'} -
- Or one your network already uses: - {#each homeSuggestions as sug (sug)} - - {/each} -
- {/if} -
- {#if typing} -
- {:else if kind === 'private'} -
Private ending. Works without the internet. Each device trusts the proxy once; we'll walk you through it.
- {:else if kind === 'public'} -
A real domain. With a token for its DNS, certificates come from Let's Encrypt automatically and no device needs any setup. Nothing becomes reachable from the internet unless you say so.
- {:else if kind === 'mdns'} -
.local often doesn't work: many devices reserve it for their own discovery. Try .lan or .home.arpa.
- {:else if kind === 'incomplete'} -
An ending needs at least one dot, like apps.lan.
- {/if} -
- - -
-
- - {:else if step === 'certs'} -

Get certificates automatically

-

Every name under {domain} gets a certificate from Let's Encrypt that every device already trusts. It renews by itself.

- { certsConnected = true; step = 'dns' }} onskip={() => { certsConnected = false; step = 'dns' }} onback={() => { step = 'kind'; error = '' }} /> - - {:else if step === 'dns'} -

Point names at the proxy

-

One DNS record sends every name under {domain} to this proxy. Add it where your devices get DNS.

- (dnsOk = true)} /> -
- - -
- - {:else if step === 'trust'} -

Trust the proxy on your devices

-

Each device needs to trust the proxy's certificate once. After that, every service opens over HTTPS with no warnings. The Trust page walks each kind of device through it, and has a QR code for phones.

-
- - -
- - {:else} -

You're set

- {#if pairedWith} -

{pairedWith} is connected. Give an app a name there and it shows up here, pointed at the app; names it makes follow the app when it moves.

-
- - {#if pairedUrl}Back to {pairedWith}{/if} -
- {:else} -

Now put a name in front of your first service. You only need its address and port.

-
- {/if} - {/if} -
-
diff --git a/admin/ui/src/screens/Trust.svelte b/admin/ui/src/screens/Trust.svelte deleted file mode 100644 index 6386a7d..0000000 --- a/admin/ui/src/screens/Trust.svelte +++ /dev/null @@ -1,239 +0,0 @@ - - -
-
- {#if info && !info.needed} -

Is this device ready?

-

With your own domain there is nothing to install. Every name under {info.domain} opens securely on any device on your network.

- {:else} -

Trust {info?.domain ?? 'this proxy'} on this device

-

Do this once and every name under {info?.domain ?? 'your ending'} opens securely, with no warnings.

- {/if} -
- - {#if info && !info.needed} - {@const pending = cert?.state === 'trial' || cert?.state === 'issuing'} -
- - {#if reach === 'trusted'} - This device opens {info.proxyName} securely. You're done here.{#if onAddress} - This page is open by address, which always says "Not secure". Names like {info.proxyName} are the secure ones.{/if} - Open {info.proxyName} - {:else if reach === 'elsewhere'} - On this device, {info.proxyName} leads to a different proxy, not this one. Point the DNS record for {info.domain} at this proxy, or turn the other one off. - Check the DNS record - {:else if reach === 'no-dns'} - Nothing on your network answers for {info.proxyName} yet. Add the DNS record first, then come back; this page checks again by itself. - Add the DNS record - {:else if reach === 'untrusted' && pending} - The certificate for {info.domain} is still on its way from Let's Encrypt. Names open with a warning until it arrives, usually within a few minutes. This page checks again by itself. - {:else if reach === 'untrusted' && cert?.state === 'failed'} - The certificate for {info.domain} didn't come through: {cert.message} - Open certificates - {:else if reach === 'untrusted'} - This device can't open {info.proxyName} securely yet. This page checks again by itself. - {:else} - Checking this device… - {/if} -
-
-

Why there is nothing to install

-

The certificates for {info.domain} come from Let's Encrypt, which phones, computers and TVs already trust. They renew by themselves.

-

A device only needs to be on your home network, so that {info.proxyName} and the other names lead to this proxy.

-
- {:else if info && !info.fingerprint} -
Finish setup first; the certificate is created with your address.
- {:else if info} - -
- - {#if reach === 'trusted'} - This device trusts the proxy. You're done here.{#if onAddress} - This page is open by address, which always says "Not secure". Names like {info.proxyName} are the secure ones.{/if} - Open {info.proxyName} - {:else if reach === 'untrusted'} - This device doesn't trust the proxy yet. This page checks again by itself as you go. - {:else if reach === 'elsewhere'} - On this device, {info.proxyName} leads to a different proxy, not this one. Point the DNS record for {info.domain} at this proxy, or turn the other one off. - Check the DNS record - {:else if reach === 'no-dns'} - Nothing on your network answers for {info.proxyName} yet. Add the DNS record first, then come back; this page checks again by itself. - Add the DNS record - {:else} - Checking whether this device trusts the proxy… - {/if} -
- -
- {#each devices as d (d.id)} - - {/each} -
- -
-
-
- -
{device === 'windows' || device === 'mac' ? 'With a few clicks' : 'Steps'}
-
    - {#each guide.clicks as step, i} -
  1. - {i + 1} - {step} -
  2. - {/each} -
- {#if guide.note}
{guide.note}
{/if} - Download certificate -
- - {#if guide.command} -
-
-
Or paste one command
- -
-
- {guide.where}. - {#if device === 'linux'} - - {/if} -
-
-
{command}
-
-
It checks the fingerprint and installs nothing if it doesn't match.
-
- {/if} - - {#if guide.browsers} -
-
Some browsers keep their own list
-
{fill(guide.browsers)}
-
- {/if} -
- -
- {#if !onPhone} -
-
Set up a phone
- QR code for {base} -
Point the phone's camera here while it's on your home Wi-Fi.
-
- {/if} -
-
Check it's the right certificate
-
{info.fingerprint}
-
Your device shows this when you install it. The command checks it for you.
-
- - {#if showRemove} -
-
To remove it later
-
    - {#each guide.remove as step}
  1. {fill(step)}
  2. {/each} -
-
- {/if} -
-
- {/if} -
diff --git a/admin/ui/svelte.config.js b/admin/ui/svelte.config.js deleted file mode 100644 index 0cf7db3..0000000 --- a/admin/ui/svelte.config.js +++ /dev/null @@ -1,2 +0,0 @@ -/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ -export default {} diff --git a/admin/ui/tsconfig.app.json b/admin/ui/tsconfig.app.json deleted file mode 100644 index fb44e5f..0000000 --- a/admin/ui/tsconfig.app.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "es2023", - "module": "esnext", - "types": ["svelte", "vite/client"], - "allowArbitraryExtensions": true, - "noEmit": true, - "allowJs": true, - "checkJs": true, - "strict": true, - "moduleDetection": "force" - }, - "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] -} diff --git a/admin/ui/tsconfig.json b/admin/ui/tsconfig.json deleted file mode 100644 index 82a8007..0000000 --- a/admin/ui/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "files": [], - "references": [{ "path": "./tsconfig.app.json" }] -} diff --git a/admin/ui/vite.config.ts b/admin/ui/vite.config.ts deleted file mode 100644 index dfd93a2..0000000 --- a/admin/ui/vite.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - plugins: [tailwindcss(), svelte()], - server: { - allowedHosts: true, - proxy: { - // changeOrigin stays false: the admin compares Origin with Host for - // cookie-authenticated writes, so rewriting Host would 403 every POST. - '/api': { target: process.env.RP_DEV_TARGET ?? 'https://localhost:8443', changeOrigin: false, secure: false }, - }, - }, -}) diff --git a/compose.yaml b/compose.yaml index 3b13e5a..d029104 100644 --- a/compose.yaml +++ b/compose.yaml @@ -36,7 +36,7 @@ x-daemonless: - **Scriptable.** A key per app or script manages names and nothing else; there is an OpenAPI reference and a command line inside the container. - **Easy to keep.** One encrypted backup file holds everything; restore it on a fresh install. Delete a name by mistake and Undo brings it back. - Native FreeBSD (amd64 and arm64), built from source. The web UI runs unprivileged; only Caddy is root, to bind ports 80 and 443. + Native FreeBSD (amd64 and arm64). Caddy is built from source; the admin and web UI come from [daemonless/reverse-proxy-src](https://github.com/daemonless/reverse-proxy-src) releases. The web UI runs unprivileged; only Caddy is root, to bind ports 80 and 443. notes: | ### First run @@ -111,6 +111,42 @@ x-daemonless: lists the apps that have no name yet and names the ones you pick, and keeps each name pointing at its app when the app's address changes. + ### Names from container labels + + A container on the same machine can ask for its name with labels, the + way Traefik users expect, without fjord or a key. Turn it on by giving + the proxy podman's socket: the `RP_PODMAN_DIR` line under `volumes` + mounts the host's `/var/run/podman` (the directory, so the socket + survives a `podman_service` restart). In fjord it is the "Name + containers by their labels" switch. The proxy's root unit reads the + socket; the web UI never does. `:ro` does not limit a socket: whoever + holds it can run podman, so it goes only to a proxy you trust with that. + Settings can turn names from labels off while the socket is mounted. + + | Label | Means | + |---|---| + | `io.daemonless.proxy=true` | Name it after the container (`radarr` becomes `radarr.`). Enough for a daemonless image: the port is the image's own. | + | `io.daemonless.proxy.name=movies` | A different name. | + | `io.daemonless.proxy.port=7878` | The port, when the image declares none or several. | + | `io.daemonless.proxy.names=films,cinema` | More names for the same container. | + | `io.daemonless.proxy.public=true` | Open it to the internet (with a public ending). | + | `io.daemonless.proxy.https=true` | The container itself speaks HTTPS; its own certificate is accepted. | + | `io.daemonless.proxy.own-origin=true` | The service expects its own address in Origin and Referer (UniFi). | + + The proxy reaches the container on a network they share (the default + `podman` bridge: no port needs publishing), else through a published + port on the host, and says which on the Names page. The labels own the + name, port and access, shown read-only; trusted networks, own address, + the icon and the uptime check are edited in the proxy and survive a + `podman-compose up`. A stopped or removed container (fjord's Stop and + `compose down` remove it) keeps its name for 7 days and shows as down; + a recreated one (new address, or a new name in its labels) is followed. + After 7 days gone the name goes, restorably for a day. A label the + proxy cannot honor, a name another container or a hand-made name + already has, or a container it cannot reach is listed on the Names + page with the reason, never guessed at. Settings → Connected apps shows + whether podman is being read. + ### Your own app An app that manages services, like a jail or VM manager, can do what @@ -171,6 +207,18 @@ x-daemonless: client_url: ${RP_PAIRING_URL} client_id: ${RP_PAIRING_ID} client_name: ${RP_PAIRING_NAME} + choices: + container_labels: + label: Names from container labels + doc: Containers on this machine with io.daemonless.proxy labels get names here. The proxy reads podman's socket; the web UI never does. + default: "off" + options: + "off": + label: "Off" + "on": + label: "On: the proxy reads podman's socket" + env: { RP_PODMAN_DIR: /var/run/podman } + docs: env: PUID: "User ID for the admin process (Caddy stays root to bind 80 and 443)" @@ -213,6 +261,10 @@ services: # "always", not "unless-stopped": FreeBSD's podman rc.d only auto-starts # containers with restart-policy=always at boot (needs podman_enable=YES). restart: always + # SELinux (Fedora, RHEL): lets the proxy use its folders and podman's + # socket. Ignored where there is no SELinux. + security_opt: + - label=disable environment: - PUID=1000 @@ -229,6 +281,9 @@ services: volumes: - /path/to/containers/reverse-proxy:/config - /path/to/containers/reverse-proxy/data:/data + # Names from container labels: point RP_PODMAN_DIR at the host's + # /var/run/podman (the directory). Empty directory: off. + - ${RP_PODMAN_DIR:-/path/to/containers/reverse-proxy/podman}:/var/run/podman:ro ports: - 80:80 diff --git a/root-linux/etc/services.d/podman-labels/run b/root-linux/etc/services.d/podman-labels/run new file mode 100755 index 0000000..11aa5d1 --- /dev/null +++ b/root-linux/etc/services.d/podman-labels/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Names containers by their labels. Root, like Caddy: podman's socket is +# root's. Idles when no socket is mounted; the README says how to mount it. +exec /usr/local/bin/reverse-proxy \ + -listen /var/run/reverse-proxy/admin.sock \ + podman-labels 2>&1 diff --git a/root/daemonless/libexec/podman-labels b/root/daemonless/libexec/podman-labels new file mode 100755 index 0000000..15245de --- /dev/null +++ b/root/daemonless/libexec/podman-labels @@ -0,0 +1,6 @@ +#!/bin/sh +# Names containers by their labels. Root, like Caddy: podman's socket is +# root's. Idles when no socket is mounted; the README says how to mount it. +exec /usr/local/bin/reverse-proxy \ + -listen /var/run/reverse-proxy/admin.sock \ + podman-labels diff --git a/root/usr/local/etc/rcd.d/podman-labels.ucl b/root/usr/local/etc/rcd.d/podman-labels.ucl new file mode 100644 index 0000000..f0b9b92 --- /dev/null +++ b/root/usr/local/etc/rcd.d/podman-labels.ucl @@ -0,0 +1,7 @@ +name = "podman-labels"; +provides = ["podman-labels"]; +type = "simple"; +enable = true; +command = "/daemonless/libexec/podman-labels"; +process { env_file = "/run/rcd.env"; } +restart { policy = "always"; max_retries = 0; delay = 5000; backoff = "none"; }