From 3fba1c876475586e6aa254d4f4c8b032cab4a4f9 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 14 Nov 2026 09:40:49 -0500 Subject: [PATCH 1/2] feat: reverse-proxy 0.3.0, names from container labels, a settings switch for them, settings as rows --- Containerfile | 4 +- Containerfile.j2 | 4 +- Containerfile.linux | 2 +- README.md | 238 ++++++++-- admin/cmd/reverse-proxy/main.go | 4 +- admin/cmd/reverse-proxy/podmanlabels.go | 430 +++++++++++++++++++ admin/internal/podman/client.go | 395 +++++++++++++++++ admin/internal/podman/labels.go | 216 ++++++++++ admin/internal/podman/labels_test.go | 151 +++++++ admin/internal/podman/sync.go | 284 ++++++++++++ admin/internal/podman/sync_test.go | 165 +++++++ admin/internal/server/hosts.go | 55 ++- admin/internal/server/openapi.json | 4 + admin/internal/server/podman_switch_test.go | 110 +++++ admin/internal/server/server.go | 3 + admin/internal/server/sources.go | 85 ++++ admin/internal/state/model.go | 42 +- admin/ui/package-lock.json | 4 +- admin/ui/package.json | 2 +- admin/ui/src/app.css | 1 + admin/ui/src/components/AlertSettings.svelte | 22 +- admin/ui/src/components/NetworkChoice.svelte | 17 +- admin/ui/src/components/SettingRow.svelte | 14 + admin/ui/src/components/Switch.svelte | 20 + admin/ui/src/lib/api.ts | 5 + admin/ui/src/screens/HostForm.svelte | 21 +- admin/ui/src/screens/Hosts.svelte | 37 +- admin/ui/src/screens/Settings.svelte | 123 ++++-- admin/ui/src/screens/Setup.svelte | 12 +- compose.yaml | 54 +++ root-linux/etc/services.d/podman-labels/run | 6 + root/daemonless/libexec/podman-labels | 6 + root/usr/local/etc/rcd.d/podman-labels.ucl | 7 + 33 files changed, 2420 insertions(+), 123 deletions(-) create mode 100644 admin/cmd/reverse-proxy/podmanlabels.go create mode 100644 admin/internal/podman/client.go create mode 100644 admin/internal/podman/labels.go create mode 100644 admin/internal/podman/labels_test.go create mode 100644 admin/internal/podman/sync.go create mode 100644 admin/internal/podman/sync_test.go create mode 100644 admin/internal/server/podman_switch_test.go create mode 100644 admin/ui/src/components/SettingRow.svelte create mode 100644 admin/ui/src/components/Switch.svelte create mode 100755 root-linux/etc/services.d/podman-labels/run create mode 100755 root/daemonless/libexec/podman-labels create mode 100644 root/usr/local/etc/rcd.d/podman-labels.ucl diff --git a/Containerfile b/Containerfile index 63ba003..02079bf 100644 --- a/Containerfile +++ b/Containerfile @@ -57,7 +57,7 @@ RUN h="$(/src/ui/source-hash)" && \ FROM ui-${FREEBSD_ARCH} AS ui-build FROM ghcr.io/daemonless/base:${BASE_VERSION} AS admin-build -ARG APP_VER=0.2.0 +ARG APP_VER=0.3.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/ @@ -66,7 +66,7 @@ RUN cd /src && CGO_ENABLED=0 go build -trimpath \ -o /out/reverse-proxy ./cmd/reverse-proxy FROM ghcr.io/daemonless/base-rcd:${BASE_VERSION} -ARG APP_VER=0.2.0 +ARG APP_VER=0.3.0 ARG FREEBSD_ARCH=amd64 ARG PACKAGES="ca_root_nss" diff --git a/Containerfile.j2 b/Containerfile.j2 index fdbe80d..1b221fb 100644 --- a/Containerfile.j2 +++ b/Containerfile.j2 @@ -51,7 +51,7 @@ RUN h="$(/src/ui/source-hash)" && \ FROM ui-${FREEBSD_ARCH} AS ui-build FROM ghcr.io/daemonless/base:${BASE_VERSION} AS admin-build -ARG APP_VER=0.2.0 +ARG APP_VER=0.3.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/ @@ -60,7 +60,7 @@ RUN cd /src && CGO_ENABLED=0 go build -trimpath \ -o /out/reverse-proxy ./cmd/reverse-proxy FROM ghcr.io/daemonless/base-rcd:${BASE_VERSION} -ARG APP_VER=0.2.0 +ARG APP_VER=0.3.0 ARG FREEBSD_ARCH=amd64 ARG PACKAGES="ca_root_nss" diff --git a/Containerfile.linux b/Containerfile.linux index 9580772..0fe5b9b 100644 --- a/Containerfile.linux +++ b/Containerfile.linux @@ -4,7 +4,7 @@ # 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 +ARG APP_VER=0.3.0 ARG CADDY_VERSION= FROM golang:1.26-alpine AS caddy-build diff --git a/README.md b/README.md index 2bc0944..93a5766 100644 --- a/README.md +++ b/README.md @@ -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,41 @@ 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 container keeps its name and shows as +down; a removed one loses it, restorably for a day; a recreated one +(new address, or a new name in its labels) is followed. 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/main.go b/admin/cmd/reverse-proxy/main.go index 9ca8bcb..80b45e1 100644 --- a/admin/cmd/reverse-proxy/main.go +++ b/admin/cmd/reverse-proxy/main.go @@ -50,9 +50,11 @@ func main() { os.Exit(hostsCmd(*listen, flag.Args()[1:])) case "listen": os.Exit(listenCmd(*listen, flag.Args()[1:])) + case "podman-labels": + os.Exit(podmanLabelsCmd(*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)) + 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 podman-labels name containers by their labels (the unit; needs podman's socket)\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) diff --git a/admin/cmd/reverse-proxy/podmanlabels.go b/admin/cmd/reverse-proxy/podmanlabels.go new file mode 100644 index 0000000..423f257 --- /dev/null +++ b/admin/cmd/reverse-proxy/podmanlabels.go @@ -0,0 +1,430 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/daemonless/reverse-proxy/admin/internal/podman" +) + +// The podman-labels command: the root unit that names containers by their +// labels. It reads podman over its socket and talks to the admin over the +// admin's socket like the hosts command, so the admin itself never holds +// socket access. It never exits on its own: no socket means idle and a +// report saying so, a broken socket means retry. + +const podmanLabelsUsage = `Usage: + reverse-proxy podman-labels [--podman-socket PATH] [--self NAME] [--interval 5m] + +Names containers that carry io.daemonless.proxy labels. Runs until stopped. +` + +type labelsUnit struct { + admin *hostsCLI + sock string + self string + interval time.Duration + log *log.Logger + // lastProblems is what was logged last, so a problem is said once. + lastProblems string + // waiting: labelled containers seen before setup gave an ending. Setup + // finishing is no podman event, so the unit looks again every few + // seconds until the names are made. + waiting bool + // hostWarned is the host name an unresolvable-name warning was logged for. + hostWarned string + // lastReport is the last report the admin took, as sent. + lastReport string + // off: container labels are turned off in Settings. A switch flip is + // no podman event either, so while off the unit looks every few seconds. + off bool +} + +func podmanLabelsCmd(adminSock string, args []string) int { + fs := flag.NewFlagSet("podman-labels", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + sock := fs.String("podman-socket", "/var/run/podman/podman.sock", "podman's API socket") + self := fs.String("self", "", "the proxy's own container name (default: this container)") + interval := fs.Duration("interval", 5*time.Minute, "full re-read between events") + if err := fs.Parse(args); err != nil { + fmt.Fprint(os.Stderr, podmanLabelsUsage) + return 2 + } + u := &labelsUnit{ + admin: &hostsCLI{c: socketClient(adminSock), out: os.Stdout, err: os.Stderr}, + sock: *sock, + self: *self, + interval: *interval, + log: log.New(os.Stdout, "[podman-labels] ", 0), + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + u.run(ctx) + return 0 +} + +func (u *labelsUnit) run(ctx context.Context) { + u.waitForAdmin(ctx) + attempt := 0 + answered := false // the socket has answered since the unit started + client := podman.NewClient(u.sock) // one client: it dials the socket path anew + quick := 0 // events streams that broke at once, in a row + for ctx.Err() == nil { + if _, err := os.Stat(u.sock); err != nil { + if answered { + // podman's service restarting: the socket file comes back + // in a moment. Not "off": the mount is still there. + if attempt == 0 { + u.report(podmanReport{Socket: true, Error: "podman's socket went away; waiting for it to come back"}) + u.log.Printf("podman socket went away; waiting for it to come back") + } + attempt++ + sleep(ctx, podman.Backoff(attempt)) + continue + } + u.report(podmanReport{Socket: false}) + if attempt == 0 { + u.log.Printf("no podman socket at %s: names from labels are off (mount the host's /var/run/podman to turn them on)", u.sock) + } + attempt++ + sleep(ctx, 30*time.Second) + continue + } + if err := client.Ping(ctx); err != nil { + u.report(podmanReport{Socket: false, Error: "the podman socket does not answer: " + err.Error()}) + u.log.Printf("podman socket does not answer (%v); retrying", err) + sleep(ctx, podman.Backoff(attempt)) + attempt++ + continue + } + if attempt > 0 { + u.log.Printf("podman socket answers") + } + answered = true + attempt = 0 + began := time.Now() + u.follow(ctx, client) + // A stream that breaks at once (podman answers _ping but not + // /events) must not turn into a reconcile loop with no pause. + if time.Since(began) < 30*time.Second { + quick++ + sleep(ctx, podman.Backoff(quick)) + } else { + quick = 0 + } + } +} + +// follow reconciles once, then on every container event (coalesced) and +// every interval, until the events stream breaks. +func (u *labelsUnit) follow(ctx context.Context, client *podman.Client) { + u.reconcile(ctx, client) + kick := make(chan struct{}, 1) + go func() { + err := client.Events(ctx, func(ev podman.Event) { + if podman.Interesting(ev) { + select { + case kick <- struct{}{}: + default: + } + } + }) + if ctx.Err() == nil { + u.log.Printf("events stream stopped (%v); reconnecting", err) + } + close(kick) + }() + for { + next := u.interval + if u.waiting || u.off { + next = 5 * time.Second + } + select { + case <-ctx.Done(): + return + case <-time.After(next): + u.reconcile(ctx, client) + case _, ok := <-kick: + if !ok { + return + } + sleep(ctx, time.Second) // a compose up fires several events at once + u.reconcile(ctx, client) + } + } +} + +// waitForAdmin gives the admin unit time to open its socket at start, +// quietly: the two come up together and the order is not fixed. +func (u *labelsUnit) waitForAdmin(ctx context.Context) { + for i := 0; ctx.Err() == nil; i++ { + var st struct{} + if err := u.admin.call(http.MethodGet, "/api/v1/status", nil, &st); err == nil { + return + } else if i == 10 { + u.log.Printf("still waiting for the admin: %v", err) + } + sleep(ctx, 2*time.Second) + } +} + +type podmanReport struct { + Socket bool `json:"socket"` + Containers int `json:"containers"` + Waiting []string `json:"waiting,omitempty"` + Problems []podman.Problem `json:"problems,omitempty"` + Elsewhere []podman.Problem `json:"elsewhere,omitempty"` + Error string `json:"error,omitempty"` +} + +func (u *labelsUnit) report(r podmanReport) { + if r.Waiting == nil { + r.Waiting = []string{} + } + if r.Problems == nil { + r.Problems = []podman.Problem{} + } + // Only a change is sent: each report is a write of the admin's state. + key, _ := json.Marshal(r) + if string(key) == u.lastReport { + return + } + if err := u.admin.call(http.MethodPut, "/api/v1/sources/podman", r, nil); err != nil { + u.log.Printf("cannot report to the admin: %v", err) + return + } + u.lastReport = string(key) +} + +// reconcile reads podman and the admin and makes them agree. +func (u *labelsUnit) reconcile(ctx context.Context, client *podman.Client) { + rep := podmanReport{Socket: true} + var src struct { + Enabled bool `json:"enabled"` + } + if err := u.admin.call(http.MethodGet, "/api/v1/sources/podman", nil, &src); err == nil && !src.Enabled { + // The admin removed the names when the switch went off; say once. + if !u.off { + u.log.Printf("names from container labels are turned off in Settings") + u.off, u.lastProblems = true, "" + u.report(rep) + } + return + } + if u.off { + u.log.Printf("names from container labels are turned on in Settings") + u.off = false + } + env, err := u.env(ctx, client) + if ctx.Err() != nil { + return // stopping: a cut-off request is not an error + } + if err != nil { + rep.Error = err.Error() + u.log.Printf("%v", err) + u.report(rep) + return + } + containers, err := client.Labelled(ctx) + if err != nil { + rep.Error = "cannot list containers: " + err.Error() + u.log.Printf("%v", rep.Error) + u.report(rep) + return + } + rep.Containers = len(containers) + var hosts struct { + Hosts []podman.Host `json:"hosts"` + } + if err := u.admin.call(http.MethodGet, "/api/v1/hosts", nil, &hosts); err != nil { + rep.Error = "cannot read the hosts: " + err.Error() + u.log.Printf("%v", rep.Error) + u.report(rep) + return + } + plan := podman.Reconcile(containers, hosts.Hosts, env.Env) + rep.Problems, rep.Elsewhere = plan.Problems, plan.Elsewhere + u.waiting = env.domain == "" && len(plan.Create) > 0 + if env.domain == "" { + for _, in := range plan.Create { + rep.Waiting = append(rep.Waiting, strings.TrimPrefix(in.Ref, podman.RefPrefix)) + } + if len(rep.Waiting) > 0 && u.lastProblems != "waiting" { + u.log.Printf("%d labelled container(s) waiting for setup to finish", len(rep.Waiting)) + u.lastProblems = "waiting" + } + u.report(rep) + return + } + for _, in := range plan.Create { + if err := u.admin.call(http.MethodPost, "/api/v1/hosts", in, nil); err != nil { + rep.Problems = append(rep.Problems, podman.Problem{Container: strings.TrimPrefix(in.Ref, podman.RefPrefix), Message: err.Error()}) + continue + } + u.log.Printf("named container %s: %s -> %s:%d", strings.TrimPrefix(in.Ref, podman.RefPrefix), in.Name, in.Target.Address, in.Target.Port) + } + for id, in := range plan.Update { + if err := u.admin.call(http.MethodPut, "/api/v1/hosts/"+url.PathEscape(id), in, nil); err != nil { + rep.Problems = append(rep.Problems, podman.Problem{Container: strings.TrimPrefix(in.Ref, podman.RefPrefix), Message: err.Error()}) + continue + } + u.log.Printf("updated container %s: %s -> %s:%d", strings.TrimPrefix(in.Ref, podman.RefPrefix), in.Name, in.Target.Address, in.Target.Port) + } + for _, id := range plan.Delete { + if err := u.admin.call(http.MethodDelete, "/api/v1/hosts/"+url.PathEscape(id), nil, nil); err != nil { + u.log.Printf("cannot remove host %s: %v", id, err) + continue + } + u.log.Printf("removed the name of a container that is gone (host %s)", id) + } + // Problems are logged when they change, not on every event. + var lines []string + for _, p := range rep.Problems { + lines = append(lines, p.Container+": "+p.Message) + } + if joined := strings.Join(lines, "\n"); joined != u.lastProblems { + for _, l := range lines { + u.log.Printf("%s", l) + } + if joined == "" && u.lastProblems != "" { + u.log.Printf("every labelled container has its name") + } + u.lastProblems = joined + } + u.report(rep) +} + +type unitEnv struct { + podman.Env + domain string +} + +// env is what the plan needs: the proxy's own container (networks, name) +// and the admin's domain, mode and host address. +func (u *labelsUnit) env(ctx context.Context, client *podman.Client) (unitEnv, error) { + var out unitEnv + self := u.self + if self == "" { + self, _ = os.Hostname() + } + me, err := client.Self(ctx, self) + if err != nil { + return out, fmt.Errorf("cannot find the proxy's own container in podman: %v", err) + } + if u.self == "" { + u.self = me.Name // found once; a compose hostname: is not the container's name + } + out.Self = me.Name + out.Reach.Networks = me.Networks + // The unit runs inside the proxy: its interfaces say which subnets the + // proxy reaches directly, whatever podman calls the networks. + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && !n.IP.IsLoopback() { + out.Reach.Subnets = append(out.Reach.Subnets, n) + } + } + } + var st struct { + Domain string `json:"domain"` + Mode string `json:"mode"` + AdminURL string `json:"adminUrl"` + } + if err := u.admin.call(http.MethodGet, "/api/v1/status", nil, &st); err != nil { + return out, errors.New("cannot read the admin's status: " + err.Error()) + } + out.domain = st.Domain + out.HomeOnly = st.Mode == "private" + // A container on a network the proxy is not on is reached through a + // port published on the host, so the host's own address is needed: the + // machine's name from podman, resolved. The admin's address is only a + // fallback, since a proxy with its own address on the LAN would point a + // name at itself. + if name, err := client.HostName(ctx); err == nil && name != "" { + out.HostNames = append(out.HostNames, name) + if addrs, err := net.DefaultResolver.LookupHost(ctx, name); err == nil { + for _, a := range addrs { + if ip := net.ParseIP(a); ip != nil && ip.To4() != nil && !ip.IsLoopback() { + out.HostNames = append(out.HostNames, a) + if out.HostAddress == "" { + out.HostAddress = a + } + } + } + } + if out.HostAddress == "" && u.hostWarned != name { + u.log.Printf("cannot resolve the host's name %q from inside the container; a container the proxy shares no network with gets no name", name) + u.hostWarned = name + } + } + if out.HostAddress == "" { + if pu, err := url.Parse(st.AdminURL); err == nil { + out.HostAddress = pu.Hostname() + } + } + for _, ip := range me.Addrs { + if ip.String() == out.HostAddress { + out.HostAddress = "" // the proxy's own address: a name there would point at itself + } + } + out.Who = u.who() + return out, nil +} + +func sleep(ctx context.Context, d time.Duration) { + select { + case <-ctx.Done(): + case <-time.After(d): + } +} + +// who names the app that made a host, as the Names page does: by the app's +// id in its ref ("fjord::"), else by the key that made it, else, +// for a fjord ref, the app that installed the proxy. +func (u *labelsUnit) who() func(podman.Host) string { + var tok struct { + Tokens []struct { + ID string `json:"id"` + Label string `json:"label"` + ClientID string `json:"clientId"` + Paired bool `json:"paired"` + } `json:"tokens"` + } + if err := u.admin.call(http.MethodGet, "/api/v1/tokens", nil, &tok); err != nil { + return nil + } + return func(h podman.Host) string { + parts := strings.SplitN(h.Ref, ":", 3) + for _, t := range tok.Tokens { + if len(parts) > 1 && t.ClientID != "" && t.ClientID == parts[1] { + return t.Label + } + } + for _, t := range tok.Tokens { + if h.By != "" && t.ID == h.By { + return t.Label + } + } + if parts[0] == "fjord" { + for _, t := range tok.Tokens { + if t.Paired { + return t.Label + } + } + return "fjord" + } + return "" + } +} diff --git a/admin/internal/podman/client.go b/admin/internal/podman/client.go new file mode 100644 index 0000000..4049bec --- /dev/null +++ b/admin/internal/podman/client.go @@ -0,0 +1,395 @@ +package podman + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Client is podman's libpod API over its unix socket. +type Client struct { + Socket string + http *http.Client + mu sync.Mutex + drivers map[string]string // network name -> driver, asked once +} + +func NewClient(socket string) *Client { + return &Client{Socket: socket, drivers: map[string]string{}, http: &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + }, + }}} +} + +const apiBase = "http://podman/v5.0.0/libpod" + +// requestTimeout bounds one API call (not the events stream): a podman that +// stalls on a stuck container must not hold the unit's only reconcile. +const requestTimeout = 30 * time.Second + +// errGone is a container that went away between being listed and inspected. +var errGone = errors.New("no such container") + +func (c *Client) get(ctx context.Context, path string, out any) error { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + err := fmt.Errorf("podman %s: %s: %s", path, resp.Status, strings.TrimSpace(string(raw))) + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("%w: %v", errGone, err) + } + return err + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// HostName is the name of the machine podman runs on, from /info. +func (c *Client) HostName(ctx context.Context) (string, error) { + var info struct { + Host struct { + Hostname string `json:"hostname"` + } `json:"host"` + } + if err := c.get(ctx, "/info", &info); err != nil { + return "", err + } + return info.Host.Hostname, nil +} + +// Ping says whether the socket answers (the answer is the text "OK"). +func (c *Client) Ping(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/_ping", nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64)) + if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(raw)) != "OK" { + return fmt.Errorf("podman _ping: %s %q", resp.Status, strings.TrimSpace(string(raw))) + } + return nil +} + +type listed struct { + ID string `json:"Id"` + Names []string `json:"Names"` + State string `json:"State"` + Labels map[string]string `json:"Labels"` +} + +type inspected struct { + ID string `json:"Id"` + Name string `json:"Name"` + State struct{ Status string } + Config struct { + Hostname string `json:"Hostname"` + Labels map[string]string `json:"Labels"` + ExposedPorts map[string]json.RawMessage `json:"ExposedPorts"` + } + HostConfig struct { + NetworkMode string `json:"NetworkMode"` + } + NetworkSettings struct { + Networks map[string]struct { + IPAddress string `json:"IPAddress"` + } `json:"Networks"` + Ports map[string][]struct { + HostPort string `json:"HostPort"` + } `json:"Ports"` + } +} + +// Labelled lists the containers that ask for a name, inspected. +func (c *Client) Labelled(ctx context.Context) ([]Container, error) { + var all []listed + if err := c.get(ctx, "/containers/json?all=true", &all); err != nil { + return nil, err + } + var out []Container + for _, l := range all { + if !Labelled(l.Labels) { + continue + } + ct, err := c.Inspect(ctx, l.ID) + if errors.Is(err, errGone) { + continue // removed since the list: its name goes, as it should + } + if err != nil { + return nil, err + } + out = append(out, ct) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// Self finds the container this process runs in: by the hostname as an id +// or name (podman's default), else the running container whose hostname +// was set to it (a compose `hostname:`). +func (c *Client) Self(ctx context.Context, hostname string) (Container, error) { + if ct, err := c.Inspect(ctx, hostname); err == nil { + return ct, nil + } + var all []listed + if err := c.get(ctx, "/containers/json", &all); err != nil { + return Container{}, err + } + for _, l := range all { + ct, err := c.Inspect(ctx, l.ID) + if err == nil && ct.Hostname == hostname { + return ct, nil + } + } + return Container{}, fmt.Errorf("no container is named or has the hostname %q", hostname) +} + +// Inspect reads one container by id or name. On FreeBSD, inspect reports +// no address for a container on an epair (macvlan-style) network; a running +// one is then asked with ifconfig and each address matched to its network's +// subnet. +func (c *Client) Inspect(ctx context.Context, id string) (Container, error) { + var in inspected + if err := c.get(ctx, "/containers/"+url.PathEscape(id)+"/json", &in); err != nil { + return Container{}, err + } + ct := Container{ + ID: in.ID, Name: strings.TrimPrefix(in.Name, "/"), State: in.State.Status, Hostname: in.Config.Hostname, + Labels: in.Config.Labels, HostNet: in.HostConfig.NetworkMode == "host", + IPs: map[string]string{}, Published: map[int]int{}, + } + blank := false + for n, v := range in.NetworkSettings.Networks { + ct.Networks = append(ct.Networks, n) + if d := c.driver(ctx, n); d == "macvlan" || d == "ipvlan" || d == "epair" { + ct.LAN = append(ct.LAN, n) + } + if v.IPAddress != "" { + ct.IPs[n] = v.IPAddress + if ip := net.ParseIP(v.IPAddress); ip != nil { + ct.Addrs = append(ct.Addrs, ip) + } + } else { + blank = true + } + } + sort.Strings(ct.Networks) + sort.Strings(ct.LAN) + if blank && ct.State == "running" { + if addrs, err := c.ifconfig(ctx, in.ID); err == nil { + ct.Addrs = append(ct.Addrs, addrs...) + } + } + for spec := range in.Config.ExposedPorts { + if p := portOf(spec); p > 0 { + ct.Exposed = append(ct.Exposed, p) + } + } + sort.Ints(ct.Exposed) + for spec, binds := range in.NetworkSettings.Ports { + p := portOf(spec) + for _, b := range binds { + if hp, _ := strconv.Atoi(b.HostPort); p > 0 && hp > 0 { + ct.Published[p] = hp + } + } + } + return ct, nil +} + +// driver is a network's driver ("bridge", "macvlan" ...); empty when podman +// does not say. Remembered: a network's driver never changes. +func (c *Client) driver(ctx context.Context, name string) string { + c.mu.Lock() + d, ok := c.drivers[name] + c.mu.Unlock() + if ok { + return d + } + var nw struct { + Driver string `json:"driver"` + } + if err := c.get(ctx, "/networks/"+url.PathEscape(name)+"/json", &nw); err != nil { + return "" + } + c.mu.Lock() + c.drivers[name] = nw.Driver + c.mu.Unlock() + return nw.Driver +} + +var inetRe = regexp.MustCompile(`(?m)^\s*inet (\d+\.\d+\.\d+\.\d+)`) + +// ifconfig runs ifconfig inside a container and returns its IPv4 addresses +// but loopback: the ground truth for epair networks. +func (c *Client) ifconfig(ctx context.Context, id string) ([]net.IP, error) { + out, err := c.Exec(ctx, id, []string{"ifconfig"}) + if err != nil { + return nil, err + } + var ips []net.IP + for _, m := range inetRe.FindAllStringSubmatch(out, -1) { + if ip := net.ParseIP(m[1]); ip != nil && !ip.IsLoopback() { + ips = append(ips, ip) + } + } + return ips, nil +} + +// Exec runs a command in a container and returns what it printed. +func (c *Client) Exec(ctx context.Context, id string, cmd []string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + body, _ := json.Marshal(map[string]any{"Cmd": cmd, "AttachStdout": true, "AttachStderr": true}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/containers/"+url.PathEscape(id)+"/exec", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + var made struct { + ID string `json:"Id"` + } + err = json.NewDecoder(resp.Body).Decode(&made) + resp.Body.Close() + if err != nil || made.ID == "" { + return "", fmt.Errorf("exec create: %s", resp.Status) + } + start, _ := json.Marshal(map[string]any{"Detach": false, "Tty": false}) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/exec/"+made.ID+"/start", bytes.NewReader(start)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + return demux(raw), nil +} + +// demux strips the stream frames (8-byte header: type, 3 zero bytes, big +// endian length) an exec without a tty is sent in; plain output passes. +func demux(raw []byte) string { + if len(raw) < 8 || raw[0] > 2 || raw[1] != 0 || raw[2] != 0 || raw[3] != 0 { + return string(raw) + } + var out bytes.Buffer + for len(raw) >= 8 { + n := int(binary.BigEndian.Uint32(raw[4:8])) + raw = raw[8:] + if n > len(raw) { + n = len(raw) + } + out.Write(raw[:n]) + raw = raw[n:] + } + return out.String() +} + +// portOf reads "1411/tcp" as 1411; udp-only entries do not count. +func portOf(spec string) int { + parts := strings.SplitN(spec, "/", 2) + if len(parts) == 2 && parts[1] == "udp" { + return 0 + } + n, _ := strconv.Atoi(parts[0]) + return n +} + +// Event is one line of the events stream. +type Event struct { + Type string `json:"Type"` + Action string `json:"Action"` + Actor struct { + ID string `json:"ID"` + Attributes map[string]string `json:"Attributes"` + } `json:"Actor"` +} + +// Events follows container events until ctx ends or the stream breaks; +// each event is handed to fn. The error says why it stopped. +func (c *Client) Events(ctx context.Context, fn func(Event)) error { + q := url.Values{"stream": {"true"}, "filters": {`{"type":["container"]}`}} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/events?"+q.Encode(), nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("podman events: %s", resp.Status) + } + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 64*1024), 1024*1024) + for sc.Scan() { + var ev Event + if err := json.Unmarshal(sc.Bytes(), &ev); err != nil { + continue + } + fn(ev) + } + if err := sc.Err(); err != nil { + return err + } + return fmt.Errorf("events stream ended") +} + +// Interesting says whether an event can change a name: anything but the +// chatter of a running container (exec, health, attach). +func Interesting(ev Event) bool { + switch ev.Action { + case "create", "start", "died", "stop", "remove", "rename", "restart", "init", "cleanup": + return true + } + return false +} + +// Backoff waits between reconnects: 2 s doubling to a minute. +func Backoff(attempt int) time.Duration { + d := 2 * time.Second << uint(attempt) + if d > time.Minute { + d = time.Minute + } + return d +} diff --git a/admin/internal/podman/labels.go b/admin/internal/podman/labels.go new file mode 100644 index 0000000..9631044 --- /dev/null +++ b/admin/internal/podman/labels.go @@ -0,0 +1,216 @@ +// Package podman names containers by their labels. A container on the +// proxy's host that carries io.daemonless.proxy.name (or io.daemonless.proxy +// alone, which names it after the container) becomes a host, the same +// record a pasted address or fjord makes, with Ref "podman:". +// +// The labels own what they say (name, extra names, port, public, HTTPS, +// own origin); the UI owns the rest (trusted networks, icon, watched). A +// stopped container keeps its name and shows down; only a removed one loses +// it, restorably. Anything the proxy cannot honor is reported, never guessed. +package podman + +import ( + "fmt" + "net" + "sort" + "strconv" + "strings" + + "github.com/daemonless/reverse-proxy/admin/internal/sources" +) + +const ( + LabelOn = "io.daemonless.proxy" + LabelName = "io.daemonless.proxy.name" + LabelPort = "io.daemonless.proxy.port" + LabelNames = "io.daemonless.proxy.names" + LabelPublic = "io.daemonless.proxy.public" + LabelHTTPS = "io.daemonless.proxy.https" + LabelOwnOrigin = "io.daemonless.proxy.own-origin" + // LabelImagePort is the port a daemonless image declares for itself. + LabelImagePort = "io.daemonless.port" + + RefPrefix = "podman:" +) + +// Container is what the unit needs to know about one container. +type Container struct { + ID string + Name string + State string // running, exited, created ... + Hostname string // what the container calls itself + Labels map[string]string + HostNet bool // network_mode: host + Networks []string // the networks it is on + LAN []string // of those, macvlan/ipvlan/epair: its address is on the LAN + IPs map[string]string // network name -> address, when inspect knows it + Addrs []net.IP // every IPv4 address it has (inspect, else ifconfig) + Exposed []int // ports the image exposes + Published map[int]int // container port -> host port +} + +// Want is what a container's labels ask for. +type Want struct { + Ref string + Container string + Name string + ExtraNames []string + Port int + Public bool + HTTPS bool + OwnOrigin *bool + // Problem is why no host can be made from this container (empty when it can). + Problem string + // Note is a label the proxy could not honor and says so on the host. + Note string +} + +// Labelled says whether a container asks for a name at all. +func Labelled(labels map[string]string) bool { + if v, set := labels[LabelOn]; set { + return truthy(v) // io.daemonless.proxy=false opts out, whatever else is set + } + for k := range labels { + if strings.HasPrefix(k, LabelName) || strings.HasPrefix(k, LabelPort) || k == LabelNames || k == LabelPublic { + return true + } + } + return false +} + +// Parse reads a container's labels. *homeOnly* is the proxy's mode: a +// public label is then kept home-only and noted. +func Parse(c Container, homeOnly bool) Want { + w := Want{Ref: RefPrefix + c.Name, Container: c.Name} + name := strings.TrimSpace(c.Labels[LabelName]) + if name == "" { + name = c.Name + } + w.Name = sources.Label(name) + if w.Name == "" { + w.Problem = fmt.Sprintf("%s is not a usable name", name) + return w + } + for _, n := range strings.Split(c.Labels[LabelNames], ",") { + if n = sources.Label(n); n != "" && n != w.Name { + w.ExtraNames = append(w.ExtraNames, n) + } + } + w.Port, w.Problem = port(c) + if w.Problem != "" { + return w + } + if truthy(c.Labels[LabelPublic]) { + if homeOnly { + w.Note = "public asked, this proxy is home only" + } else { + w.Public = true + } + } + w.HTTPS = truthy(c.Labels[LabelHTTPS]) + if v, ok := c.Labels[LabelOwnOrigin]; ok { + b := truthy(v) + w.OwnOrigin = &b + } + return w +} + +// port is the label, else the image's own port, else the one exposed port. +func port(c Container) (int, string) { + for _, key := range []string{LabelPort, LabelImagePort} { + v := strings.TrimSpace(c.Labels[key]) + if v == "" { + continue + } + n, err := strconv.Atoi(strings.Split(v, "/")[0]) + if err != nil || n < 1 || n > 65535 { + return 0, fmt.Sprintf("%s is not a port (%s)", v, key) + } + return n, "" + } + if len(c.Exposed) == 1 { + return c.Exposed[0], "" + } + if len(c.Exposed) > 1 { + return 0, fmt.Sprintf("the image exposes %d ports; say which with %s", len(c.Exposed), LabelPort) + } + return 0, "no port: the image declares none, add " + LabelPort +} + +// Reach is what the proxy can reach directly: the networks it is on, by +// name, and the subnets of its own interfaces (on FreeBSD inspect knows +// no address on an epair network, so an address read with ifconfig is +// matched to the proxy's own subnets instead). +type Reach struct { + Networks []string + Subnets []*net.IPNet +} + +// Address picks where the proxy reaches the container: its address on a +// network the proxy is on too, else the host and a published port. The +// second return is a note for the Names page, the third a problem. +func Address(c Container, reach Reach, hostAddress string, port int) (string, int, string, string) { + for _, n := range reach.Networks { + if ip := c.IPs[n]; ip != "" { + return ip, port, "", "" + } + } + for _, sn := range reach.Subnets { + for _, a := range c.Addrs { + if sn.Contains(a) { + return a.String(), port, "", "" + } + } + } + shared := "" + for _, n := range reach.Networks { + for _, cn := range c.Networks { + if cn == n { + shared = n + } + } + } + if shared != "" && !c.HostNet && c.Published[port] == 0 { + return "", 0, "", fmt.Sprintf("on %s with the proxy, but its address there is not known yet", shared) + } + // A macvlan/ipvlan container has its own address on the LAN: the proxy + // reaches it there like any other machine, no shared network needed. + for _, n := range c.LAN { + if ip := c.IPs[n]; ip != "" { + return ip, port, "", "" + } + } + if len(c.LAN) == len(c.Networks) && len(c.LAN) > 0 { + for _, a := range c.Addrs { + if !a.IsLoopback() { + return a.String(), port, "", "" + } + } + } + if c.HostNet { + if hostAddress == "" { + return "", 0, "", "runs on the host network, and the proxy does not know the host's address yet" + } + return hostAddress, port, "", "" + } + if hp := c.Published[port]; hp != 0 && hostAddress != "" { + return hostAddress, hp, fmt.Sprintf("reached through the host's port %d: not on a network the proxy is on", hp), "" + } + nets := make([]string, 0, len(c.IPs)) + for n := range c.IPs { + nets = append(nets, n) + } + sort.Strings(nets) + if len(nets) == 0 { + return "", 0, "", "not reachable: no network address and no published port" + } + return "", 0, "", fmt.Sprintf("not reachable: on %s, the proxy is not; publish port %d or share a network", strings.Join(nets, ", "), port) +} + +func truthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + } + return false +} diff --git a/admin/internal/podman/labels_test.go b/admin/internal/podman/labels_test.go new file mode 100644 index 0000000..15605ff --- /dev/null +++ b/admin/internal/podman/labels_test.go @@ -0,0 +1,151 @@ +package podman + +import ( + "net" + "strings" + "testing" +) + +func ct(name string, labels map[string]string) Container { + return Container{ID: "id-" + name, Name: name, State: "running", Labels: labels, Networks: []string{"podman"}, + IPs: map[string]string{"podman": "10.88.0.5"}, Addrs: []net.IP{net.ParseIP("10.88.0.5")}, Published: map[int]int{}} +} + +var bridge = Reach{Networks: []string{"podman"}} + +func TestParseNameAndShorthand(t *testing.T) { + w := Parse(ct("radarr", map[string]string{LabelName: "Movies", LabelImagePort: "7878"}), false) + if w.Name != "movies" || w.Port != 7878 || w.Ref != "podman:radarr" || w.Problem != "" { + t.Fatalf("got %+v", w) + } + w = Parse(ct("Radarr_Movies", map[string]string{LabelOn: "true", LabelImagePort: "7878"}), false) + if w.Name != "radarr-movies" { + t.Fatalf("shorthand names after the container: %+v", w) + } + if !Labelled(map[string]string{LabelOn: "yes"}) || Labelled(map[string]string{LabelImagePort: "80"}) { + t.Fatal("Labelled") + } +} + +func TestParsePortSources(t *testing.T) { + c := ct("x", map[string]string{LabelOn: "true", LabelPort: "9000", LabelImagePort: "80"}) + if w := Parse(c, false); w.Port != 9000 { + t.Fatalf("the label wins: %+v", w) + } + c = ct("x", map[string]string{LabelOn: "true"}) + c.Exposed = []int{1411} + if w := Parse(c, false); w.Port != 1411 { + t.Fatalf("one exposed port: %+v", w) + } + c.Exposed = []int{80, 443} + if w := Parse(c, false); !strings.Contains(w.Problem, "exposes 2 ports") { + t.Fatalf("several exposed: %+v", w) + } + c.Exposed = nil + if w := Parse(c, false); !strings.HasPrefix(w.Problem, "no port") { + t.Fatalf("no port: %+v", w) + } + c.Labels[LabelPort] = "lots" + if w := Parse(c, false); !strings.Contains(w.Problem, "not a port") { + t.Fatalf("bad port: %+v", w) + } +} + +func TestParseExtrasPublicHTTPSOwnOrigin(t *testing.T) { + c := ct("unifi", map[string]string{LabelOn: "1", LabelImagePort: "8443", LabelNames: "Wifi, unifi ,net", + LabelPublic: "true", LabelHTTPS: "true", LabelOwnOrigin: "true"}) + w := Parse(c, false) + if strings.Join(w.ExtraNames, ",") != "wifi,net" || !w.Public || !w.HTTPS || w.OwnOrigin == nil || !*w.OwnOrigin { + t.Fatalf("got %+v", w) + } + w = Parse(c, true) + if w.Public || w.Note != "public asked, this proxy is home only" { + t.Fatalf("home only: %+v", w) + } + w = Parse(ct("y", map[string]string{LabelOn: "true", LabelImagePort: "80"}), false) + if w.OwnOrigin != nil { + t.Fatal("own-origin absent means untouched") + } +} + +func TestAddress(t *testing.T) { + c := ct("a", nil) + addr, port, note, problem := Address(c, bridge, "192.168.1.5", 7878) + if addr != "10.88.0.5" || port != 7878 || note != "" || problem != "" { + t.Fatalf("shared network: %s %d %q %q", addr, port, note, problem) + } + c.Networks, c.IPs = []string{"other"}, map[string]string{"other": "10.89.0.9"} + c.Published[7878] = 17878 + c.Addrs = []net.IP{net.ParseIP("10.89.0.9")} + addr, port, note, _ = Address(c, bridge, "192.168.1.5", 7878) + if addr != "192.168.1.5" || port != 17878 || !strings.Contains(note, "host's port 17878") { + t.Fatalf("published: %s %d %q", addr, port, note) + } + c.Published = map[int]int{} + if _, _, _, problem = Address(c, bridge, "192.168.1.5", 7878); !strings.Contains(problem, "on other, the proxy is not") { + t.Fatalf("unreachable: %q", problem) + } + c.HostNet, c.IPs = true, nil + if addr, port, _, _ = Address(c, Reach{}, "192.168.1.5", 7878); addr != "192.168.1.5" || port != 7878 { + t.Fatalf("host network: %s %d", addr, port) + } + if _, _, _, problem = Address(c, Reach{}, "", 7878); problem == "" { + t.Fatal("host network without a host address is a problem") + } +} + +func TestAddressEpairBySubnet(t *testing.T) { + // jupiter: inspect knows no address on vlan5; ifconfig in the container does + c := ct("radarr", nil) + c.Networks, c.IPs, c.Addrs = []string{"vlan5"}, map[string]string{}, []net.IP{net.ParseIP("192.168.5.10")} + _, own, _ := net.ParseCIDR("192.168.5.145/24") + addr, port, note, problem := Address(c, Reach{Networks: []string{"vlan5", "lan86"}, Subnets: []*net.IPNet{own}}, "192.168.86.4", 7878) + if addr != "192.168.5.10" || port != 7878 || note != "" || problem != "" { + t.Fatalf("epair by subnet: %s %d %q %q", addr, port, note, problem) + } + c.Addrs = nil // stopped, or ifconfig failed + if _, _, _, problem = Address(c, Reach{Networks: []string{"vlan5"}}, "192.168.86.4", 7878); !strings.Contains(problem, "on vlan5 with the proxy") { + t.Fatalf("shared but unknown: %q", problem) + } +} + +func TestDemux(t *testing.T) { + framed := append([]byte{1, 0, 0, 0, 0, 0, 0, 5}, []byte("hello")...) + framed = append(framed, append([]byte{2, 0, 0, 0, 0, 0, 0, 2}, []byte("!\n")...)...) + if got := demux(framed); got != "hello!\n" { + t.Fatalf("framed: %q", got) + } + if got := demux([]byte("plain text\n")); got != "plain text\n" { + t.Fatalf("plain: %q", got) + } + out := "lo0: flags=8049\n\tinet 127.0.0.1 netmask 0xff000000\neth0: flags=8843\n\tinet 192.168.5.10 netmask 0xffffff00 broadcast 192.168.5.255\n" + m := inetRe.FindAllStringSubmatch(out, -1) + if len(m) != 2 || m[1][1] != "192.168.5.10" { + t.Fatalf("inet parse: %v", m) + } +} + +func TestAddressMacvlanOwnAddress(t *testing.T) { + // venus: plex on the vlan4_net macvlan, the proxy on the default bridge + c := ct("plex", nil) + c.Networks, c.LAN = []string{"vlan4_net"}, []string{"vlan4_net"} + c.IPs, c.Addrs = map[string]string{"vlan4_net": "192.168.4.2"}, []net.IP{net.ParseIP("192.168.4.2")} + addr, port, note, problem := Address(c, bridge, "192.168.86.6", 32400) + if addr != "192.168.4.2" || port != 32400 || note != "" || problem != "" { + t.Fatalf("macvlan: %s %d %q %q", addr, port, note, problem) + } + // FreeBSD: inspect knows no address on a macvlan-style network; ifconfig does + c.IPs = map[string]string{} + if addr, _, _, problem = Address(c, bridge, "192.168.86.6", 32400); addr != "192.168.4.2" || problem != "" { + t.Fatalf("macvlan by ifconfig: %s %q", addr, problem) + } +} + +func TestLabelledOptOutWins(t *testing.T) { + if Labelled(map[string]string{LabelOn: "false", LabelPort: "8080", LabelPublic: "true"}) { + t.Fatal("io.daemonless.proxy=false opts out even with other proxy labels") + } + if !Labelled(map[string]string{LabelPort: "8080"}) { + t.Fatal("a proxy label alone still opts in") + } +} diff --git a/admin/internal/podman/sync.go b/admin/internal/podman/sync.go new file mode 100644 index 0000000..90ca874 --- /dev/null +++ b/admin/internal/podman/sync.go @@ -0,0 +1,284 @@ +package podman + +import ( + "fmt" + "sort" + "strings" +) + +// Host is the slice of the admin's host view the sync compares against. +type Host struct { + ID string `json:"id"` + Name string `json:"name"` + ExtraNames []string `json:"extraNames,omitempty"` + Ref string `json:"ref,omitempty"` + Access string `json:"access"` + TrustedNetworks []string `json:"trustedNetworks,omitempty"` + OwnOrigin bool `json:"ownOrigin,omitempty"` + Note string `json:"note,omitempty"` + Unwatched bool `json:"unwatched,omitempty"` + By string `json:"by,omitempty"` + Target struct { + Address string `json:"address"` + Port int `json:"port"` + HTTPS bool `json:"https"` + AcceptUntrusted bool `json:"acceptUntrusted"` + } `json:"target"` +} + +// HostInput is what the sync sends to create or update a host: the label- +// owned fields, with the UI-owned ones copied back so nothing is lost. +type HostInput struct { + Name string `json:"name"` + ExtraNames []string `json:"extraNames"` + Ref string `json:"ref"` + Access string `json:"access"` + TrustedNetworks []string `json:"trustedNetworks"` + OwnOrigin *bool `json:"ownOrigin,omitempty"` + Note *string `json:"note,omitempty"` + Target struct { + Address string `json:"address"` + Port int `json:"port"` + HTTPS bool `json:"https"` + AcceptUntrusted bool `json:"acceptUntrusted"` + } `json:"target"` +} + +// Problem is a container the sync could not name, and why. +type Problem struct { + Container string `json:"container"` + Message string `json:"message"` +} + +// Plan is what one reconcile does. +type Plan struct { + Create []HostInput + Update map[string]HostInput // host id -> fields + Delete []string // host ids whose container is gone + Problems []Problem + // Elsewhere are containers another app already names: the same name at + // the same place, or another name at the same place. Not problems. + Elsewhere []Problem +} + +// Env is what the plan needs to know about the proxy itself. +type Env struct { + Self string // the proxy's own container name, skipped + Reach Reach // what the proxy reaches directly + HostAddress string // the host's address, for published ports and host-network containers + HomeOnly bool // no internet names possible + // HostNames are every way a name can point at the host itself: its + // addresses and its name. A published port behind any of them is the + // container's. + HostNames []string + // Who says who made a host ("fjord on jupiter"); nil: the host's name. + Who func(Host) string +} + +// who made h, for a message. +func (e Env) who(h Host) string { + if strings.HasPrefix(h.Ref, RefPrefix) { + return "container " + strings.TrimPrefix(h.Ref, RefPrefix) + } + if e.Who != nil { + if w := e.Who(h); w != "" { + return w + } + } + if h.Ref == "" && h.By == "" { + return "you" // made by hand on the Names page + } + return h.Name +} + +// pointsAt says whether h already sends its name to the container's port: +// one of the container's own addresses, or the host and the port it +// publishes (the host network: the host and the port itself). +func pointsAt(h Host, c Container, port int, hostNames []string) bool { + a := strings.ToLower(strings.TrimSuffix(h.Target.Address, ".")) + if h.Target.Port == port && !c.HostNet { + for _, ip := range c.IPs { + if ip == a { + return true + } + } + for _, ip := range c.Addrs { + if ip.String() == a { + return true + } + } + } + hostPort := c.Published[port] + if c.HostNet { + hostPort = port + } + if hostPort == 0 || h.Target.Port != hostPort { + return false + } + for _, n := range hostNames { + if n = strings.ToLower(n); n != "" && (n == a || strings.HasPrefix(a, n+".") || strings.HasPrefix(n, a+".")) { + return true + } + } + return false +} + +// Reconcile turns the labelled containers and the admin's hosts into a plan. +func Reconcile(containers []Container, hosts []Host, env Env) Plan { + p := Plan{Update: map[string]HostInput{}} + byRef := map[string]Host{} + taken := map[string]Host{} // every live name, label and extra, by whoever holds it + for _, h := range hosts { + if h.Ref != "" { + byRef[h.Ref] = h + } + taken[h.Name] = h + for _, n := range h.ExtraNames { + taken[n] = h + } + } + seen := map[string]bool{} + claimed := map[string]string{} // names this plan hands out, by container + for _, c := range containers { + if c.Name == env.Self { + continue + } + w := Parse(c, env.HomeOnly) + existing, have := byRef[w.Ref] + // A stopped container keeps its name and shows down (it has no + // address to compare); one that never ran gets its name on start. + if c.State != "running" { + if have { + seen[existing.ID] = true + } + continue + } + // A container the plan cannot name keeps the host it already has, + // as it is, rather than losing it over a label edit gone wrong. + keep := func() { + if have { + seen[existing.ID] = true + } + } + if w.Problem != "" { + p.Problems = append(p.Problems, Problem{c.Name, w.Problem}) + keep() + continue + } + addr, port, note, problem := Address(c, env.Reach, env.HostAddress, w.Port) + if problem != "" { + p.Problems = append(p.Problems, Problem{c.Name, problem}) + keep() + continue + } + conflict, same := "", "" + for _, n := range append([]string{w.Name}, w.ExtraNames...) { + if by, ok := claimed[n]; ok && by != c.Name { + conflict = fmt.Sprintf("name %s is taken by container %s", n, by) + break + } + if h, ok := taken[n]; ok && h.Ref != w.Ref { + if !strings.HasPrefix(h.Ref, RefPrefix) && pointsAt(h, c, w.Port, append([]string{env.HostAddress}, env.HostNames...)) { + same = fmt.Sprintf("already named %s by %s", n, env.who(h)) + break + } + conflict = fmt.Sprintf("name %s is taken by %s", n, env.who(h)) + break + } + } + if same != "" { + // One app, two sources: the other one's name stays, nothing + // is wrong, and a name the label made earlier is not kept twice. + p.Elsewhere = append(p.Elsewhere, Problem{c.Name, same}) + continue + } + if conflict != "" { + p.Problems = append(p.Problems, Problem{c.Name, conflict}) + keep() + continue + } + var also []string + for _, h := range hosts { + if h.Ref != w.Ref && !strings.HasPrefix(h.Ref, RefPrefix) && pointsAt(h, c, w.Port, append([]string{env.HostAddress}, env.HostNames...)) { + also = append(also, fmt.Sprintf("%s by %s", h.Name, env.who(h))) + } + } + if len(also) > 0 { + sort.Strings(also) + p.Elsewhere = append(p.Elsewhere, Problem{c.Name, "also named " + strings.Join(also, ", ")}) + } + for _, n := range append([]string{w.Name}, w.ExtraNames...) { + claimed[n] = c.Name + } + in := HostInput{Name: w.Name, ExtraNames: w.ExtraNames, Ref: w.Ref, Access: "home"} + if in.ExtraNames == nil { + in.ExtraNames = []string{} + } + if w.Public { + in.Access = "internet" + } + in.Target.Address, in.Target.Port, in.Target.HTTPS = addr, port, w.HTTPS + in.Target.AcceptUntrusted = w.HTTPS + in.OwnOrigin = w.OwnOrigin + noteText := strings.TrimSpace(strings.Join(nonEmpty(w.Note, note), "; ")) + in.Note = ¬eText + if !have { + in.TrustedNetworks = []string{} + p.Create = append(p.Create, in) + continue + } + seen[existing.ID] = true + in.TrustedNetworks = existing.TrustedNetworks + if in.TrustedNetworks == nil { + in.TrustedNetworks = []string{} + } + if differs(existing, in) { + p.Update[existing.ID] = in + } + } + for _, h := range hosts { + if strings.HasPrefix(h.Ref, RefPrefix) && !seen[h.ID] { + p.Delete = append(p.Delete, h.ID) + } + } + sort.Strings(p.Delete) + return p +} + +func differs(h Host, in HostInput) bool { + if h.Name != in.Name || !equal(h.ExtraNames, in.ExtraNames) || h.Access != in.Access { + return true + } + if h.Target.Address != in.Target.Address || h.Target.Port != in.Target.Port || h.Target.HTTPS != in.Target.HTTPS { + return true + } + if in.OwnOrigin != nil && h.OwnOrigin != *in.OwnOrigin { + return true + } + if in.Note != nil && h.Note != *in.Note { + return true + } + return false +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func nonEmpty(ss ...string) []string { + var out []string + for _, s := range ss { + if s != "" { + out = append(out, s) + } + } + return out +} diff --git a/admin/internal/podman/sync_test.go b/admin/internal/podman/sync_test.go new file mode 100644 index 0000000..088cff4 --- /dev/null +++ b/admin/internal/podman/sync_test.go @@ -0,0 +1,165 @@ +package podman + +import ( + "net" + "strings" + "testing" +) + +func host(id, name, ref, addr string, port int) Host { + h := Host{ID: id, Name: name, Ref: ref, Access: "home"} + h.Target.Address, h.Target.Port = addr, port + return h +} + +var env = Env{Self: "reverse-proxy", Reach: Reach{Networks: []string{"podman"}}, HostAddress: "192.168.1.5"} + +func TestReconcileCreateUpdateDelete(t *testing.T) { + radarr := ct("radarr", map[string]string{LabelOn: "true", LabelImagePort: "7878"}) + sonarr := ct("sonarr", map[string]string{LabelName: "tv", LabelImagePort: "8989"}) + sonarr.IPs["podman"] = "10.88.0.6" + self := ct("reverse-proxy", map[string]string{LabelOn: "true", LabelImagePort: "8443"}) + hosts := []Host{ + host("h1", "radarr", "podman:radarr", "10.88.0.50", 7878), // moved: new IP + host("h2", "gone", "podman:gone", "10.88.0.7", 80), // container removed + host("h3", "paperless", "fjord:1:paperless", "10.88.0.8", 8000), + } + hosts[0].TrustedNetworks = []string{"10.0.0.0/8"} + p := Reconcile([]Container{radarr, sonarr, self}, hosts, env) + if len(p.Create) != 1 || p.Create[0].Name != "tv" || p.Create[0].Target.Address != "10.88.0.6" || p.Create[0].Ref != "podman:sonarr" { + t.Fatalf("create: %+v", p.Create) + } + up, ok := p.Update["h1"] + if !ok || up.Target.Address != "10.88.0.5" || strings.Join(up.TrustedNetworks, ",") != "10.0.0.0/8" { + t.Fatalf("update keeps the UI's fields: %+v", p.Update) + } + if len(p.Delete) != 1 || p.Delete[0] != "h2" { + t.Fatalf("delete only the podman host whose container is gone: %v", p.Delete) + } + if len(p.Problems) != 0 { + t.Fatalf("problems: %v", p.Problems) + } +} + +func TestReconcileUnchangedIsQuiet(t *testing.T) { + radarr := ct("radarr", map[string]string{LabelOn: "true", LabelImagePort: "7878"}) + h := host("h1", "radarr", "podman:radarr", "10.88.0.5", 7878) + p := Reconcile([]Container{radarr}, []Host{h}, env) + if len(p.Create)+len(p.Update)+len(p.Delete)+len(p.Problems) != 0 { + t.Fatalf("expected nothing: %+v", p) + } +} + +func TestReconcileNameTakenIsReportedNotStolen(t *testing.T) { + a := ct("radarr", map[string]string{LabelName: "movies", LabelImagePort: "7878"}) + b := ct("radarr-4k", map[string]string{LabelName: "movies", LabelImagePort: "7878"}) + byHand := host("h9", "movies", "", "192.168.1.9", 7878) + p := Reconcile([]Container{a, b}, []Host{byHand}, env) + if len(p.Create) != 0 || len(p.Problems) != 2 { + t.Fatalf("hand-made name kept: %+v", p) + } + for _, pr := range p.Problems { + if !strings.Contains(pr.Message, "taken by you") { + t.Fatalf("problem wording: %+v", pr) + } + } + p = Reconcile([]Container{a, b}, nil, env) + if len(p.Create) != 1 || p.Create[0].Ref != "podman:radarr" || len(p.Problems) != 1 || !strings.Contains(p.Problems[0].Message, "taken by container radarr") { + t.Fatalf("first seen keeps it: %+v", p) + } + // a host that holds the name keeps it while the other is reported + existing := host("h1", "movies", "podman:radarr-4k", "10.88.0.5", 7878) + p = Reconcile([]Container{a, b}, []Host{existing}, env) + if len(p.Delete) != 0 || len(p.Create) != 0 || len(p.Problems) != 1 || p.Problems[0].Container != "radarr" { + t.Fatalf("existing holder wins: %+v", p) + } +} + +func TestReconcileProblemsKeepTheHost(t *testing.T) { + c := ct("app", map[string]string{LabelOn: "true"}) // no port anywhere + h := host("h1", "app", "podman:app", "10.88.0.5", 80) + p := Reconcile([]Container{c}, []Host{h}, env) + if len(p.Delete) != 0 || len(p.Problems) != 1 { + t.Fatalf("a container with a problem is not deleted, it is reported: %+v", p) + } +} + +func TestReconcilePublicAndNote(t *testing.T) { + c := ct("app", map[string]string{LabelOn: "true", LabelImagePort: "80", LabelPublic: "true"}) + p := Reconcile([]Container{c}, nil, env) + if p.Create[0].Access != "internet" || *p.Create[0].Note != "" { + t.Fatalf("public: %+v", p.Create[0]) + } + e := env + e.HomeOnly = true + p = Reconcile([]Container{c}, nil, e) + if p.Create[0].Access != "home" || !strings.Contains(*p.Create[0].Note, "home only") { + t.Fatalf("home only: %+v", p.Create[0]) + } +} + +func TestReconcileStoppedKeepsTheName(t *testing.T) { + c := ct("radarr", map[string]string{LabelOn: "true", LabelImagePort: "7878"}) + c.State, c.IPs = "exited", map[string]string{} + h := host("h1", "radarr", "podman:radarr", "10.88.0.5", 7878) + p := Reconcile([]Container{c}, []Host{h}, env) + if len(p.Delete)+len(p.Update)+len(p.Problems) != 0 { + t.Fatalf("stopped is not gone and not a problem: %+v", p) + } + if p = Reconcile([]Container{c}, nil, env); len(p.Create)+len(p.Problems) != 0 { + t.Fatalf("never started: nothing yet, no problem: %+v", p) + } +} + +func fjordWho(h Host) string { + if strings.HasPrefix(h.Ref, "fjord:") { + return "fjord on jupiter" + } + return "" +} + +func TestReconcileSameNameSamePlaceIsNotAProblem(t *testing.T) { + // jupiter: fjord names radarr at its epair address; the label asks the same + radarr := ct("radarr", map[string]string{LabelOn: "true", LabelImagePort: "7878"}) + radarr.Networks, radarr.IPs, radarr.Addrs = []string{"vlan5"}, map[string]string{}, []net.IP{net.ParseIP("192.168.5.10")} + fj := host("f1", "radarr", "fjord:e815:radarr", "192.168.5.10", 7878) + e := env + _, own, _ := net.ParseCIDR("192.168.5.145/24") + e.Reach = Reach{Networks: []string{"vlan5"}, Subnets: []*net.IPNet{own}} + e.Who = fjordWho + p := Reconcile([]Container{radarr}, []Host{fj}, e) + if len(p.Create)+len(p.Update)+len(p.Delete)+len(p.Problems) != 0 { + t.Fatalf("one app, two sources: nothing to do, no problem: %+v", p) + } + if len(p.Elsewhere) != 1 || p.Elsewhere[0].Message != "already named radarr by fjord on jupiter" { + t.Fatalf("elsewhere: %+v", p.Elsewhere) + } +} + +func TestReconcileSameNameOtherPlaceSaysWho(t *testing.T) { + radarr := ct("radarr", map[string]string{LabelOn: "true", LabelImagePort: "7878"}) + fj := host("f1", "radarr", "fjord:e815:radarr", "192.168.5.99", 7878) + e := env + e.Who = fjordWho + p := Reconcile([]Container{radarr}, []Host{fj}, e) + if len(p.Create) != 0 || len(p.Problems) != 1 || p.Problems[0].Message != "name radarr is taken by fjord on jupiter" { + t.Fatalf("clash: %+v", p) + } +} + +func TestReconcileOtherNameSamePlaceIsNoted(t *testing.T) { + // published port, named by fjord through the host's name + sonarr := ct("sonarr", map[string]string{LabelName: "tv", LabelImagePort: "8989"}) + sonarr.Networks, sonarr.IPs, sonarr.Addrs = []string{"other"}, map[string]string{"other": "10.89.0.4"}, nil + sonarr.Published[8989] = 18989 + fj := host("f1", "sonarr", "fjord:e815:sonarr", "jupiter", 18989) + e := env + e.HostNames, e.Who = []string{"jupiter.ahze.lan", "192.168.1.5"}, fjordWho + p := Reconcile([]Container{sonarr}, []Host{fj}, e) + if len(p.Create) != 1 || p.Create[0].Name != "tv" || len(p.Problems) != 0 { + t.Fatalf("a second name is allowed: %+v", p) + } + if len(p.Elsewhere) != 1 || p.Elsewhere[0].Message != "also named sonarr by fjord on jupiter" { + t.Fatalf("elsewhere: %+v", p.Elsewhere) + } +} diff --git a/admin/internal/server/hosts.go b/admin/internal/server/hosts.go index 88d3394..0460ede 100644 --- a/admin/internal/server/hosts.go +++ b/admin/internal/server/hosts.go @@ -28,6 +28,9 @@ type hostInput struct { OwnOrigin *bool `json:"ownOrigin,omitempty"` // Watch is kept as it was when absent, for the same reason. Watch *bool `json:"watch,omitempty"` + // Note is kept as it was when absent: only the source that made the + // host (podman-labels) sends it. + Note *string `json:"note,omitempty"` } // hostView is a host as the API presents it, with the names resolved. @@ -77,6 +80,12 @@ func (in hostInput) apply(h *state.Host) { if in.Watch != nil { h.Unwatched = !*in.Watch } + if in.Note != nil { + h.Note = strings.TrimSpace(*in.Note) + if len(h.Note) > 200 { + h.Note = h.Note[:200] + } + } } // listHosts is the live hosts; ?include=removed adds the ones removed in @@ -85,7 +94,14 @@ 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 + hosts = hosts[:0:0] + for _, h := range st.Hosts { + // Held names come back with the container-labels switch, not + // with Undo: they are not listed as removed. + if !h.Held { + hosts = append(hosts, h) + } + } } out := []hostView{} for _, h := range hosts { @@ -139,6 +155,9 @@ func (s *Server) createHost(w http.ResponseWriter, r *http.Request) { if st.Settings.Domain == "" { return &state.FieldError{Field: "", Msg: "set the address your services live under first (settings)"} } + if err := labelsOff(st, h.Ref); err != nil { + return err + } // 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 { @@ -159,6 +178,23 @@ func (s *Server) createHost(w http.ResponseWriter, r *http.Request) { continue } h.ID, h.CreatedAt = o.ID, o.CreatedAt + if h.Ref == "" { + *o = h // added again by hand: a new name, nothing carried over + return nil + } + // What the UI set comes back with it: a name that left with + // its container (or with container labels turned off) returns + // as it was, not as new. + h.Icon = o.Icon + if in.Watch == nil { + h.Unwatched = o.Unwatched + } + if in.OwnOrigin == nil { + h.OwnOrigin = o.OwnOrigin + } + if len(h.TrustedNetworks) == 0 { + h.TrustedNetworks = o.TrustedNetworks + } *o = h return nil } @@ -191,6 +227,9 @@ func (s *Server) updateHost(w http.ResponseWriter, r *http.Request) { } h := st.Hosts[i] in.apply(&h) + if err := labelsOff(st, h.Ref); err != nil { + return err + } if err := state.ValidateHost(h, st.Hosts); err != nil { return err } @@ -242,7 +281,10 @@ func (s *Server) restoreHost(w http.ResponseWriter, r *http.Request) { return errNotFound } h := st.Hosts[i] - h.DeletedAt = nil + if err := labelsOff(st, h.Ref); err != nil { + return err + } + h.DeletedAt, h.Held = nil, false if err := state.ValidateHost(h, st.Hosts); err != nil { return err // its name was reused meanwhile } @@ -377,3 +419,12 @@ func (e sentinel) Error() string { return string(e) } const errRefTaken sentinel = "a host with this ref exists" const errNotFound = sentinel("not found") + +// labelsOff refuses a name from container labels while they are turned off: +// a reconcile already under way must not bring back what the switch removed. +func labelsOff(st *state.State, ref string) error { + if st.Settings.Sources.PodmanOff && strings.HasPrefix(ref, state.PodmanRefPrefix) { + return &state.FieldError{Field: "", Msg: "names from container labels are turned off (Settings)"} + } + return nil +} diff --git a/admin/internal/server/openapi.json b/admin/internal/server/openapi.json index ad25048..b4e4462 100644 --- a/admin/internal/server/openapi.json +++ b/admin/internal/server/openapi.json @@ -1049,6 +1049,10 @@ "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." }, + "note": { + "type": "string", + "description": "A line from the source that made the host: for a container named by its labels (ref podman:), a label the proxy could not honor or how the container is reached. Shown on the Names page; 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." diff --git a/admin/internal/server/podman_switch_test.go b/admin/internal/server/podman_switch_test.go new file mode 100644 index 0000000..a1c8bf5 --- /dev/null +++ b/admin/internal/server/podman_switch_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/daemonless/reverse-proxy/admin/internal/state" +) + +// Turning container labels off removes their names at once, held past the +// undo window; turning them on and naming the container again brings the +// same host back with what the UI set on it. +func TestPodmanSwitchHoldsAndRevives(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","ref":"podman:radarr","trustedNetworks":[],"target":{"address":"10.88.0.5","port":7878}}` + rr, h := c.do("POST", "/api/v1/hosts", body) + if rr.Code != 201 { + t.Fatalf("create: %d %v", rr.Code, h) + } + id, _ := h["id"].(string) + hand := `{"name":"paperless","target":{"address":"10.88.0.9","port":8000}}` + if rr, _ := c.do("POST", "/api/v1/hosts", hand); rr.Code != 201 { + t.Fatalf("hand-made: %d", rr.Code) + } + s.mgr.Apply(func(st *state.State) error { + i := st.Find(id) + st.Hosts[i].Icon, st.Hosts[i].TrustedNetworks, st.Hosts[i].Unwatched = "radarr.svg", []string{"100.64.0.0/10"}, true + return nil + }) + + flip := func(on bool) { + t.Helper() + v := "false" + if on { + v = "true" + } + rr := httptest.NewRecorder() + s.putPodmanEnabled(rr, httptest.NewRequest("PUT", "/api/v1/sources/podman/enabled", strings.NewReader(`{"enabled":`+v+`}`))) + if rr.Code != 200 { + t.Fatalf("switch %v: %d %s", on, rr.Code, rr.Body) + } + } + + flip(false) + st := s.mgr.State() + if i := st.Find(id); st.Hosts[i].DeletedAt == nil || !st.Hosts[i].Held { + t.Fatalf("off removes and holds: %+v", st.Hosts[i]) + } + for _, o := range st.Hosts { + if o.Name == "paperless" && o.DeletedAt != nil { + t.Fatal("off touched a name not made from a label") + } + } + if rr, out := c.do("POST", "/api/v1/hosts", body); rr.Code < 400 { + t.Fatalf("a label name while off is refused: %d %v", rr.Code, out) + } + if rr, _ := c.do("POST", "/api/v1/hosts/"+id+"/restore", ""); rr.Code < 400 { + t.Fatalf("restore while off is refused: %d", rr.Code) + } + if _, out := c.do("GET", "/api/v1/hosts?include=removed", ""); strings.Contains(fmt.Sprint(out), "radarr") { + t.Fatalf("a held name is not listed as removed: %v", out) + } + if rr, _ := c.do("PUT", "/api/v1/sources/podman", `{"socket":true}`); rr.Code != 403 { + t.Fatalf("an app's key cannot write the unit's report: %d", rr.Code) + } + s.mgr.Apply(func(st *state.State) error { st.Purge(time.Now().Add(72 * time.Hour)); return nil }) + if s.mgr.State().Find(id) < 0 { + t.Fatal("a held name outlives the undo window") + } + + flip(true) + if rr, h = c.do("POST", "/api/v1/hosts", body); rr.Code != 201 || h["id"] != id { + t.Fatalf("revive: %d %v", rr.Code, h) + } + st = s.mgr.State() + got := st.Hosts[st.Find(id)] + if got.DeletedAt != nil || got.Held || got.Icon != "radarr.svg" || !got.Unwatched || strings.Join(got.TrustedNetworks, ",") != "100.64.0.0/10" { + t.Fatalf("revive keeps what the UI set: %+v", got) + } +} + +// A name removed and added again by hand is a new name: nothing the old one +// had (trusted networks, icon) is carried over to what may be another machine. +func TestReaddByHandStartsFresh(t *testing.T) { + s, _ := newTestServer(t) + key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if _, err := RegisterPairingKey(s.mgr, key, "fjord", "", ""); err != nil { + t.Fatal(err) + } + s.mgr.Apply(func(st *state.State) error { st.Settings.Domain = "apps.lan"; return nil }) + c := &client{t: t, s: s, bearer: key} + _, h := c.do("POST", "/api/v1/hosts", `{"name":"nas","trustedNetworks":["10.8.0.0/24"],"target":{"address":"192.168.1.9","port":5000}}`) + id, _ := h["id"].(string) + c.do("DELETE", "/api/v1/hosts/"+id, "") + rr, h := c.do("POST", "/api/v1/hosts", `{"name":"nas","target":{"address":"192.168.1.10","port":5000}}`) + if rr.Code != 201 || h["trustedNetworks"] != nil { + t.Fatalf("re-added by hand keeps nothing: %d %v", rr.Code, h["trustedNetworks"]) + } +} diff --git a/admin/internal/server/server.go b/admin/internal/server/server.go index 1b7f7d2..a180744 100644 --- a/admin/internal/server/server.go +++ b/admin/internal/server/server.go @@ -139,6 +139,9 @@ func New(version string, caddy Runner, mgr *app.Manager, setupToken, secrets str 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/sources/podman", s.requireAdmin(s.getPodmanSource)) + s.mux.HandleFunc("PUT /api/v1/sources/podman", s.requireAdmin(s.putPodmanSource)) + s.mux.HandleFunc("PUT /api/v1/sources/podman/enabled", s.requireAdmin(s.putPodmanEnabled)) 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)) diff --git a/admin/internal/server/sources.go b/admin/internal/server/sources.go index 8c0127f..d51297d 100644 --- a/admin/internal/server/sources.go +++ b/admin/internal/server/sources.go @@ -4,6 +4,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/daemonless/reverse-proxy/admin/internal/sources" "github.com/daemonless/reverse-proxy/admin/internal/state" @@ -98,3 +99,87 @@ func (s *Server) fjordServices(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"services": out, "fjord": sources.HostOf(f.URL)}) } + +// getPodmanSource is what the podman-labels unit last reported; nil when +// it never did (no socket mounted, or an image without the unit). +func (s *Server) getPodmanSource(w http.ResponseWriter, _ *http.Request) { + src := s.mgr.State().Settings.Sources + out := struct { + state.PodmanSource + Enabled bool `json:"enabled"` + }{Enabled: !src.PodmanOff} + if src.Podman != nil { + out.PodmanSource = *src.Podman + } + writeJSON(w, http.StatusOK, out) +} + +// putPodmanEnabled is the Settings switch. Off removes every name made +// from a label at once, held so it comes back as it was; on lets them go +// back to the normal undo window, and the unit names the containers again. +func (s *Server) putPodmanEnabled(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 in.Enabled == nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "enabled is required"}) + return + } + now := time.Now().UTC() + err := s.mgr.Apply(func(st *state.State) error { + st.Settings.Sources.PodmanOff = !*in.Enabled + for i := range st.Hosts { + h := &st.Hosts[i] + if !strings.HasPrefix(h.Ref, state.PodmanRefPrefix) { + continue + } + if !*in.Enabled && h.DeletedAt == nil { + h.DeletedAt, h.Held = &now, true + } else if *in.Enabled && h.Held { + h.DeletedAt, h.Held = &now, false + } + } + return nil + }) + if err != nil { + writeError(w, err) + return + } + s.getPodmanSource(w, r) +} + +// putPodmanSource is the unit's report after each reconcile. Only the unit +// writes it, over the admin's local socket. +func (s *Server) putPodmanSource(w http.ResponseWriter, r *http.Request) { + if s.who(r) != local { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "only the podman-labels unit reports here"}) + return + } + var in state.PodmanSource + if err := decode(w, r, &in); err != nil { + writeError(w, err) + return + } + in.At = time.Now().UTC() + if len(in.Waiting) > 100 { + in.Waiting = in.Waiting[:100] + } + if len(in.Problems) > 100 { + in.Problems = in.Problems[:100] + } + if len(in.Elsewhere) > 100 { + in.Elsewhere = in.Elsewhere[:100] + } + err := s.mgr.Note(func(st *state.State) { + st.Settings.Sources.Podman = &in + }) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, in) +} diff --git a/admin/internal/state/model.go b/admin/internal/state/model.go index d20efcd..f632846 100644 --- a/admin/internal/state/model.go +++ b/admin/internal/state/model.go @@ -92,7 +92,32 @@ const ( // Sources holds the connections Add host can pick from. type Sources struct { - Fjord *FjordSource `json:"fjord,omitempty"` + Fjord *FjordSource `json:"fjord,omitempty"` + Podman *PodmanSource `json:"podman,omitempty"` + // PodmanOff turns names from container labels off while podman's + // socket is mounted. Set from Settings, never by the unit's report. + PodmanOff bool `json:"podmanOff,omitempty"` +} + +// PodmanSource is what the podman-labels unit last reported: whether the +// socket answers, the labelled containers it saw, the ones waiting for +// setup and the ones it could not name. Written by the unit, read by the +// UI; nothing here is edited by hand. +type PodmanSource struct { + Socket bool `json:"socket"` + Containers int `json:"containers"` + Waiting []string `json:"waiting,omitempty"` + Problems []PodmanProblem `json:"problems,omitempty"` + // Elsewhere are labelled containers another app already names. + Elsewhere []PodmanProblem `json:"elsewhere,omitempty"` + Error string `json:"error,omitempty"` + At time.Time `json:"at"` +} + +// PodmanProblem is a labelled container that got no name, and why. +type PodmanProblem struct { + Container string `json:"container"` + Message string `json:"message"` } // FjordSource is a fjord instance to read stacks from. @@ -128,9 +153,17 @@ type Host struct { // 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"` + Unwatched bool `json:"unwatched,omitempty"` + // Note is a line from the source that made the host (the podman-labels + // unit: a label it could not honor, how the target is reached). Shown + // on the Names page, never edited by hand. + Note string `json:"note,omitempty"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"deletedAt,omitempty"` // soft delete; Undo clears it + // Held is a container-label name removed by turning container labels + // off: kept past the undo window, so turning them on again brings it + // back as it was. + Held bool `json:"held,omitempty"` } // Target is where the service is. @@ -231,6 +264,9 @@ func (st *State) Find(id string) int { return -1 } +// PodmanRefPrefix starts the ref of a name made from container labels. +const PodmanRefPrefix = "podman:" + // UndoWindow is how long a deleted host can be restored. const UndoWindow = 24 * time.Hour @@ -251,7 +287,7 @@ func (st *State) Purge(now time.Time) { kept := st.Hosts[:0] for _, h := range st.Hosts { if h.DeletedAt != nil { - if now.Sub(*h.DeletedAt) > UndoWindow { + if now.Sub(*h.DeletedAt) > UndoWindow && !h.Held { continue } if h.Name != "" && (live[h.Name] || !h.DeletedAt.Equal(latest[h.Name])) { diff --git a/admin/ui/package-lock.json b/admin/ui/package-lock.json index fd38120..a045a25 100644 --- a/admin/ui/package-lock.json +++ b/admin/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "reverse-proxy-ui", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reverse-proxy-ui", - "version": "0.2.0", + "version": "0.3.0", "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.2.0", "@tailwindcss/vite": "^4.3.3", diff --git a/admin/ui/package.json b/admin/ui/package.json index 71fcab0..e69986c 100644 --- a/admin/ui/package.json +++ b/admin/ui/package.json @@ -1,7 +1,7 @@ { "name": "reverse-proxy-ui", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite --host", diff --git a/admin/ui/src/app.css b/admin/ui/src/app.css index 2aed355..b354d40 100644 --- a/admin/ui/src/app.css +++ b/admin/ui/src/app.css @@ -113,3 +113,4 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { outline: non .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; } +.state-pill { @apply inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full; } diff --git a/admin/ui/src/components/AlertSettings.svelte b/admin/ui/src/components/AlertSettings.svelte index d038c6b..3cf2805 100644 --- a/admin/ui/src/components/AlertSettings.svelte +++ b/admin/ui/src/components/AlertSettings.svelte @@ -1,4 +1,6 @@ +
{#each networks as n (n.network)} {@const mine = n.network === here} - +
+ {netName(n.network)} + The proxy is {n.address} here{#if mine}. You're on this network, so it stays on.{/if} + ontoggle(n.network, on)} /> +
{/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.
diff --git a/admin/ui/src/components/SettingRow.svelte b/admin/ui/src/components/SettingRow.svelte new file mode 100644 index 0000000..52d916c --- /dev/null +++ b/admin/ui/src/components/SettingRow.svelte @@ -0,0 +1,14 @@ + + +
+
+ {title} + {#if children}{@render children()}{/if} +
+ {#if control}
{@render control()}
{/if} +
diff --git a/admin/ui/src/components/Switch.svelte b/admin/ui/src/components/Switch.svelte new file mode 100644 index 0000000..6882ff2 --- /dev/null +++ b/admin/ui/src/components/Switch.svelte @@ -0,0 +1,20 @@ + + + diff --git a/admin/ui/src/lib/api.ts b/admin/ui/src/lib/api.ts index 0de6030..2ec6611 100644 --- a/admin/ui/src/lib/api.ts +++ b/admin/ui/src/lib/api.ts @@ -65,6 +65,7 @@ export type Host = { ownOrigin?: boolean icon?: string // ".": the app that made the name sent one unwatched?: boolean // not checked for uptime, no alerts + note?: string // from the source that made it (podman-labels): a label it could not honor, how it is reached createdAt: string deletedAt?: string names: string[] @@ -82,6 +83,10 @@ export type Session = { authenticated: boolean; kind: string; setupRequired: boo 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 } +/** What the podman-labels unit last saw: no socket, or N labelled containers, the ones waiting for setup and the ones it could not name. */ +export type PodmanSource = { socket: boolean; enabled: boolean; containers: number; waiting?: string[]; problems?: { container: string; message: string }[]; elsewhere?: { container: string; message: string }[]; error?: string; at?: string } +/** The container a host came from, when a label made it. */ +export const containerOf = (h: { ref?: string }) => (h.ref?.startsWith('podman:') ? h.ref.slice('podman:'.length) : '') 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 } diff --git a/admin/ui/src/screens/HostForm.svelte b/admin/ui/src/screens/HostForm.svelte index 83a254a..53430a8 100644 --- a/admin/ui/src/screens/HostForm.svelte +++ b/admin/ui/src/screens/HostForm.svelte @@ -1,6 +1,6 @@ - - -
- - - diff --git a/admin/ui/package-lock.json b/admin/ui/package-lock.json deleted file mode 100644 index a045a25..0000000 --- a/admin/ui/package-lock.json +++ /dev/null @@ -1,1840 +0,0 @@ -{ - "name": "reverse-proxy-ui", - "version": "0.3.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "reverse-proxy-ui", - "version": "0.3.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 e69986c..0000000 --- a/admin/ui/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "reverse-proxy-ui", - "private": true, - "version": "0.3.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 b354d40..0000000 --- a/admin/ui/src/app.css +++ /dev/null @@ -1,116 +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; } -.state-pill { @apply inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full; } diff --git a/admin/ui/src/components/AlertSettings.svelte b/admin/ui/src/components/AlertSettings.svelte deleted file mode 100644 index 3cf2805..0000000 --- a/admin/ui/src/components/AlertSettings.svelte +++ /dev/null @@ -1,106 +0,0 @@ - - -
-

Uptime and alerts

-
-
- - {alerts?.enabled === false ? 'Off. Names are not checked, and no alerts are sent. Their history is kept.' : "Once a minute. Down means two checks in a row got no answer. A name's own page can turn it off for that name."} - {#snippet control()}{#if alerts} setEnabled(on)} />{/if}{/snippet} - -
- {#if alerts?.enabled} -
-
- - Alerts - {#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 b018613..0000000 --- a/admin/ui/src/components/NetworkChoice.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - -
-{#each networks as n (n.network)} - {@const mine = n.network === here} -
- {netName(n.network)} - The proxy is {n.address} here{#if mine}. You're on this network, so it stays on.{/if} - ontoggle(n.network, on)} /> -
-{/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/SettingRow.svelte b/admin/ui/src/components/SettingRow.svelte deleted file mode 100644 index 52d916c..0000000 --- a/admin/ui/src/components/SettingRow.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -
-
- {title} - {#if children}{@render children()}{/if} -
- {#if control}
{@render control()}
{/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/Switch.svelte b/admin/ui/src/components/Switch.svelte deleted file mode 100644 index 6882ff2..0000000 --- a/admin/ui/src/components/Switch.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - 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 2ec6611..0000000 --- a/admin/ui/src/lib/api.ts +++ /dev/null @@ -1,106 +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 - note?: string // from the source that made it (podman-labels): a label it could not honor, how it is reached - 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 } -/** What the podman-labels unit last saw: no socket, or N labelled containers, the ones waiting for setup and the ones it could not name. */ -export type PodmanSource = { socket: boolean; enabled: boolean; containers: number; waiting?: string[]; problems?: { container: string; message: string }[]; elsewhere?: { container: string; message: string }[]; error?: string; at?: string } -/** The container a host came from, when a label made it. */ -export const containerOf = (h: { ref?: string }) => (h.ref?.startsWith('podman:') ? h.ref.slice('podman:'.length) : '') -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 53430a8..0000000 --- a/admin/ui/src/screens/HostForm.svelte +++ /dev/null @@ -1,263 +0,0 @@ - - -
-
- - - Names - -
{editing ? 'Edit name' : 'Add a name'}
- -
-
- -{#if loaded} -
- {#if editing && savedTarget}{/if} - {#if fromContainer} -
Named by the labels on container {fromContainer}. The name, address, port and who can open it come from the labels; change them there and the proxy follows. Everything below Advanced is yours.
- {/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 disabled={!!fromContainer} /> - .{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 467f3b6..0000000 --- a/admin/ui/src/screens/Hosts.svelte +++ /dev/null @@ -1,328 +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 note(h: Host)} - {#if h.note}{h.note}{/if} - {/snippet} - {#snippet dot(st: { ok: boolean | null })} - - {/snippet} - - {#snippet chips(h: Host)} - {#if containerOf(h)} - - From container {containerOf(h)} - {:else 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 podman?.enabled && podman?.problems?.length} - -
- {podman.problems.length === 1 ? 'A container with a proxy label got no name' : `${podman.problems.length} containers with proxy labels got no name`} -
    - {#each podman.problems as p (p.container)}
  • {p.container}: {p.message}
  • {/each} -
-
- {/if} - {#if podman?.enabled && podman?.elsewhere?.length} - -
- {podman.elsewhere.length === 1 ? '1 labelled container is also named by another app' : `${podman.elsewhere.length} labelled containers are also named by another app`} -
    - {#each podman.elsewhere as p (p.container)}
  • {p.container}: {p.message}
  • {/each} -
-
- {/if} - {#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 note(h)} - {@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 1551a8f..0000000 --- a/admin/ui/src/screens/Settings.svelte +++ /dev/null @@ -1,644 +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

-
- - Open a name from a short word, like {settings.shortLinks || 'go'}/radarr. - {#snippet control()} saveShort(on ? 'go' : '')} />{/snippet} - - {#if settings.shortLinks} -
- - Any of your names works, with its path: {settings.shortLinks}/radarr/settings. {settings.shortLinks}/ alone lists them. - {#snippet control()} - {#if !shortEdit}{/if} - {/snippet} - - {#if shortEdit} -
{ e.preventDefault(); saveShort(shortWord.trim().toLowerCase()) }}> -
/radarr
- - -
- {/if} -
-
-
- - - {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 shortError}
{shortError}
{/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 ones switched on 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} -
-
- - -
-

Container labels

-
- - {#if podman?.socket && !podman.enabled} - Off. The proxy can read podman; turn this on to name containers with a proxy label. - {:else if podman?.socket && !podman.error} - {podman.containers === 0 ? 'Reading podman. No container carries a proxy label yet.' : podman.containers === 1 ? 'Reading podman. 1 container carries a proxy label.' : `Reading podman. ${podman.containers} containers carry proxy labels.`}{#if podman.problems?.length} {podman.problems.length === 1 ? 'One got no name; the Names page says why.' : `${podman.problems.length} got no name; the Names page says why.`}{/if}{#if podman.elsewhere?.length} {podman.elsewhere.length === 1 ? 'One is also named by another app.' : `${podman.elsewhere.length} are also named by another app.`}{/if} - {:else if podman?.error} - The proxy cannot read podman. - {:else} - A container on this machine with a proxy label gets a name here. - {/if} - {#snippet control()} - {#if podman?.socket} - {#if podman.error && podman.enabled}Problem{/if} - - {:else if podman?.error} - Problem - {:else} - Off - {/if} - {/snippet} - - {#if podman?.error && podman?.enabled} -
{podman.error}
- {/if} - {#if !podman?.socket || podman?.error} -
- {podman?.error ? 'Check the mount' : 'To turn it on'} - Mount the host's podman socket folder into the proxy. In the compose: -
- /var/run/podman:/var/run/podman:ro
- Then give a container the label io.daemonless.proxy=true. The socket lets the proxy control podman, so mount it only if you trust the proxy with that. -
- {/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 6bf315e..0000000 --- a/admin/ui/src/screens/Setup.svelte +++ /dev/null @@ -1,457 +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 ones switched on can open it and your home-only names.

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

Not sure? Leave them all on. 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 waitingContainers.length} -

{waitingContainers.length === 1 ? `Container ${waitingContainers[0]} carries a proxy label and gets its name now.` : `${waitingContainers.length} containers carry proxy labels and get their names now.`}

- {/if} - {#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 f0ed2a9..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 @@ -138,9 +138,10 @@ x-daemonless: 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 container keeps its name and shows as - down; a removed one loses it, restorably for a day; a recreated one - (new address, or a new name in its labels) is followed. A label the + `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