From 6e84d3f8c1caced1f0cff2fe3e38464d6ab61a13 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Mon, 9 Nov 2026 11:47:26 -0500 Subject: [PATCH] fix: let any app link and give names, not only fjord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connected apps counted only names whose ref started with fjord:, so another app such as Sylve showed none. Names are now counted by the app's id in the ref, whatever comes before it, or by the key that made them, which each new name records. Linking is worded for any app (Link an app…), an app that links without a name is shown as Linked app, and the API reference opens with the steps for adding an app. --- README.md | 14 ++++++++- admin/internal/server/auth.go | 42 +++++++++++++------------- admin/internal/server/hosts.go | 3 +- admin/internal/server/openapi.json | 12 +++++--- admin/internal/server/pair.go | 12 ++++---- admin/internal/server/pairing_test.go | 43 +++++++++++++++++++++++++++ admin/internal/server/server.go | 8 ++--- admin/internal/state/model.go | 9 ++++-- admin/ui/src/App.svelte | 6 ++-- admin/ui/src/lib/api.ts | 1 + admin/ui/src/lib/paired.svelte.ts | 13 +++++--- admin/ui/src/screens/Hosts.svelte | 7 ++--- admin/ui/src/screens/Settings.svelte | 35 +++++++++++----------- compose.yaml | 14 ++++++++- 14 files changed, 151 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 620f616..2bc0944 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ address you use today. It is [Caddy](https://caddyserver.com) underneath, manage - **Short links.** Type `go/radarr` in any browser on your network. `go/` alone lists every name. - **Walks you through your router.** Setup says exactly which record to add, with steps for OPNsense, pfSense, UniFi, Pi-hole, AdGuard Home and others, and turns green when it works. - **Says what is wrong, in plain words.** A certificate that fails says why (token rejected, record never appeared, rate limit) and how to fix it. A name nobody set up shows a page saying so, at home. -- **Works with [fjord](https://github.com/daemonless/fjord).** Installed from fjord, it pairs by itself: fjord lists your apps that have no name yet, names them in one click, and keeps each name pointing at its app when the app moves to a new address. +- **Works with [fjord](https://github.com/daemonless/fjord), and with your app.** Installed from fjord, it pairs by itself: fjord lists your apps that have no name yet, names them in one click, and keeps each name pointing at its app when the app moves to a new address. Any other app can link the same way, through the same API. - **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. @@ -292,6 +292,18 @@ 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. +### Your own app + +An app that manages services, like a jail or VM manager, can do what +fjord does. It gets a key one of three ways: the person presses "Link an +app…" under Settings → Connected apps and pastes the link into the app, +which redeems it at `POST /api/v1/pair`; the app installs the proxy with +`RP_PAIRING_KEY` set; or the person makes a key by hand. Then it says who +it is (`PUT /api/v1/me`), gives each service a name with a `ref` like +`::` so a retry never makes two, and moves the name +when the service's address changes. Connected apps shows each app and how +many names came from it. The steps are at the top of the API reference. + ### Scripts and other apps Anything that can make an HTTPS request can manage names. Make a key diff --git a/admin/internal/server/auth.go b/admin/internal/server/auth.go index 154dcd5..defb035 100644 --- a/admin/internal/server/auth.go +++ b/admin/internal/server/auth.go @@ -79,8 +79,8 @@ func (s *Server) tokenValid(tok string) (string, bool) { return "", false } -// touchInterval is how often a key's last use is written: fjord calls every -// minute, and the state file need not follow each call. +// touchInterval is how often a key's last use is written: an app may call +// every minute, and the state file need not follow each call. const touchInterval = 5 * time.Minute // touchToken records that key id was used now, at most once per @@ -465,34 +465,34 @@ func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) } -// namesFrom counts the live hosts each connected app made, by token id. A -// fjord's refs are fjord::; the installer also gets every -// fjord ref no id claims (fjord::, made before ids existed). +// namesFrom counts the live hosts each connected app made, by token id: by +// the app's id in the ref (::), which outlives a relink to +// a new key; else by the key that made it; else, for a fjord ref from +// before ids (fjord::), the installer's. func namesFrom(st *state.State) map[string]int { out := map[string]int{} - claimed := map[string]string{} // ref prefix -> token id + byClient := map[string]string{} // app id -> token id + live := map[string]bool{} + installer := "" for _, t := range st.Tokens { if t.ClientID != "" { - claimed["fjord:"+t.ClientID+":"] = t.ID + byClient[t.ClientID] = t.ID } + if t.Paired { + installer = t.ID + } + live[t.ID] = true } for _, h := range st.Live() { - if !strings.HasPrefix(h.Ref, "fjord:") { - continue - } owner := "" - for prefix, id := range claimed { - if strings.HasPrefix(h.Ref, prefix) { - owner = id - } + if parts := strings.SplitN(h.Ref, ":", 3); len(parts) == 3 { + owner = byClient[parts[1]] } - if owner == "" { - // fjord::, from before ids: the installer's. - for _, t := range st.Tokens { - if t.Paired { - owner = t.ID - } - } + if owner == "" && live[h.By] { + owner = h.By + } + if owner == "" && strings.HasPrefix(h.Ref, "fjord:") { + owner = installer } if owner != "" { out[owner]++ diff --git a/admin/internal/server/hosts.go b/admin/internal/server/hosts.go index 1ae808d..88d3394 100644 --- a/admin/internal/server/hosts.go +++ b/admin/internal/server/hosts.go @@ -24,7 +24,7 @@ type hostInput struct { TrustedNetworks []string `json:"trustedNetworks"` Ref string `json:"ref"` // OwnOrigin is kept as it was when absent: a client that predates it - // (fjord's port follow PUTs the host back) must not switch it off. + // (an app that PUTs the host back to follow a port) must not switch it off. OwnOrigin *bool `json:"ownOrigin,omitempty"` // Watch is kept as it was when absent, for the same reason. Watch *bool `json:"watch,omitempty"` @@ -133,6 +133,7 @@ func (s *Server) createHost(w http.ResponseWriter, r *http.Request) { } h := state.Host{ID: newID(), CreatedAt: time.Now().UTC()} in.apply(&h) + h.By, _ = s.callerToken(r) var existing *state.Host err := s.mgr.Apply(func(st *state.State) error { if st.Settings.Domain == "" { diff --git a/admin/internal/server/openapi.json b/admin/internal/server/openapi.json index f6b50d2..ad25048 100644 --- a/admin/internal/server/openapi.json +++ b/admin/internal/server/openapi.json @@ -4,7 +4,7 @@ "title": "Daemonless Reverse Proxy API", "version": "1", "summary": "Give services on a home network HTTPS names.", - "description": "The hosts API of the daemonless reverse proxy. A host is one name (radarr.apps.lan) in front of one service (an address and a port). The proxy issues the certificate and decides who may open the name.\n\nCalls are made to the admin, https://:8443, with `Authorization: Bearer `. A key is made in Settings > Connected apps, or given to the proxy at install as `RP_PAIRING_KEY`. A key manages hosts only; the proxy's own settings, certificates, backup and keys belong to the account and answer 403 to a key.\n\nThis is version 1 of the API. Fields may be added; nothing listed here is renamed or removed within version 1. `GET /api/v1/status` says which version a proxy speaks.\n\nInside the container the same calls work over the admin's unix socket without a key, which is what the `reverse-proxy hosts` command uses.", + "description": "The hosts API of the daemonless reverse proxy. A host is one name (radarr.apps.lan) in front of one service (an address and a port). The proxy issues the certificate and decides who may open the name.\n\nCalls are made to the admin, https://:8443, with `Authorization: Bearer `. A key is made in Settings > Connected apps, or given to the proxy at install as `RP_PAIRING_KEY`. A key manages hosts only; the proxy's own settings, certificates, backup and keys belong to the account and answer 403 to a key.\n\nThis is version 1 of the API. Fields may be added; nothing listed here is renamed or removed within version 1. `GET /api/v1/status` says which version a proxy speaks.\n\nInside the container the same calls work over the admin's unix socket without a key, which is what the `reverse-proxy hosts` command uses.\n\n## Adding your app\n\nfjord is one app that uses this API; any app that knows where its services live (a jail or VM manager, a container UI, a script) can do the same:\n\n1. **Get a key.** Either the person links your app: they press \"Link an app…\" under Connected apps and paste the link into your app, which sends the code to `POST /api/v1/pair` with its own id, name and address. Or, when your app installs the proxy, it sets `RP_PAIRING_KEY` (and `RP_PAIRING_NAME`, `RP_PAIRING_ID`, `RP_PAIRING_URL`) and the proxy takes that key on first start. A key made by hand under Connected apps works too.\n2. **Say who you are.** `PUT /api/v1/me` with your permanent id, the name to show and where your app is opened, whenever one of them changes.\n3. **Give names.** `POST /api/v1/hosts` with the name, the service's address and port, and a `ref` of the form `::` (for example `sylve::jail-104`). One host per ref: a retry gets `409` and the host already made, so a lost answer never makes two.\n4. **Follow the service.** When its address or port changes, `PUT /api/v1/hosts/{id}` with the new target; `GET /api/v1/probe` says first whether the proxy can reach it.\n5. **Optionally,** `PUT /api/v1/hosts/{id}/icon` with the service's icon, shown next to its name.", "license": { "name": "BSD-2-Clause", "identifier": "BSD-2-Clause" @@ -430,7 +430,7 @@ "trust" ], "summary": "Redeem a pairing code for a key", - "description": "Public, rate-limited. An app elsewhere (a fjord) sends a code the admin made under Connected apps (\"Connect a fjord…\", good for ten minutes, once) with its own id, name and address, and gets a key of its own plus what it needs to trust this proxy.", + "description": "Public, rate-limited. An app (fjord, or any other) sends a code the admin made under Connected apps (\"Link an app…\", good for ten minutes, once) with its own id, name and address, and gets a key of its own plus what it needs to trust this proxy.", "security": [ {} ], @@ -1041,8 +1041,8 @@ }, "ref": { "type": "string", - "description": "Opaque mark of where the host came from, e.g. `fjord::`, so a manager finds its hosts again. One host per ref.", - "example": "fjord:server:radarr" + "description": "Mark of where the host came from, so the app that made it finds it again: `::`. The proxy counts an app's names by the id in it. One host per ref.", + "example": "fjord:4cc0fb41d2a0:radarr" }, "ownOrigin": { "type": "boolean", @@ -1091,6 +1091,10 @@ "type": "string", "format": "date-time" }, + "by": { + "type": "string", + "description": "Id of the key that made the host; absent when it was made by hand. Set by the proxy." + }, "deletedAt": { "type": "string", "format": "date-time", diff --git a/admin/internal/server/pair.go b/admin/internal/server/pair.go index f7d7ec0..56f77bc 100644 --- a/admin/internal/server/pair.go +++ b/admin/internal/server/pair.go @@ -11,12 +11,12 @@ import ( "github.com/daemonless/reverse-proxy/admin/internal/state" ) -// pairCodeLife is how long a code from "Connect a fjord…" stays good. +// pairCodeLife is how long a code from "Link an app…" stays good. const pairCodeLife = 10 * time.Minute // newPairCode is POST /api/v1/pair/code: a one-time code and the link that // carries it (https://proxy./link#code=…), for the person to paste -// into the other fjord. Ten minutes, once. +// into the app that links. Ten minutes, once. func (s *Server) newPairCode(w http.ResponseWriter, r *http.Request) { code := auth.NewSetupToken() now := time.Now() @@ -32,7 +32,7 @@ func (s *Server) newPairCode(w http.ResponseWriter, r *http.Request) { s.pairCodes[code] = now.Add(pairCodeLife) s.mu.Unlock() // The link names this proxy the way the admin reached it (an address, - // or the name): the other fjord may not resolve proxy. yet, and + // or the name): the app may not resolve proxy. yet, and // an address it can always reach. The proxy's own name comes back when // the code is redeemed, for the pin. writeJSON(w, http.StatusCreated, map[string]any{ @@ -42,8 +42,8 @@ func (s *Server) newPairCode(w http.ResponseWriter, r *http.Request) { }) } -// pair is POST /api/v1/pair {code, client:{id,name,url}}: the other fjord -// redeems a code for a key of its own, and learns what it needs to trust +// pair is POST /api/v1/pair {code, client:{id,name,url}}: an app (fjord, +// Sylve, anything with the code) redeems a code for a key of its own, and learns what it needs to trust // this proxy. The code proves the person had the admin page open a moment // ago; a wrong one counts against the caller like a wrong password. func (s *Server) pair(w http.ResponseWriter, r *http.Request) { @@ -77,7 +77,7 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) { s.limiter.Succeed(clientAddr(r)) name := strings.TrimSpace(in.Client.Name) if name == "" || len(name) > 64 { - name = "fjord" + name = "Linked app" } id := newID() secret := auth.NewSecret(32) diff --git a/admin/internal/server/pairing_test.go b/admin/internal/server/pairing_test.go index 3424612..0d25e19 100644 --- a/admin/internal/server/pairing_test.go +++ b/admin/internal/server/pairing_test.go @@ -134,6 +134,28 @@ func TestNamesFromCountsByClientID(t *testing.T) { } } +// Any app's names count, not only fjord's: by its id in the ref whatever +// the ref's first part, and by the key that made the name for an app that +// never said an id. +func TestNamesFromCountsAnyApp(t *testing.T) { + st := &state.State{} + st.Tokens = []state.ApiToken{ + {ID: "sylve", Label: "Sylve on nas", ClientID: "b7e2"}, + {ID: "script", Label: "my script"}, + } + st.Hosts = []state.Host{ + {ID: "1", Name: "jellyfin", Ref: "sylve:b7e2:jail-104"}, + {ID: "2", Name: "home-vm", Ref: "sylve:b7e2:vm-7", By: "gone"}, + {ID: "3", Name: "printer", Ref: "printer", By: "script"}, + {ID: "4", Name: "nas", By: "script"}, + {ID: "5", Name: "router", By: "deleted-key"}, + } + got := namesFrom(st) + if got["sylve"] != 2 || got["script"] != 2 || len(got) != 2 { + t.Fatalf("names from each app: %v", got) + } +} + // An app tells the proxy who it is with its key; the account cannot. func TestMeUpdatesTheCaller(t *testing.T) { s, admin, _ := setUp(t) @@ -195,3 +217,24 @@ func TestStatusNamesInstallerBeforeSetup(t *testing.T) { t.Fatalf("after setup: %v", out["installedBy"]) } } + +// A name made with a key counts for that app under Connected apps, whatever +// its ref; one made by hand counts for nobody. +func TestHostRemembersTheKeyThatMadeIt(t *testing.T) { + s, admin, key := setUp(t) + api := &client{t: t, s: s, bearer: key} + if rr, out := api.do("POST", "/api/v1/hosts", `{"name":"jellyfin","target":{"address":"10.0.0.5","port":8096},"ref":"sylve:b7e2:jail-104"}`); rr.Code != 201 { + t.Fatalf("create with a key: %d %v", rr.Code, out) + } + if rr, out := admin.do("POST", "/api/v1/hosts", `{"name":"router","target":{"address":"10.0.0.1","port":443}}`); rr.Code != 201 { + t.Fatalf("create by hand: %d %v", rr.Code, out) + } + _, out := admin.do("GET", "/api/v1/tokens", "") + toks, _ := out["tokens"].([]any) + if len(toks) != 1 { + t.Fatalf("tokens: %v", out) + } + if n := toks[0].(map[string]any)["names"]; n != float64(1) { + t.Fatalf("names from the key: %v, want 1", n) + } +} diff --git a/admin/internal/server/server.go b/admin/internal/server/server.go index 03e5375..1b7f7d2 100644 --- a/admin/internal/server/server.go +++ b/admin/internal/server/server.go @@ -76,8 +76,8 @@ type Server struct { tokenAt time.Time // when tokenBad was found setupTok string // one-time token from the log; empty once setup is done touched map[string]time.Time // key id -> last time its use was written - // pairCodes are one-time codes (Connected apps, "Connect a fjord…") - // with their expiry: a fjord elsewhere redeems one for a key. + // pairCodes are one-time codes (Connected apps, "Link an app…") with + // their expiry: an app elsewhere redeems one for a key. pairCodes map[string]time.Time } @@ -93,8 +93,8 @@ func New(version string, caddy Runner, mgr *app.Manager, setupToken, secrets str s.mux.HandleFunc("POST /api/v1/setup/token", s.setupToken) s.mux.HandleFunc("POST /api/v1/setup/password", s.setupPassword) s.mux.HandleFunc("POST /api/v1/login", s.login) - // Linking a fjord elsewhere: the admin mints a code, the fjord redeems - // it for a key. Public like login, and limited like it. + // Linking an app: the admin mints a code, the app redeems it for a + // key. Public like login, and limited like it. s.mux.HandleFunc("POST /api/v1/pair/code", s.requireAdmin(s.newPairCode)) s.mux.HandleFunc("POST /api/v1/pair", s.pair) s.mux.HandleFunc("PUT /api/v1/me", s.requireAuth(s.me)) diff --git a/admin/internal/state/model.go b/admin/internal/state/model.go index 32d4122..d20efcd 100644 --- a/admin/internal/state/model.go +++ b/admin/internal/state/model.go @@ -108,9 +108,14 @@ type Host struct { ExtraNames []string `json:"extraNames,omitempty"` // more labels for the same service Target Target `json:"target"` Access Access `json:"access"` - // Ref says where a host came from ("fjord::"), so a - // source can show "already added". Empty for hand-made hosts. + // Ref says where a host came from ("::", fjord's + // "fjord::"), so the app that made it finds it again. Empty + // for hand-made hosts. Ref string `json:"ref,omitempty"` + // By is the id of the key that made the host, so Connected apps can + // count an app's names whatever its refs look like. Empty when made by + // hand. + By string `json:"by,omitempty"` // TrustedNetworks overrides Settings.TrustedNetworks for this host when set. TrustedNetworks []string `json:"trustedNetworks,omitempty"` // OwnOrigin: the app expects requests from its own address (UniFi diff --git a/admin/ui/src/App.svelte b/admin/ui/src/App.svelte index da8f339..7b0f39e 100644 --- a/admin/ui/src/App.svelte +++ b/admin/ui/src/App.svelte @@ -178,10 +178,10 @@ {:else if screen === 'link'} + into the app: say where it goes. -->
-

