Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eaba6150b | ||
|
|
7bb54e1ec8 | ||
|
|
c7750fbf0b |
@@ -1002,3 +1002,55 @@ Also fixed: the Settings *Preview what an upload would send* button did nothing.
|
|||||||
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
|
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
|
||||||
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
|
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
|
||||||
preview rather than silently ignoring the tap.
|
preview rather than silently ignoring the tap.
|
||||||
|
|
||||||
|
### Security: the admin listener was publicly exposed for ~15 minutes (2026-08-01)
|
||||||
|
Moving the admin listener to `[::2]:443` for the UI exposed `/admin/enroll-tokens` and
|
||||||
|
`/admin/selftest` to the internet **with no authentication**. Anyone who could reach
|
||||||
|
`fmr.echo-lot.app` could mint enrolment tokens.
|
||||||
|
|
||||||
|
The listener was designed localhost-only — its own flag help says *"keep localhost"* — and that
|
||||||
|
assumption travelled with it when the address changed. The compounding error: `checkAdminExposure`,
|
||||||
|
added the same day, verifies **encryption** and says nothing about **authentication**. It passed,
|
||||||
|
and a green light on an adjacent property is worse than no check, because it invites you to stop
|
||||||
|
looking.
|
||||||
|
|
||||||
|
Closed by returning to loopback (the TLS and ACME work is retained, just not exposed). All 68 device
|
||||||
|
enrolments matched the timestamps of test runs, so there is no evidence of abuse — but the window
|
||||||
|
existed on a freshly published hostname and absence cannot be proven. 39 unused enrolment tokens
|
||||||
|
were purged, since any could have been minted by someone else and they cost nothing to replace, and
|
||||||
|
63 test devices removed.
|
||||||
|
|
||||||
|
**The admin listener does not become reachable again until it authenticates.** That reorders the UI
|
||||||
|
work: auth on the listener first, everything else after.
|
||||||
|
|
||||||
|
### Open: encrypted uploads, where the operator cannot read the data
|
||||||
|
Not built. Recorded because the shape is decided by a few early choices, and the current design
|
||||||
|
happens to leave the door open.
|
||||||
|
|
||||||
|
The goal: hand someone an account, let them upload, and be unable to read what they uploaded.
|
||||||
|
|
||||||
|
Sketch: a random per-account **master key**, generated on the first device and wrapped under a
|
||||||
|
key derived from a passphrase (PBKDF2-HMAC-SHA256 — stdlib on both sides). The wrapped key is
|
||||||
|
stored server-side as an opaque blob, so a new device signs in, fetches it, and unwraps locally;
|
||||||
|
the server never sees either key. Runs are encrypted client-side with AES-256-GCM, fresh nonce per
|
||||||
|
run. All of this is stdlib in Go and `javax.crypto` in Kotlin — no dependency either side.
|
||||||
|
|
||||||
|
Four consequences that decide whether it is worth it:
|
||||||
|
|
||||||
|
1. **What stays readable determines what the UI can do.** The server builds its index by *parsing*
|
||||||
|
the document — verdict, finding count, started_at. An opaque payload means the client supplies
|
||||||
|
that metadata or the index disappears, and with it retention-by-verdict and any "runs with
|
||||||
|
findings" view. The honest version supplies only run id, timestamp and size, and moves the rest
|
||||||
|
client-side.
|
||||||
|
2. **Lose the passphrase, lose the data.** That is the feature working, and also the support
|
||||||
|
burden. It needs a recovery code printed at setup, not a reset flow — there is nothing to reset.
|
||||||
|
3. **Metadata is not hidden.** The operator still sees which account uploaded, when, how often and
|
||||||
|
how large. "Cannot see it" is about content, not existence, and saying otherwise would oversell.
|
||||||
|
4. **It makes `min_anonymization` unenforceable** — a server cannot check a level it cannot read.
|
||||||
|
That is not a conflict so much as a redundancy: the anonymization floor exists to protect the
|
||||||
|
user from the operator, and encryption does that better. The two should not both be demanded of
|
||||||
|
one upload.
|
||||||
|
|
||||||
|
What keeps this possible: uploads are already stored byte-for-byte as received, and every index
|
||||||
|
field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require*
|
||||||
|
reading content — those would have to be unbuilt later.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -39,6 +38,7 @@ import (
|
|||||||
|
|
||||||
"echo-lot.app/server/internal/acmehttp"
|
"echo-lot.app/server/internal/acmehttp"
|
||||||
"echo-lot.app/server/internal/adminauth"
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/adminui"
|
||||||
"echo-lot.app/server/internal/canarydns"
|
"echo-lot.app/server/internal/canarydns"
|
||||||
"echo-lot.app/server/internal/certreload"
|
"echo-lot.app/server/internal/certreload"
|
||||||
"echo-lot.app/server/internal/compat"
|
"echo-lot.app/server/internal/compat"
|
||||||
@@ -184,22 +184,33 @@ func serve(cfg *config.Config) error {
|
|||||||
// Identity is optional. Without an issuer the server simply has no sign-in, and
|
// Identity is optional. Without an issuer the server simply has no sign-in, and
|
||||||
// uploads=account can never be satisfied — which is the honest outcome, not a silent
|
// uploads=account can never be satisfied — which is the honest outcome, not a silent
|
||||||
// downgrade to anonymous.
|
// downgrade to anonymous.
|
||||||
var idp *oidc.Verifier
|
// One verifier per issuer. An IdP may mint a distinct issuer per application — Authentik
|
||||||
if cfg.OIDCIssuer != "" && (cfg.OIDCClientID != "" || cfg.OIDCAppClientID != "") {
|
// derives it from the application slug — and a token's `iss` must match whoever signed it.
|
||||||
|
// Each verifier accepts only the client belonging to its own issuer, so a token minted for
|
||||||
|
// the phone cannot be replayed at the admin login and vice versa.
|
||||||
|
var idp, adminIdP *oidc.Verifier
|
||||||
|
appIssuer := cfg.OIDCAppIssuer
|
||||||
|
if appIssuer == "" {
|
||||||
|
appIssuer = cfg.OIDCIssuer // IdPs with one global issuer
|
||||||
|
}
|
||||||
|
if appIssuer != "" && cfg.OIDCAppClientID != "" {
|
||||||
idp = oidc.New(oidc.Config{
|
idp = oidc.New(oidc.Config{
|
||||||
Issuer: cfg.OIDCIssuer,
|
Issuer: appIssuer, AppClientID: cfg.OIDCAppClientID, AdminGroup: cfg.OIDCAdminGroup,
|
||||||
ClientID: cfg.OIDCClientID,
|
|
||||||
AppClientID: cfg.OIDCAppClientID,
|
|
||||||
AdminGroup: cfg.OIDCAdminGroup,
|
|
||||||
}, nil)
|
}, nil)
|
||||||
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
|
slog.Info("identity: app client", "issuer", appIssuer, "client_id", cfg.OIDCAppClientID)
|
||||||
"admin_client_id", cfg.OIDCClientID, "app_client_id", cfg.OIDCAppClientID,
|
}
|
||||||
"admin_group", cfg.OIDCAdminGroup)
|
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
|
||||||
|
adminIdP = oidc.New(oidc.Config{
|
||||||
|
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, AdminGroup: cfg.OIDCAdminGroup,
|
||||||
|
}, nil)
|
||||||
|
slog.Info("identity: admin client", "issuer", cfg.OIDCIssuer,
|
||||||
|
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
|
||||||
if cfg.OIDCAdminGroup == "" {
|
if cfg.OIDCAdminGroup == "" {
|
||||||
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
|
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
|
||||||
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
|
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
|
||||||
}
|
}
|
||||||
} else if cfg.UploadsMode == string(runs.ModeAccount) {
|
}
|
||||||
|
if idp == nil && adminIdP == nil && cfg.UploadsMode == string(runs.ModeAccount) {
|
||||||
slog.Warn("uploads=account but no identity provider is configured — " +
|
slog.Warn("uploads=account but no identity provider is configured — " +
|
||||||
"every upload will be refused")
|
"every upload will be refused")
|
||||||
}
|
}
|
||||||
@@ -216,6 +227,7 @@ func serve(cfg *config.Config) error {
|
|||||||
AppRange: appRange,
|
AppRange: appRange,
|
||||||
PublicControlURL: publicControlURL(cfg),
|
PublicControlURL: publicControlURL(cfg),
|
||||||
OIDC: idp,
|
OIDC: idp,
|
||||||
|
AdminOIDC: adminIdP,
|
||||||
}
|
}
|
||||||
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
|
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
|
||||||
// reason rather than failing somewhere deeper.
|
// reason rather than failing somewhere deeper.
|
||||||
@@ -285,36 +297,34 @@ func serve(cfg *config.Config) error {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin/health (plain HTTP, localhost by default; spec §7)
|
// The admin interface. Every route except /healthz requires a session — the old arrangement
|
||||||
admin := http.NewServeMux()
|
// (no auth, kept safe by binding to loopback) failed the moment the address changed, and a
|
||||||
admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
// binding address is a deployment detail rather than an access control.
|
||||||
fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version)
|
secret, err := st.SessionSecret()
|
||||||
})
|
|
||||||
admin.HandleFunc("GET /admin/selftest", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(selftestPtr.Load())
|
|
||||||
})
|
|
||||||
// TODO(spec §7): enrollment token management + device list. Until the
|
|
||||||
// admin UI exists, mint tokens with: echolot-admin (or curl on this
|
|
||||||
// listener once the endpoint lands).
|
|
||||||
admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
return fmt.Errorf("admin session secret: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// The whole bootstrap, not just the token: this is what gets pasted or turned into a
|
adminSecure := cfg.AdminTLSCert != ""
|
||||||
// QR code, and assembling it here is what keeps an operator from transcribing a pin by
|
ui := &adminui.Server{
|
||||||
// hand — a pin wrong by one character fails as an inscrutable TLS error days later.
|
Store: st,
|
||||||
w.Header().Set("Content-Type", "application/json")
|
Runs: runStore,
|
||||||
enc := json.NewEncoder(w)
|
OIDC: adminIdP,
|
||||||
enc.SetEscapeHTML(false) // the link is full of / and =; escaping them helps nobody
|
Sessions: adminauth.NewSessions(secret, 12*time.Hour),
|
||||||
_ = enc.Encode(map[string]any{
|
Throttle: adminauth.NewThrottle(),
|
||||||
"token": tok,
|
AdminUser: cfg.AdminUser,
|
||||||
"expires_in_s": 86400,
|
BaseURL: cfg.AdminBaseURL,
|
||||||
"enroll_uri": ctl.EnrollmentLink(tok),
|
ClientSecret: cfg.OIDCClientSecret,
|
||||||
})
|
Secure: adminSecure,
|
||||||
})
|
EnrollLink: ctl.EnrollmentLink,
|
||||||
|
SelfTest: func() any { return selftestPtr.Load() },
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
if st.LocalAdmin() == nil && adminIdP == nil {
|
||||||
|
slog.Warn("nobody can sign in to the admin UI: no break-glass password is set " +
|
||||||
|
"(--set-admin-password) and no identity provider is configured")
|
||||||
|
}
|
||||||
|
admin := ui.Handler()
|
||||||
|
|
||||||
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
||||||
if cfg.AdminTLSCert != "" {
|
if cfg.AdminTLSCert != "" {
|
||||||
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
|
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package adminui serves the operator's web interface.
|
||||||
|
//
|
||||||
|
// Everything here is behind authentication, without exception. The previous arrangement — an
|
||||||
|
// unauthenticated listener kept safe by binding to loopback — worked exactly until the address
|
||||||
|
// changed, and then failed silently and publicly. Binding address is a deployment detail; it is
|
||||||
|
// not an access control, and this package does not treat it as one.
|
||||||
|
//
|
||||||
|
// Rendered server-side with html/template and no JavaScript. The pages are lists and forms; a
|
||||||
|
// framework would add a build step, a dependency tree and an update treadmill to a program that
|
||||||
|
// currently has none of those.
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/oidc"
|
||||||
|
"echo-lot.app/server/internal/runs"
|
||||||
|
"echo-lot.app/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "echolot_admin"
|
||||||
|
stateCookie = "echolot_oidc"
|
||||||
|
csrfField = "csrf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the admin interface.
|
||||||
|
type Server struct {
|
||||||
|
Store *store.Store
|
||||||
|
Runs *runs.Store
|
||||||
|
OIDC *oidc.Verifier // admin client; nil when no IdP is configured
|
||||||
|
Sessions *adminauth.Sessions
|
||||||
|
Throttle *adminauth.Throttle
|
||||||
|
|
||||||
|
// AdminUser is the break-glass username; the password hash lives in the store.
|
||||||
|
AdminUser string
|
||||||
|
// BaseURL is where this UI is reachable, for building the OIDC redirect. Must match the URI
|
||||||
|
// registered at the IdP exactly.
|
||||||
|
BaseURL string
|
||||||
|
// ClientSecret authenticates the confidential admin client at the token endpoint.
|
||||||
|
ClientSecret string
|
||||||
|
// Secure marks cookies Secure. Off only for loopback HTTP, where there is no network to
|
||||||
|
// intercept and browsers refuse Secure cookies over plaintext anyway.
|
||||||
|
Secure bool
|
||||||
|
|
||||||
|
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
|
||||||
|
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
|
||||||
|
EnrollLink func(token string) string
|
||||||
|
// SelfTest and Version render on the dashboard.
|
||||||
|
SelfTest func() any
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler builds the routes. Only /healthz is reachable without a session.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Unauthenticated: a health check that required a session would be no use to a monitor, and
|
||||||
|
// it discloses nothing beyond "the process is up".
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /login", s.loginForm)
|
||||||
|
mux.HandleFunc("POST /login", s.loginSubmit)
|
||||||
|
mux.HandleFunc("GET /auth/start", s.oidcStart)
|
||||||
|
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
|
||||||
|
mux.HandleFunc("POST /logout", s.logout)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /", s.guard(s.dashboard))
|
||||||
|
mux.HandleFunc("GET /devices", s.guard(s.devices))
|
||||||
|
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice))
|
||||||
|
mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken))
|
||||||
|
mux.HandleFunc("GET /runs", s.guard(s.runsList))
|
||||||
|
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
|
||||||
|
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard requires a valid session, and checks CSRF on anything that changes state.
|
||||||
|
func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Session)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := s.session(r)
|
||||||
|
if sess == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
// SameSite=Lax already blocks cross-site form posts in current browsers, but this
|
||||||
|
// is the control that does not depend on the browser being current.
|
||||||
|
if !s.csrfOK(r, sess) {
|
||||||
|
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h(w, r, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) session(r *http.Request) *adminauth.Session {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sess, err := s.Sessions.Parse(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfToken derives a per-session token. Derived rather than stored so it needs no server-side
|
||||||
|
// state and cannot drift out of sync with the session it belongs to.
|
||||||
|
func (s *Server) csrfToken(sess *adminauth.Session) string {
|
||||||
|
sum := sha256.Sum256([]byte("csrf|" + sess.Subject + "|" + sess.Expires.String()))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return r.PostFormValue(csrfField) == s.csrfToken(sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setSession(w http.ResponseWriter, subject, display string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: s.Sessions.Issue(subject, display),
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
|
||||||
|
Secure: s.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- local password ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The delay is applied before the answer, so a wrong guess costs time whether or not the
|
||||||
|
// username exists — the timing carries no information either way.
|
||||||
|
if d := s.Throttle.Delay(); d > 0 {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
user := r.PostFormValue("username")
|
||||||
|
pass := r.PostFormValue("password")
|
||||||
|
|
||||||
|
cred := s.Store.LocalAdmin()
|
||||||
|
if cred == nil || !cred.Verify(user, pass) {
|
||||||
|
s.Throttle.Failed()
|
||||||
|
slog.Info("admin login failed", "user", user, "from", clientIP(r))
|
||||||
|
s.render(w, r, "login", map[string]any{
|
||||||
|
"Error": "Incorrect username or password.",
|
||||||
|
"OIDC": s.oidcAvailable(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Throttle.Succeeded()
|
||||||
|
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
|
||||||
|
s.setSession(w, "local:"+cred.Username, cred.Username)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OIDC -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) oidcAvailable() bool {
|
||||||
|
return s.OIDC != nil && s.OIDC.Config().Enabled() && s.BaseURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcStart redirects to the IdP with state and PKCE.
|
||||||
|
//
|
||||||
|
// PKCE even though this is a confidential client: it costs one hash and closes code interception
|
||||||
|
// independently of the secret, which is worth having when the redirect crosses a browser.
|
||||||
|
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.oidcAvailable() {
|
||||||
|
http.Error(w, "no identity provider is configured on this server", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := s.OIDC.Discover(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "identity provider unreachable: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, verifier := randomToken(), randomToken()
|
||||||
|
challenge := sha256.Sum256([]byte(verifier))
|
||||||
|
|
||||||
|
// state and the PKCE verifier ride in one short-lived cookie: the callback must prove it
|
||||||
|
// belongs to the browser that started the flow, or an attacker can feed us their own code.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookie, Value: state + "." + verifier, Path: "/",
|
||||||
|
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
||||||
|
})
|
||||||
|
|
||||||
|
q := url.Values{
|
||||||
|
"response_type": {"code"},
|
||||||
|
"client_id": {s.OIDC.Config().ClientID},
|
||||||
|
"redirect_uri": {s.redirectURI()},
|
||||||
|
"scope": {"openid profile email"},
|
||||||
|
"state": {state},
|
||||||
|
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, d.AuthorizationEndpoint+"?"+q.Encode(), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) redirectURI() string {
|
||||||
|
return strings.TrimRight(s.BaseURL, "/") + "/admin/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.oidcAvailable() {
|
||||||
|
http.Error(w, "no identity provider configured", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(stateCookie)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "sign-in did not start here — try again from the login page", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
|
||||||
|
|
||||||
|
state, verifier, ok := strings.Cut(c.Value, ".")
|
||||||
|
if !ok || state == "" || r.URL.Query().Get("state") != state {
|
||||||
|
http.Error(w, "sign-in state did not match — start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "no authorization code returned: "+r.URL.Query().Get("error"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := s.exchange(r.Context(), code, verifier)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("admin oidc exchange failed", "err", err, "from", clientIP(r))
|
||||||
|
http.Error(w, "could not complete sign-in", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := s.OIDC.Verify(r.Context(), idToken)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("admin oidc token rejected", "err", err, "from", clientIP(r))
|
||||||
|
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.OIDC.IsAdmin(claims) {
|
||||||
|
// Named explicitly: "you signed in but you are not an admin" is a different problem from
|
||||||
|
// "your password is wrong", and the group is the thing to go and check.
|
||||||
|
slog.Info("admin access denied: not in group", "account", claims.AccountID(),
|
||||||
|
"want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups)
|
||||||
|
http.Error(w, fmt.Sprintf(
|
||||||
|
"Signed in as %s, but that account is not in the %q group, so it cannot administer "+
|
||||||
|
"this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r))
|
||||||
|
s.setSession(w, claims.AccountID(), claims.Display())
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange trades the authorization code for tokens at the IdP.
|
||||||
|
func (s *Server) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||||
|
d, err := s.OIDC.Discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {s.redirectURI()},
|
||||||
|
"client_id": {s.OIDC.Config().ClientID},
|
||||||
|
"code_verifier": {verifier},
|
||||||
|
}
|
||||||
|
if s.ClientSecret != "" {
|
||||||
|
form.Set("client_secret", s.ClientSecret)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var tok struct {
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tok); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if tok.IDToken == "" {
|
||||||
|
return "", fmt.Errorf("token endpoint returned no id_token")
|
||||||
|
}
|
||||||
|
return tok.IDToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP is for logs only. X-Forwarded-For is deliberately ignored: nothing is meant to sit in
|
||||||
|
// front of this listener, so a header claiming otherwise is a caller's assertion about itself.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
||||||
|
return r.RemoteAddr[:i]
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/runs"
|
||||||
|
"echo-lot.app/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.session(r) != nil {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "login", map[string]any{
|
||||||
|
"OIDC": s.oidcAvailable(),
|
||||||
|
"LocalSet": s.Store.LocalAdmin() != nil,
|
||||||
|
"AdminUser": s.AdminUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
devices := s.Store.Devices()
|
||||||
|
linked := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.LinkedToAccount() {
|
||||||
|
linked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var selftest any
|
||||||
|
if s.SelfTest != nil {
|
||||||
|
selftest = s.SelfTest()
|
||||||
|
}
|
||||||
|
s.render(w, r, "dashboard", map[string]any{
|
||||||
|
"Session": sess,
|
||||||
|
"CSRF": s.csrfToken(sess),
|
||||||
|
"Devices": len(devices),
|
||||||
|
"Linked": linked,
|
||||||
|
"Runs": s.totalRuns(devices),
|
||||||
|
"SelfTest": selftest,
|
||||||
|
"Version": s.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) totalRuns(devices []store.Device) int {
|
||||||
|
if s.Runs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
n += len(s.Runs.List(d.ID))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
devices := s.Store.Devices()
|
||||||
|
// Newest first: the device someone is looking for is almost always the one just enrolled.
|
||||||
|
sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) })
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
store.Device
|
||||||
|
Runs int
|
||||||
|
}
|
||||||
|
rows := make([]row, 0, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
n := 0
|
||||||
|
if s.Runs != nil {
|
||||||
|
n = len(s.Runs.List(d.ID))
|
||||||
|
}
|
||||||
|
rows = append(rows, row{Device: d, Runs: n})
|
||||||
|
}
|
||||||
|
s.render(w, r, "devices", map[string]any{
|
||||||
|
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
|
||||||
|
"Link": r.URL.Query().Get("link"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) revokeDevice(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := s.Store.DeleteDevice(id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Worth a log line: revoking a device is destructive, immediate, and someone will eventually
|
||||||
|
// want to know who did it and when.
|
||||||
|
slog.Info("device revoked", "device", id, "by", sess.Subject)
|
||||||
|
http.Redirect(w, r, "/devices", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
tok, err := s.Store.NewEnrollToken(24*time.Hour, "admin-ui")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("enrolment token minted", "by", sess.Subject)
|
||||||
|
// The whole link, not the bare token: it carries the URL and the pin as well, and assembling
|
||||||
|
// those by hand is where an operator gets a pin wrong by one character.
|
||||||
|
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrollLink is supplied by the caller so this package does not need the control server's pin.
|
||||||
|
var _ = 0
|
||||||
|
|
||||||
|
func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
type row struct {
|
||||||
|
runs.Meta
|
||||||
|
DeviceName string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
for _, d := range s.Store.Devices() {
|
||||||
|
if s.Runs == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := d.Name
|
||||||
|
if name == "" {
|
||||||
|
name = d.ID
|
||||||
|
}
|
||||||
|
for _, m := range s.Runs.List(d.ID) {
|
||||||
|
rows = append(rows, row{Meta: m, DeviceName: name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].UploadedAt.After(rows[j].UploadedAt) })
|
||||||
|
if len(rows) > 200 {
|
||||||
|
rows = rows[:200] // a page, not the archive; the count is on the dashboard
|
||||||
|
}
|
||||||
|
s.render(w, r, "runs", map[string]any{"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-indented for reading, but otherwise exactly what was stored. An admin sees the document
|
||||||
|
// at the privacy level its uploader chose — there is nothing here that can un-redact it.
|
||||||
|
var pretty json.RawMessage = body
|
||||||
|
out, err := json.MarshalIndent(json.RawMessage(pretty), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
out = body
|
||||||
|
}
|
||||||
|
s.render(w, r, "run", map[string]any{
|
||||||
|
"Session": sess, "CSRF": s.csrfToken(sess),
|
||||||
|
"Device": r.PathValue("device"), "ID": r.PathValue("id"),
|
||||||
|
"JSON": string(out),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
device, id := r.PathValue("device"), r.PathValue("id")
|
||||||
|
if err := s.Runs.Delete(device, id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("run deleted", "device", device, "run", id, "by", sess.Subject)
|
||||||
|
http.Redirect(w, r, "/runs", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Templates are parsed once at start. html/template escapes by context, which is what makes it
|
||||||
|
// safe to render device names and finding text that ultimately arrived over a network.
|
||||||
|
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
||||||
|
"kb": func(n int64) int64 { return n / 1024 },
|
||||||
|
}).Parse(baseHTML))
|
||||||
|
|
||||||
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
||||||
|
data["Page"] = page
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, data); err != nil {
|
||||||
|
slog.Error("admin template", "page", page, "err", err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
|
||||||
|
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
_, _ = buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHTML = `<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Echolot — {{.Page}}</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark}
|
||||||
|
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
|
||||||
|
header{display:flex;gap:1.2rem;align-items:baseline;padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
|
||||||
|
header h1{font-size:1.1rem;margin:0;font-weight:600}
|
||||||
|
header nav a{color:#9ecbff;text-decoration:none;margin-right:1rem}
|
||||||
|
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem}
|
||||||
|
main{padding:1.2rem;max-width:70rem}
|
||||||
|
table{border-collapse:collapse;width:100%;margin:.6rem 0}
|
||||||
|
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
|
||||||
|
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
|
||||||
|
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
|
||||||
|
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem}
|
||||||
|
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0}
|
||||||
|
.grid{display:flex;gap:1rem;flex-wrap:wrap}
|
||||||
|
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;min-width:8rem}
|
||||||
|
.stat b{display:block;font-size:1.6rem;font-weight:600}
|
||||||
|
.stat span{color:#9aa3ad;font-size:.85rem}
|
||||||
|
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
|
||||||
|
button.danger{background:#8b2f2f}
|
||||||
|
button.plain{background:#3a3f47}
|
||||||
|
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;padding:.4rem .6rem}
|
||||||
|
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
|
||||||
|
.muted{color:#9aa3ad}
|
||||||
|
form.inline{display:inline}
|
||||||
|
</style></head><body>
|
||||||
|
{{if ne .Page "login"}}
|
||||||
|
<header>
|
||||||
|
<h1>Echolot</h1>
|
||||||
|
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
|
||||||
|
<span class="who">{{.Session.Display}}
|
||||||
|
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
|
<main>
|
||||||
|
|
||||||
|
{{if eq .Page "login"}}
|
||||||
|
<h2>Sign in</h2>
|
||||||
|
{{with .Error}}<p class="err">{{.}}</p>{{end}}
|
||||||
|
{{if .OIDC}}
|
||||||
|
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
|
||||||
|
<p class="muted">or use the break-glass account:</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .LocalSet}}
|
||||||
|
<form method="post" action="/login" class="card">
|
||||||
|
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
|
||||||
|
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
|
||||||
|
<p><button>Sign in</button></p>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="err">No break-glass admin is set. Run
|
||||||
|
<code>echolot-server --set-admin-password</code> on the host.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{else if eq .Page "dashboard"}}
|
||||||
|
<div class="grid">
|
||||||
|
<div class="stat"><b>{{.Devices}}</b><span>devices</span></div>
|
||||||
|
<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>
|
||||||
|
<div class="stat"><b>{{.Runs}}</b><span>stored runs</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Server</h3>
|
||||||
|
<p class="muted">version {{.Version}}</p>
|
||||||
|
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{else if eq .Page "devices"}}
|
||||||
|
<h2>Devices</h2>
|
||||||
|
{{with .Link}}
|
||||||
|
<div class="card">
|
||||||
|
<p><b>Enrolment link</b> — single use, valid 24 hours. Treat it like a password until spent.</p>
|
||||||
|
<p><code>{{.}}</code></p>
|
||||||
|
<p class="muted">On a device with adb:<br>
|
||||||
|
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/enroll-tokens">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button>Create enrolment link</button>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
|
||||||
|
{{range .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
|
||||||
|
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>{{.Runs}}</td>
|
||||||
|
<td><form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
|
<button class="danger">Revoke</button></form></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="muted">No devices enrolled.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{else if eq .Page "runs"}}
|
||||||
|
<h2>Uploaded runs</h2>
|
||||||
|
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
|
||||||
|
here can un-redact a run.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
|
||||||
|
{{range .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>{{.DeviceName}}</td>
|
||||||
|
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
<td>{{.FindingCount}}</td>
|
||||||
|
<td>{{kb .SizeBytes}} kB</td>
|
||||||
|
<td>{{.Anonymization}}</td>
|
||||||
|
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{else if eq .Page "run"}}
|
||||||
|
<h2>Run {{.ID}}</h2>
|
||||||
|
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button class="danger">Delete this run</button>
|
||||||
|
</form>
|
||||||
|
<pre>{{.JSON}}</pre>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</main></body></html>
|
||||||
|
`
|
||||||
@@ -74,6 +74,12 @@ type Config struct {
|
|||||||
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
|
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
|
||||||
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
|
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
|
||||||
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
|
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
|
||||||
|
// Issuer for the app's client, when the IdP gives each application its own.
|
||||||
|
//
|
||||||
|
// Authentik derives the issuer from the application slug, so two applications mean two
|
||||||
|
// issuers — and a token's `iss` must match the one that minted it. Empty means both clients
|
||||||
|
// share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do.
|
||||||
|
OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer
|
||||||
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
|
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
|
||||||
|
|
||||||
// Break-glass admin username; the password lives hashed in the state store.
|
// Break-glass admin username; the password lives hashed in the state store.
|
||||||
@@ -167,6 +173,7 @@ func Load(args []string) (*Config, *Actions, error) {
|
|||||||
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
|
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
|
||||||
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
|
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
|
||||||
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
|
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
|
||||||
|
fs.StringVar(&c.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer")
|
||||||
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
|
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
|
||||||
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
|
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
|
||||||
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
|
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ type Server struct {
|
|||||||
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
|
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
|
||||||
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
|
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
|
||||||
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
|
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
|
||||||
// OIDC verifies ID tokens when the operator has configured an issuer (may be nil).
|
// OIDC verifies ID tokens presented by the *app* (may be nil).
|
||||||
OIDC *oidc.Verifier
|
OIDC *oidc.Verifier
|
||||||
|
// AdminOIDC verifies tokens from the admin UI's own client. Separate because an IdP may
|
||||||
|
// give each application its own issuer — Authentik derives it from the application slug —
|
||||||
|
// and a verifier pins exactly one issuer and the clients belonging to it.
|
||||||
|
AdminOIDC *oidc.Verifier
|
||||||
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
|
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
|
||||||
Runs *runs.Store
|
Runs *runs.Store
|
||||||
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
|
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
|
||||||
|
|||||||
Reference in New Issue
Block a user