rework
build / build-push (push) Successful in 1m8s

This commit is contained in:
mats committed 2026-10-16 11:13:13 +02:00
1 parent 7fa48bcdf9
commit a3d1614dca
15 files changed
+704 -123

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# fancy-dns — development status and continuation plan
## Console authentication update
OIDC login now starts automatically, with server-owned state and PKCE. Go
exchanges the authorization code, verifies the access token, and stores it in
an in-memory session addressed by a Secure, HttpOnly, SameSite=Lax cookie.
Cookie mutations require an exact configured console Origin. Sessions reverify
the access token and cannot outlive its signed expiry. Logout revokes the local
session and offers an explicit sign-in button rather than immediately logging
back in. API keys and bearer tokens remain available for machine clients.
`api.auth.oidc_redirect_uri` names the registered HTTPS callback; when omitted,
an HTTPS audience supplies the origin with `/auth/oidc/callback`. The current
AuthClient already registers that callback, so provider CORS changes are no
longer needed. Pending logins and sessions are process-local; a restart requires
sign-in, and multiple replicas need sticky routing (as existing basic sessions do).
No refresh token is requested; expiry starts another provider sign-in using SSO.
Console cleanup removes the unused bootstrap refresh editor, hides Save when
configuration writes are unavailable, removes script instructions from login,
and fixes the shell's identity/read-only display by moving it inside its provider.
Validation: `make check` passes, including race tests; frontend lint and production
build pass with warnings. These changes have not been deployed.
Last updated after the round that finished the **server shutdown paths**:
every transport's ordering with a query in flight is now a test, and it found
two real bugs — an idle client could hold a shutdown for the whole grace
+2
View File
@@ -0,0 +1,2 @@
# Replace with a unique random secret before running. Keep the real .env private.
FANCYDNS_API_KEY=replace-with-a-long-random-value
+86
View File
@@ -0,0 +1,86 @@
# Run fancy-dns outside Kubernetes
This Compose deployment follows `deploy/k8s/00-config.yaml` and
`deploy/k8s/10-deployment.yaml`. It builds the image from this checkout instead
of pulling `registry.k6n.net/fancydns`, avoiding a bootstrap dependency on the
cluster registry DNS.
## Prepare and start
From the repository root:
```sh
mkdir -p deploy/compose/{data,zones,rpz,tls}
cp deploy/compose/.env.example deploy/compose/.env
```
Edit `deploy/compose/.env` (keep it private) and set a random API key as
`FANCYDNS_API_KEY=<long-random-value>`. Install the certificate and private key
for `dns.k6n.net` as `deploy/compose/tls/fullchain.pem` and
`deploy/compose/tls/privkey.pem`. Add the same zone files used by the cluster to
`deploy/compose/zones/`. Keep private key and `.env` out of source control.
The container runs as UID/GID 65532 with a read-only root filesystem, so make
the persistent directory writable first, for example:
```sh
sudo chown -R 65532:65532 deploy/compose/data
docker compose --env-file deploy/compose/.env -f deploy/compose/compose.yaml build
docker compose --env-file deploy/compose/.env -f deploy/compose/compose.yaml run --rm fancy-dns -config /etc/fancy-dns/config.yaml -check
docker compose --env-file deploy/compose/.env -f deploy/compose/compose.yaml up -d
```
The DNS ports publish on all host interfaces: UDP/TCP 53, TCP 853, and TCP 443.
The admin API is published only on `127.0.0.1:8080`; the embedded UI is disabled
by default for backup-resolver use. To enable it, set `api.ui: true` in
`config.yaml` and restart. Use an authenticated TLS reverse proxy for remote
access. Check that nothing already owns host port
53 before starting. The sample mounts TLS files and enables DoT and DoH, so the
files must exist before startup.
## How configuration is shared
The repository is the source of truth, but there is currently **no automatic
config distribution** between Kubernetes and the bare-metal Compose host.
Kubernetes GitOps reconciles `deploy/k8s` from `main` (see `gitops/README.md`);
the bare-metal host must get the updated checkout and have Compose restarted.
Kubernetes gets its config from the ConfigMap in `deploy/k8s/00-config.yaml`;
Compose reads the checked-in `deploy/compose/config.yaml` and files under
`deploy/compose/`. Treat these as two deployment configs: commit reviewed
changes to both when a shared resolver setting changes. The Compose config now
includes the same `local.` forward zone to `10.10.0.1:53` as Kubernetes.
The shared inputs to keep aligned are upstreams/forward zones, resolver policy,
cache sizing/TTL, blocklist sources and actions, ACLs, local zone files, RPZ
rules, and the DoT/DoH certificate for the same public DNS name. Deployment
specific settings intentionally differ: listener container ports/storage paths
must match each deployment, and Compose uses API-key auth with the UI disabled
while Kubernetes uses OIDC. On bare metal the YAML, zone and RPZ files are
bind-mounted; Kubernetes stores config, zones and RPZ in ConfigMaps and TLS in
a Secret. Secrets should be provisioned separately on each system, not copied
into Git. The Compose API key is in its private `.env` file.
Do not share the `data/` directory or copy its bbolt files while either server
is running. Cache snapshots and query logs are instance-local state. The
managed zones written by the console also live under this directory; edits
made on one instance do not appear on the other automatically. Make operator
zone changes in the source of truth and deploy them to both, or designate one
instance as the writer. Valkey clustering syncs cluster events/list leadership,
not these files or bbolt state.
The private forwarding target must remain reachable from both the Kubernetes
pods and the bare-metal host. Verify that the bare-metal resolver has outbound
access to its configured public upstreams before making it the cluster's
registry resolver.
For registry recovery, configure the Kubernetes nodes' OS resolver and the
container runtime's registry DNS path to use this host's **static IP directly**
as a DNS server (with another independent resolver as fallback). Do not rely on
a hostname for the backup resolver, and do not make pulling this Compose image
depend on `registry.k6n.net`: this Compose file builds the image locally from
the repository checkout. A working route from the cluster nodes to the
bare-metal host on UDP/TCP 53 is required; allow it through the host firewall.
To update the local build after code changes, rebuild and recreate the
container. The running server can hot-reload its YAML on SIGHUP, but Compose
does not automatically send SIGHUP when bind-mounted files change.
+39
View File
@@ -0,0 +1,39 @@
services:
fancy-dns:
# Build here so initial startup does not depend on the cluster registry
# whose name this resolver may be needed to resolve.
build:
context: ../..
dockerfile: Dockerfile
image: fancy-dns:local
container_name: fancy-dns
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
user: "65532:65532"
environment:
FANCYDNS_API_KEY: ${FANCYDNS_API_KEY:?Set FANCYDNS_API_KEY in deploy/compose/.env}
ports:
- "53:1053/udp"
- "53:1053/tcp"
- "853:1853/tcp"
- "443:8443/tcp"
# Keep the admin UI/API on the host loopback. Put it behind an
# authenticated TLS reverse proxy if remote access is needed.
- "127.0.0.1:8080:8080/tcp"
volumes:
- ./config.yaml:/etc/fancy-dns/config.yaml:ro
- ./zones:/etc/fancy-dns/zones:ro
- ./rpz:/etc/fancy-dns/rpz:ro
- ./tls:/etc/fancy-dns/tls:ro
- ./data:/var/lib/fancy-dns
tmpfs:
- /tmp:mode=1777,size=16m
healthcheck:
test: ["CMD", "/usr/local/bin/fancy-dns", "-version"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
+114
View File
@@ -0,0 +1,114 @@
# Bare-metal equivalent of deploy/k8s/00-config.yaml.
# Keep this file, zones/, rpz/, and TLS material in sync with the cluster's
# ConfigMaps/Secret as appropriate. The persistent state in ./data is local
# to this host and must not be copied/shared while the server is running.
server:
max_concurrent: 2048
timeouts:
upstream: 2s
list_fetch: 60s
dns:
# Container ports stay unprivileged; Compose publishes the conventional
# host ports 53, 853 and 443.
udp: ["0.0.0.0:1053"]
tcp: ["0.0.0.0:1053"]
max_udp_size: 1232
dot:
enabled: true
listen: ["0.0.0.0:1853"]
cert_file: /etc/fancy-dns/tls/fullchain.pem
key_file: /etc/fancy-dns/tls/privkey.pem
doh:
enabled: true
listen: ["0.0.0.0:8443"]
cert_file: /etc/fancy-dns/tls/fullchain.pem
key_file: /etc/fancy-dns/tls/privkey.pem
base_path: /dns-query
upstream:
forward_zones:
- zone: local
servers:
- addr: 10.10.0.1:53
proto: udp
policy: parallel
max_attempts: 3
healthcheck_interval: 30s
servers:
- addr: 9.9.9.9:53
proto: udp
- addr: 1.1.1.1:853
proto: tls
server_name: cloudflare-dns.com
- addr: 9.9.9.9:853
proto: tls
server_name: dns.quad9.net
memory:
limit: 1GiB
reserve: 256MiB
cache_max_bytes: 384MiB
cache:
enabled: true
size: 500000
max_ttl: 1h
serve_stale: 30m
persist:
enabled: true
path: /var/lib/fancy-dns/cache.db
interval: 30s
blocklist:
enabled: true
default_action: nxdomain
managed_zones: /var/lib/fancy-dns/managed-zones
local_zones: ["/etc/fancy-dns/zones/*.zone"]
lists:
- name: edge-router-x-address-overrides
format: rpz
path: /etc/fancy-dns/rpz/address-overrides.rpz
enabled: true
refresh: 0s
- name: haas
format: plain
url: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
path: /var/lib/fancy-dns/lists/hosts
enabled: true
refresh: 24h
categories: [ads, tracking]
acl:
default_policy: allow
groups: []
qlog:
enabled: true
path: /var/lib/fancy-dns/queries.db
max_entries: 200000
sample_rate: 1.0
log_blocked: true
log_cached: false
api:
enabled: true
listen: ["0.0.0.0:8080"]
base_path: /api
# Backup resolver mode keeps the console disabled. Set true when an operator
# wants the embedded console; it remains reachable only via host loopback.
ui: false
allow_config_write: false
auth:
mode: apikey
api_keys: ["${FANCYDNS_API_KEY}"]
trust_loopback: true
metrics:
enabled: true
path: /metrics
protect: true
log:
level: info
format: json
+6
View File
@@ -0,0 +1,6 @@
$TTL 60
tornberg.me A 10.10.3.10
knatofs.se A 10.10.3.10
k6n.net A 10.10.3.10
slask.local A 10.10.3.10
iamu.dev A 10.10.3.10
+5 -2
View File
@@ -207,10 +207,13 @@ api:
read_only_api_keys: []
trust_loopback: true
session_ttl: 12h
# For mode: oidc, configure a registered public PKCE client and the API
# audience included in its access tokens. Users need the admin role to write.
# For mode: oidc, register a public PKCE client with this callback.
# Go exchanges codes and keeps tokens in an HttpOnly cookie session;
# no token-endpoint CORS setting is needed. The audience must be included
# in access tokens. Users need the admin role to write.
oidc_issuer: https://users.k6n.net
oidc_audience: https://fancy-dns.example.net
oidc_redirect_uri: https://fancy-dns.example.net/auth/oidc/callback
oidc_client_id: fancy-dns
oidc_write_roles: [admin]
rate_limit:
+1
View File
@@ -119,6 +119,7 @@ data:
api_keys: ["${FANCYDNS_API_KEY}"]
oidc_issuer: https://users.k6n.net
oidc_audience: https://dns.k6n.net
oidc_redirect_uri: https://dns.k6n.net/auth/oidc/callback
oidc_client_id: fancy-dns
oidc_write_roles: [admin]
trust_loopback: false
+11 -2
View File
@@ -151,8 +151,9 @@ type Server struct {
}
type session struct {
principal Principal
expires time.Time
principal Principal
expires time.Time
accessToken string
}
type limitState struct {
@@ -220,6 +221,8 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
})))
}
mux.Handle("POST "+base+"auth/login", s.limited(s.handleLogin))
mux.Handle("GET "+base+"auth/oidc/start", s.limited(s.handleOIDCStart))
mux.Handle("POST "+base+"auth/oidc/callback", s.limited(s.handleOIDCCallback))
mux.Handle("GET "+base+"auth/config", s.public(s.handleAuthConfig))
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
@@ -390,6 +393,12 @@ func (s *Server) principal(r *http.Request) (Principal, error) {
}
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
}
if cookie, err := r.Cookie(browserSessionCookie); err == nil {
if !s.cookieRequestAllowed(r) {
return Principal{}, errors.New("cross-origin session request")
}
return s.auth.session(cookie.Value)
}
if s.cfgTrustLoopback(r) {
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
}
+28 -18
View File
@@ -41,28 +41,31 @@ type authenticator struct {
// sessionTTL is how long an issued token stays valid.
sessionTTL time.Duration
// trustLoopback is informational; the caller checks it.
mu sync.RWMutex
sessions map[string]session
oidcIssuer string
oidcAudience string
oidcClientID string
oidcWriteRoles map[string]bool
oidcInitMu sync.Mutex
oidcVerifier *oidc.IDTokenVerifier
mu sync.RWMutex
sessions map[string]session
oidcPending map[string]oidcLogin
oidcRedirectURI string
oidcIssuer string
oidcAudience string
oidcClientID string
oidcWriteRoles map[string]bool
oidcInitMu sync.Mutex
oidcVerifier *oidc.IDTokenVerifier
}
func newAuthenticator(cfg config.Auth) *authenticator {
a := &authenticator{
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
oidcIssuer: cfg.OIDCIssuer,
oidcAudience: cfg.OIDCAudience,
oidcClientID: cfg.OIDCClientID,
oidcWriteRoles: make(map[string]bool),
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
oidcRedirectURI: cfg.OIDCRedirectURI,
oidcIssuer: cfg.OIDCIssuer,
oidcAudience: cfg.OIDCAudience,
oidcClientID: cfg.OIDCClientID,
oidcWriteRoles: make(map[string]bool),
}
for _, role := range cfg.OIDCWriteRoles {
a.oidcWriteRoles[role] = true
@@ -237,6 +240,9 @@ func (a *authenticator) session(token string) (Principal, error) {
a.mu.Unlock()
return Principal{}, errors.New("session expired")
}
if s.accessToken != "" {
return a.oidcPrincipal(s.accessToken)
}
return s.principal, nil
}
@@ -330,6 +336,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// handleLogout drops the caller's session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(browserSessionCookie); err == nil {
s.auth.revoke(c.Value)
}
s.setBrowserCookie(w, browserSessionCookie, "", -1)
if h := r.Header.Get("Authorization"); h != "" {
if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" {
s.auth.revoke(value)
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
const browserSessionCookie = "__Host-fancydns-session"
const loginCookie = "__Host-fancydns-login"
type oidcLogin struct {
verifier string
next string
expires time.Time
}
// The registered callback is configuration, never an untrusted Host header.
func (s *Server) browserRedirect() (*url.URL, error) {
raw := s.auth.oidcRedirectURI
if raw == "" {
u, err := url.Parse(s.auth.oidcAudience)
if err != nil || u.Scheme != "https" || u.Host == "" {
return nil, errors.New("configure api.auth.oidc_redirect_uri with the HTTPS console callback URL")
}
raw = u.Scheme + "://" + u.Host + "/auth/oidc/callback"
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("OIDC callback must be an absolute HTTPS URL without query or fragment")
}
return u, nil
}
func (s *Server) cookieRequestAllowed(r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return true
}
u, err := s.browserRedirect()
return err == nil && r.Header.Get("Origin") == u.Scheme+"://"+u.Host
}
func (s *Server) setBrowserCookie(w http.ResponseWriter, name, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
}
func (s *Server) browserOAuth(ctx context.Context) (*oauth2.Config, error) {
if s.auth.mode != "oidc" {
return nil, errors.New("OIDC login is not enabled")
}
redirect, err := s.browserRedirect()
if err != nil {
return nil, err
}
provider, err := oidc.NewProvider(ctx, s.auth.oidcIssuer)
if err != nil {
return nil, err
}
endpoint := provider.Endpoint()
// Discovery is trusted only over TLS; never send codes to a downgraded endpoint.
for _, raw := range []string{endpoint.AuthURL, endpoint.TokenURL} {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
return nil, errors.New("OIDC endpoints must use HTTPS")
}
}
endpoint.AuthStyle = oauth2.AuthStyleInParams
return &oauth2.Config{ClientID: s.auth.oidcClientID, RedirectURL: redirect.String(), Endpoint: endpoint, Scopes: []string{"openid", "profile", "email"}}, nil
}
func safeLoginNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") || strings.HasPrefix(next, "/auth/") {
return "/"
}
return next
}
func (s *Server) handleOIDCStart(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
cfg, err := s.browserOAuth(ctx)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "could not start identity provider login")
return
}
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
writeError(w, 500, "could not create login state")
return
}
state := base64.RawURLEncoding.EncodeToString(stateBytes)
verifier := oauth2.GenerateVerifier()
now := time.Now()
s.auth.mu.Lock()
if s.auth.oidcPending == nil {
s.auth.oidcPending = make(map[string]oidcLogin)
}
for key, login := range s.auth.oidcPending {
if now.After(login.expires) {
delete(s.auth.oidcPending, key)
}
}
if len(s.auth.oidcPending) >= 1024 {
s.auth.mu.Unlock()
writeError(w, 429, "too many pending logins")
return
}
s.auth.oidcPending[state] = oidcLogin{verifier: verifier, next: safeLoginNext(r.URL.Query().Get("next")), expires: now.Add(5 * time.Minute)}
s.auth.mu.Unlock()
s.setBrowserCookie(w, loginCookie, state, 300)
http.Redirect(w, r, cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound)
}
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !s.cookieRequestAllowed(r) {
writeError(w, 403, "invalid login origin")
return
}
var in struct {
Code string `json:"code"`
State string `json:"state"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&in); err != nil || in.Code == "" || in.State == "" {
writeError(w, 400, "code and state are required")
return
}
cookie, err := r.Cookie(loginCookie)
if err != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(in.State)) != 1 {
writeError(w, 400, "login state check failed; start sign in again")
return
}
s.auth.mu.Lock()
login, ok := s.auth.oidcPending[in.State]
delete(s.auth.oidcPending, in.State)
s.auth.mu.Unlock()
s.setBrowserCookie(w, loginCookie, "", -1)
if !ok || time.Now().After(login.expires) {
writeError(w, 400, "login expired; start sign in again")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
cfg, err := s.browserOAuth(ctx)
if err != nil {
writeError(w, 502, "could not discover identity provider")
return
}
token, err := cfg.Exchange(ctx, in.Code, oauth2.VerifierOption(login.verifier))
if err != nil {
writeError(w, 401, "identity provider rejected login; start sign in again")
return
}
p, err := s.auth.oidcPrincipal(token.AccessToken)
if err != nil {
writeError(w, 401, "identity provider returned an invalid access token")
return
}
// Bound the session by both the configured local TTL and the signed JWT expiry.
parts := strings.Split(token.AccessToken, ".")
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
var claims struct {
Exp int64 `json:"exp"`
}
if err != nil || json.Unmarshal(payload, &claims) != nil || claims.Exp <= time.Now().Unix() {
writeError(w, 401, "access token has expired")
return
}
local, expires := s.auth.issue(p)
if local == "" {
writeError(w, 500, "could not create session")
return
}
if jwtExpiry := time.Unix(claims.Exp, 0); jwtExpiry.Before(expires) {
expires = jwtExpiry
}
s.auth.mu.Lock()
s.auth.sessions[local] = session{principal: p, expires: expires, accessToken: token.AccessToken}
s.auth.mu.Unlock()
s.setBrowserCookie(w, browserSessionCookie, local, int(time.Until(expires).Seconds()))
writeJSON(w, 200, map[string]string{"next": login.next})
}
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func TestBrowserOIDCSession(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
var issuer, challenge string
exchanges := 0
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/keys", "authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token"})
})
mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]string{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "test-key", "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())}}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
exchanges++
_ = r.ParseForm()
digest := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
if r.Form.Get("code") != "valid-code" || base64.RawURLEncoding.EncodeToString(digest[:]) != challenge || r.Form.Get("redirect_uri") != "https://dns.example/auth/oidc/callback" {
t.Error("incorrect code, PKCE verifier or redirect")
http.Error(w, "invalid grant", 400)
return
}
w.Header().Set("Content-Type", "application/json")
token := signedOIDCToken(t, key, map[string]any{"iss": issuer, "aud": "https://dns.example", "sub": "admin", "token_use": "access", "roles": []string{"admin"}, "exp": time.Now().Add(2 * time.Minute).Unix()})
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": token, "token_type": "Bearer", "expires_in": 120})
})
provider := httptest.NewTLSServer(mux)
defer provider.Close()
issuer = provider.URL
original := http.DefaultTransport
http.DefaultTransport = provider.Client().Transport
defer func() { http.DefaultTransport = original }()
h := newHarness(t, func(c *config.Config) {
c.API.Auth.Mode = "oidc"
c.API.Auth.OIDCIssuer = issuer
c.API.Auth.OIDCAudience = "https://dns.example"
c.API.Auth.OIDCClientID = "dns"
})
start := httptest.NewRecorder()
h.srv.Handler().ServeHTTP(start, httptest.NewRequest("GET", "https://dns.example/api/auth/oidc/start?next=%2Fzones", nil))
if start.Code != 302 {
t.Fatalf("start: %d %s", start.Code, start.Body.String())
}
target, err := url.Parse(start.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
challenge = target.Query().Get("code_challenge")
if challenge == "" || target.Query().Get("code_challenge_method") != "S256" {
t.Fatal("missing PKCE")
}
binding := start.Result().Cookies()[0]
callback := func(origin, state string, cookie *http.Cookie) *httptest.ResponseRecorder {
b, _ := json.Marshal(map[string]string{"code": "valid-code", "state": state})
r := httptest.NewRequest("POST", "https://dns.example/api/auth/oidc/callback", strings.NewReader(string(b)))
r.Header.Set("Origin", origin)
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
h.srv.Handler().ServeHTTP(w, r)
return w
}
state := target.Query().Get("state")
for _, tc := range []struct {
origin, state string
cookie *http.Cookie
}{{"https://evil.example", state, binding}, {"https://dns.example", "wrong", binding}, {"https://dns.example", state, nil}} {
if w := callback(tc.origin, tc.state, tc.cookie); w.Code == 200 {
t.Fatal("accepted unbound or cross-origin callback")
}
}
if exchanges != 0 {
t.Fatal("invalid callback reached token endpoint")
}
w := callback("https://dns.example", state, binding)
if w.Code != 200 {
t.Fatalf("callback: %d %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "access_token") || !strings.Contains(w.Body.String(), "/zones") {
t.Fatal("callback leaked token or lost destination")
}
var sessionCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == browserSessionCookie {
sessionCookie = c
}
}
if sessionCookie == nil || !sessionCookie.Secure || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteLaxMode || sessionCookie.MaxAge > 120 {
t.Fatalf("unsafe session cookie: %+v", sessionCookie)
}
if w := callback("https://dns.example", state, binding); w.Code == 200 || exchanges != 1 {
t.Fatal("authorization code replay accepted")
}
request := func(method, path, origin string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "https://dns.example"+path, nil)
r.AddCookie(sessionCookie)
if origin != "" {
r.Header.Set("Origin", origin)
}
w := httptest.NewRecorder()
h.srv.Handler().ServeHTTP(w, r)
return w
}
if w := request("GET", "/api/whoami", ""); w.Code != 200 {
t.Fatalf("session refused: %d %s", w.Code, w.Body.String())
}
if w := request("POST", "/api/cache/flush", "https://evil.example"); w.Code == 200 {
t.Fatal("cross-origin cookie mutation accepted")
}
if w := request("POST", "/api/auth/logout", "https://dns.example"); w.Code != 200 {
t.Fatal("logout failed")
}
if w := request("GET", "/api/whoami", ""); w.Code != 401 {
t.Fatal("revoked session still works")
}
}
func TestSafeLoginNext(t *testing.T) {
for _, next := range []string{"//evil.example", "/\\evil.example", "/auth/login", "/\r\nLocation: evil", "https://evil.example"} {
if safeLoginNext(next) != "/" {
t.Fatalf("unsafe redirect %q", next)
}
}
if safeLoginNext("/zones?zone=example.net") != "/zones?zone=example.net" {
t.Fatal("lost local destination")
}
}
+11 -4
View File
@@ -499,10 +499,11 @@ type Auth struct {
// Mode is "none", "apikey", "basic" or "oidc".
Mode string `yaml:"mode"`
// OIDC settings validate access tokens issued by an external provider.
OIDCIssuer string `yaml:"oidc_issuer"`
OIDCAudience string `yaml:"oidc_audience"`
OIDCClientID string `yaml:"oidc_client_id"`
OIDCWriteRoles []string `yaml:"oidc_write_roles"`
OIDCIssuer string `yaml:"oidc_issuer"`
OIDCAudience string `yaml:"oidc_audience"`
OIDCRedirectURI string `yaml:"oidc_redirect_uri"`
OIDCClientID string `yaml:"oidc_client_id"`
OIDCWriteRoles []string `yaml:"oidc_write_roles"`
// APIKeys grant full access.
APIKeys []string `yaml:"api_keys"`
// ReadOnlyAPIKeys may only call GET endpoints.
@@ -1231,6 +1232,12 @@ func (c *Config) Validate() error {
}
}
}
if raw := c.API.Auth.OIDCRedirectURI; raw != "" {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
add("api.auth.oidc_redirect_uri must be an absolute HTTPS callback URL without query or fragment")
}
}
if c.API.Auth.SessionTTL <= 0 {
add("api.auth.session_ttl must be > 0")
}
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-DnNommMg.js"></script>
<script type="module" crossorigin src="/assets/index-DAZEaLUJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bf5OWE1r.css">
</head>
<body>
+36 -96
View File
@@ -1,18 +1,11 @@
/**
* Login — the only page that renders outside AppShell.
*
* The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the
* form is built from what `/api/whoami` reports rather than hard-coded. A 401
* from that probe is expected (that is why the visitor is here) and is not
* shown as an error.
*/
/** Sign-in uses the node's public auth settings. OIDC tokens stay on the server. */
import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
import { Globe, KeyRound, LogIn, ShieldCheck } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import { ApiError, login, setToken } from '../lib/api'
import { ApiError, login, clearToken } from '../lib/api'
import { Button, Card, CardBody, Input } from '../components/ui'
import { Spinner } from '../components/ui'
@@ -31,7 +24,6 @@ export default function Login() {
const callbackStarted = useRef(false)
const [mode, setMode] = useState<string | null>(null)
const [oidc, setOIDC] = useState<{ issuer: string; client_id: string } | null>(null)
const [apiKey, setApiKey] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -48,13 +40,13 @@ export default function Login() {
.then((cfg) => {
if (!live) return
setMode(cfg.mode ?? 'apikey')
if (cfg.issuer && cfg.client_id) setOIDC({ issuer: cfg.issuer, client_id: cfg.client_id })
if (cfg.mode === 'none') window.location.replace(safeNext(next))
})
.catch(() => { if (live) setMode('apikey') })
.catch(() => { if (live) setError('Could not load sign-in settings. Reload to try again.') })
return () => {
live = false
}
}, [])
}, [next])
useEffect(() => {
const providerError = params.get('error')
@@ -63,78 +55,37 @@ export default function Login() {
return
}
const code = params.get('code')
if (!code || !oidc || mode !== 'oidc') return
if (callbackStarted.current) return
if (!code || mode !== 'oidc' || callbackStarted.current) return
callbackStarted.current = true
let pendingLogin: { state: string; verifier: string; next: string } | null
try {
pendingLogin = JSON.parse(sessionStorage.getItem('fancydns.oidc.pending') ?? 'null')
} catch { pendingLogin = null }
if (!pendingLogin?.state || pendingLogin.state !== params.get('state') || !pendingLogin.verifier) {
setError('OIDC login state check failed. Start sign in again.')
return
}
sessionStorage.removeItem('fancydns.oidc.pending')
setPending(true)
void (async () => {
try {
const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`)
if (!discoveryRes.ok) throw new Error('could not discover the identity provider')
const discovery = (await discoveryRes.json()) as { token_endpoint?: string }
if (!discovery.token_endpoint) throw new Error('identity provider has no token endpoint')
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: oidc.client_id,
code,
redirect_uri: `${window.location.origin}/auth/oidc/callback`,
code_verifier: pendingLogin.verifier,
})
const tokenRes = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const tokenBody = (await tokenRes.json()) as { access_token?: string; error_description?: string }
if (!tokenRes.ok || !tokenBody.access_token) throw new Error(tokenBody.error_description ?? 'code exchange failed')
setToken(tokenBody.access_token)
window.location.assign(safeNext(pendingLogin.next))
} catch (err) {
setError(err instanceof Error ? err.message : 'OIDC sign in failed')
} finally { setPending(false) }
})()
}, [mode, oidc, params])
const startOIDC = async (): Promise<void> => {
if (!oidc) return
setPending(true)
setError(null)
try {
const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`)
if (!discoveryRes.ok) throw new Error('could not discover the identity provider')
const discovery = (await discoveryRes.json()) as { authorization_endpoint?: string }
if (!discovery.authorization_endpoint) throw new Error('identity provider has no authorization endpoint')
const random = (bytes: number): string => {
const data = crypto.getRandomValues(new Uint8Array(bytes))
return btoa(String.fromCharCode(...data)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
const verifier = random(32)
const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(challenge))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const state = random(24)
const redirectURI = `${window.location.origin}/auth/oidc/callback`
sessionStorage.setItem('fancydns.oidc.pending', JSON.stringify({ state, verifier, next }))
const authorize = new URL(discovery.authorization_endpoint)
authorize.search = new URLSearchParams({
response_type: 'code', client_id: oidc.client_id, redirect_uri: redirectURI,
scope: 'openid profile email', state, code_challenge: codeChallenge, code_challenge_method: 'S256',
}).toString()
window.location.assign(authorize.toString())
} catch (err) {
setError(err instanceof Error ? err.message : 'could not start OIDC sign in')
void fetch('/api/auth/oidc/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state: params.get('state') }),
}).then(async (res) => {
const body = await res.json() as { next?: string; error?: string }
if (!res.ok) throw new Error(body.error ?? 'Sign in failed')
clearToken()
window.location.replace(safeNext(body.next ?? '/'))
}).catch((err: unknown) => {
window.history.replaceState(null, '', '/auth/login')
setError(err instanceof Error ? err.message : 'Sign in failed')
setPending(false)
}
})
}, [mode, params])
const startOIDC = (): void => {
setPending(true)
window.location.assign(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`)
}
useEffect(() => {
if (mode === 'oidc' && !params.has('code') && !params.has('error') && !params.has('signed_out') && !callbackStarted.current) {
callbackStarted.current = true
window.location.replace(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`)
}
}, [mode, next, params])
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (pending) return
@@ -184,12 +135,12 @@ export default function Login() {
? 'Checking the authentication mode…'
: passwordMode
? 'This node uses username and password.'
: mode === 'oidc' ? 'Sign in with your identity provider.' : 'This node uses an API key.'
: mode === 'oidc' ? 'Continue with your k6n account.' : 'This node uses an API key.'
}
>
<CardBody>
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
{mode === 'oidc' ? null : passwordMode ? (
{mode === null || mode === 'oidc' ? null : passwordMode ? (
<>
<Input
label="Username"
@@ -236,30 +187,19 @@ export default function Login() {
<Button
type="submit"
disabled={mode === null || pending}
variant="primary"
size="lg"
icon={pending ? undefined : LogIn}
loading={pending}
className="w-full justify-center"
>
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue with OIDC' : 'Sign in'}
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue to sign in' : 'Sign in'}
</Button>
</form>
</CardBody>
</Card>
<p className="mt-4 flex items-start gap-2 text-xs text-fg-subtle">
<Terminal size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Scripts can skip this page: send the same credential as an{' '}
<span className="font-mono text-fg-muted">X-API-Key</span> header, for example{' '}
<span className="font-mono text-fg-muted">
curl -H &quot;X-API-Key: $KEY&quot; http://node:8080/api/dashboard
</span>
.
</span>
</p>
{pending && (
<p className="mt-3 flex items-center justify-center gap-2 text-xs text-fg-muted">
<Spinner size="sm" />