This link is for fjord

-

Paste it into the other fjord under Settings, Names, in the Reverse proxy box. It lets that fjord give its apps names here. It works for ten minutes, once.

+

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/lib/api.ts b/admin/ui/src/lib/api.ts index a9efa1a..0de6030 100644 --- a/admin/ui/src/lib/api.ts +++ b/admin/ui/src/lib/api.ts @@ -61,6 +61,7 @@ export type Host = { 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 diff --git a/admin/ui/src/lib/paired.svelte.ts b/admin/ui/src/lib/paired.svelte.ts index fb20fc0..e06565f 100644 --- a/admin/ui/src/lib/paired.svelte.ts +++ b/admin/ui/src/lib/paired.svelte.ts @@ -10,12 +10,17 @@ let loaded = $state(false) export const paired = { get value() { return all.find((t) => t.paired) ?? null }, get apps() { return all }, - /** The app behind a host's ref ("fjord::"), by its id. A fjord - * ref no id claims is the installer's (made before ids existed). */ - appFor(ref: string): string { + /** 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 = all.find((t) => t.clientId && t.clientId === id) + 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 }, diff --git a/admin/ui/src/screens/Hosts.svelte b/admin/ui/src/screens/Hosts.svelte index 9f829ac..e1057c9 100644 --- a/admin/ui/src/screens/Hosts.svelte +++ b/admin/ui/src/screens/Hosts.svelte @@ -212,10 +212,9 @@ open to the internet. Home-network-only is the default and goes without saying. --> {#snippet chips(h: Host)} - {#if h.ref} - - From {paired.appFor(h.ref)} + {#if paired.appFor(h)} + + From {paired.appFor(h)} {/if} {#if h.access === 'internet'} Open to the internet diff --git a/admin/ui/src/screens/Settings.svelte b/admin/ui/src/screens/Settings.svelte index 21452de..f0dd964 100644 --- a/admin/ui/src/screens/Settings.svelte +++ b/admin/ui/src/screens/Settings.svelte @@ -21,16 +21,16 @@ let netError = $state('') let current = $state(''); let next = $state(''); let nextAgain = $state(''); let weakOk = $state(''); let pwError = $state(''); let pwBusy = $state(false); let editPassword = $state(false) let appLabel = $state(''); let freshToken = $state(''); let freshLabel = $state(''); let appError = $state('') - // Linking a fjord elsewhere: one link to paste there, good for ten - // minutes, once. The fjord redeems it for a key of its own. + // Linking an app: one link to paste there, good for ten minutes, once. + // The app redeems it for a key of its own. let pairLink = $state(''); let pairBusy = $state(false) - async function connectFjord() { + async function linkApp() { pairBusy = true; appError = ''; freshToken = '' try { pairLink = (await post<{ link: string }>('/api/v1/pair/code', {})).link } catch (err) { appError = err instanceof ApiError ? err.message : 'Something went wrong' } finally { pairBusy = false } } - async function copyPairLink() { toast((await copyText(pairLink)) ? 'Copied. Paste it into fjord, Settings, Names.' : 'Select the link and copy it by hand') } + async function copyPairLink() { toast((await copyText(pairLink)) ? 'Copied. Paste it into the app you are linking.' : 'Select the link and copy it by hand') } let aboutDialog = $state(null) let trust = $state(null) function loadTrust() { get('/api/v1/trust').then((t) => { trust = t }).catch(() => {}) } @@ -192,12 +192,12 @@ tokens = (await get<{ tokens: Token[] }>('/api/v1/tokens')).tokens } catch (err) { appError = err instanceof ApiError ? err.message : 'Something went wrong' } } - // The app that installed this proxy (fjord) pairs with it up front; it is + // The app that installed this proxy (fjord, say) pairs with it up front; it is // shown as a connected service, not as one more key. let paired = $derived(tokens.find((t) => t.paired)) let keys = $derived(tokens.filter((t) => !t.paired)) // How many names each app made comes with the token list (the proxy - // counts by the app's id in the refs). + // counts by the app's id in the refs, else by the key that made them). const namesFrom = (t: Token) => t.names ?? 0 let fromPaired = $derived(paired ? namesFrom(paired) : 0) const came = (n: number) => (n === 0 ? 'no names from it yet' : n === 1 ? '1 name comes from it' : `${n} names come from it`) @@ -436,10 +436,10 @@

Connected apps

- -
An app with a key adds, changes and removes names here, and nothing else. fjord connects by itself when it installs this proxy. API reference
+ +
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} @@ -464,11 +464,12 @@
  • + then only made. An app linked by code has an id and an + address; a plain key has neither, but its names still + count. --> {#if t.lastUsedAt} {t.label}Connected - {#if t.clientId}Linked by code; {came(namesFrom(t))}.{:else}By key, last used {new Date(t.lastUsedAt).toLocaleDateString()}.{/if}{#if t.url} · Open {t.label}{/if} + {#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} @@ -490,8 +491,8 @@ {/if} {#if pairLink}
    -
    For another fjord, on another machine.
    -
    Open that fjord's Settings, Names tab, paste this link into its Reverse proxy box and press Link. Works once, within ten minutes.
    +
    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}
    @@ -500,8 +501,8 @@
    {/if}
    - - Another fjord, on another machine, so its apps get names here too. For a script or anything else, make a key: + + 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:
    diff --git a/compose.yaml b/compose.yaml index 3ee8772..3b13e5a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -32,7 +32,7 @@ x-daemonless: - **Short links.** Type `go/radarr` in any browser on your network. `go/` alone lists every name. - **Walks you through your router.** Setup says exactly which record to add, with steps for OPNsense, pfSense, UniFi, Pi-hole, AdGuard Home and others, and turns green when it works. - **Says what is wrong, in plain words.** A certificate that fails says why (token rejected, record never appeared, rate limit) and how to fix it. A name nobody set up shows a page saying so, at home. - - **Works with [fjord](https://github.com/daemonless/fjord).** Installed from fjord, it pairs by itself: fjord lists your apps that have no name yet, names them in one click, and keeps each name pointing at its app when the app moves to a new address. + - **Works with [fjord](https://github.com/daemonless/fjord), and with your app.** Installed from fjord, it pairs by itself: fjord lists your apps that have no name yet, names them in one click, and keeps each name pointing at its app when the app moves to a new address. Any other app can link the same way, through the same API. - **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. @@ -111,6 +111,18 @@ x-daemonless: lists the apps that have no name yet and names the ones you pick, and keeps each name pointing at its app when the app's address changes. + ### Your own app + + An app that manages services, like a jail or VM manager, can do what + fjord does. It gets a key one of three ways: the person presses "Link an + app…" under Settings → Connected apps and pastes the link into the app, + which redeems it at `POST /api/v1/pair`; the app installs the proxy with + `RP_PAIRING_KEY` set; or the person makes a key by hand. Then it says who + it is (`PUT /api/v1/me`), gives each service a name with a `ref` like + `::` so a retry never makes two, and moves the name + when the service's address changes. Connected apps shows each app and how + many names came from it. The steps are at the top of the API reference. + ### Scripts and other apps Anything that can make an HTTPS request can manage names. Make a key