Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
`<app>:<its id>:<service>` 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
Expand Down
42 changes: 21 additions & 21 deletions admin/internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<its id>:<stack>; the installer also gets every
// fjord ref no id claims (fjord:<host>:<stack>, made before ids existed).
// namesFrom counts the live hosts each connected app made, by token id: by
// the app's id in the ref (<app>:<id>:<thing>), which outlives a relink to
// a new key; else by the key that made it; else, for a fjord ref from
// before ids (fjord:<host>:<stack>), 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:<host>:<stack>, 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]++
Expand Down
3 changes: 2 additions & 1 deletion admin/internal/server/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 == "" {
Expand Down
12 changes: 8 additions & 4 deletions admin/internal/server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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://<proxy>:8443, with `Authorization: Bearer <key>`. 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://<proxy>:8443, with `Authorization: Bearer <key>`. 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 `<app>:<your id>:<thing>` (for example `sylve:<id>: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"
Expand Down Expand Up @@ -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": [
{}
],
Expand Down Expand Up @@ -1041,8 +1041,8 @@
},
"ref": {
"type": "string",
"description": "Opaque mark of where the host came from, e.g. `fjord:<host>:<stack>`, 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: `<app>:<the app's id, as sent to /me>:<thing>`. The proxy counts an app's names by the id in it. One host per ref.",
"example": "fjord:4cc0fb41d2a0:radarr"
},
"ownOrigin": {
"type": "boolean",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions admin/internal/server/pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<ending>/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()
Expand All @@ -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.<ending> yet, and
// or the name): the app may not resolve proxy.<ending> 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{
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions admin/internal/server/pairing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
8 changes: 4 additions & 4 deletions admin/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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))
Expand Down
9 changes: 7 additions & 2 deletions admin/internal/state/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<host>:<stack>"), so a
// source can show "already added". Empty for hand-made hosts.
// Ref says where a host came from ("<app>:<app's id>:<thing>", fjord's
// "fjord:<id>:<stack>"), 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
Expand Down
6 changes: 3 additions & 3 deletions admin/ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,10 @@
<Trust />
{:else if screen === 'link'}
<!-- Someone opened the pairing link in a browser instead of pasting it
into fjord: say where it goes. -->
into the app: say where it goes. -->
<div class="max-w-[640px] mx-auto box-border px-4 py-12 flex flex-col gap-4">
<h1 class="m-0 text-2xl font-bold text-fg leading-tight">This link is for fjord</h1>
<p class="m-0 text-sm leading-snug">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.</p>
<h1 class="m-0 text-2xl font-bold text-fg leading-tight">This link is for an app</h1>
<p class="m-0 text-sm leading-snug">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.</p>
<code class="font-mono break-all select-all text-sm bg-inset text-fg rounded p-3">{location.href}</code>
</div>
{/if}
Expand Down
1 change: 1 addition & 0 deletions admin/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 // "<hash>.<kind>": the app that made the name sent one
unwatched?: boolean // not checked for uptime, no alerts
Expand Down
13 changes: 9 additions & 4 deletions admin/ui/src/lib/paired.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>:<stack>"), 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
* ("<app>:<id>:<thing>"), 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
},
Expand Down
Loading
Loading