1 parent
7fa48bcdf9
commit
a3d1614dca
15 files changed
+704
-123
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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 "X-API-Key: $KEY" 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" />
|
||||
|
||||
Reference in new issue
Block a user