Files
homelab/wlan-setup/server.go
claude-botandClaude Opus 5.5 28e864cfc4
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/wlan-setup-test Pipeline was canceled
refactor(wlan-setup): build profiles with XML and plist serializers
All three profiles were assembled from strings. They are now typed
documents:

- Android: PPS MO from encoding/xml structs; MIME multipart with
  mime/multipart (boundary renner-passpoint-profile; Android's ConfigParser
  reads part headers into a map, so their order does not matter).
- Apple: .mobileconfig marshalled with howett.net/plist.
- Windows: CarrierProvisioning built as an etree document; the signature is
  an etree subtree, digest and SignedInfo canonicalized with goxmldsig's
  C14N 1.0 canonicalizer (SignedInfo in place, so it carries the inherited
  xmldsig namespace). Same form as Microsoft's samples: one transform,
  rsa-sha1, sha1 digest, chain in KeyInfo.

Tests parse the outputs (multipart reader, encoding/xml, plist, etree) and
verify the Windows signature with goxmldsig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:18:15 +01:00

250 lines
7.3 KiB
Go

package main
import (
"crypto/rand"
"crypto/subtle"
_ "embed"
"encoding/base64"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
//go:embed index.html
var indexHTML []byte
const (
clientID = "wlan-setup"
stateCookie = "wlan_oauth_state"
lifetimeDays = 29
restart = "Bitte die Einrichtung neu starten.\n"
failed = "Die Einrichtung ist fehlgeschlagen.\n"
)
// userPattern admits only plain usernames into a generated file.
var userPattern = regexp.MustCompile(`^[A-Za-z0-9._@+-]{1,64}$`)
var identifierUnsafe = regexp.MustCompile(`[^a-z0-9-]`)
var platformLabels = map[string]string{"android": "Android", "apple": "Apple", "windows": "Windows"}
// Server serves the setup page and the per-platform flows.
type Server struct {
cfg Config
authentik *Authentik
profiles *ProfileStore
now func() time.Time
log *slog.Logger
}
func newServer(cfg Config, log *slog.Logger) *Server {
return &Server{
cfg: cfg,
authentik: &Authentik{base: cfg.AuthentikURL, secret: cfg.ClientSecret, http: &http.Client{Timeout: 10 * time.Second}},
profiles: newProfileStore(time.Now),
now: time.Now,
log: log,
}
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
})
mux.HandleFunc("GET /whoami", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if u := r.Header.Get("X-authentik-username"); userPattern.MatchString(u) {
_, _ = io.WriteString(w, u)
}
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = io.WriteString(w, "ok\n")
})
for name := range platformLabels {
mux.HandleFunc("GET /"+name+"/install", func(w http.ResponseWriter, r *http.Request) { s.install(w, r, name) })
mux.HandleFunc("GET /"+name+"/callback", func(w http.ResponseWriter, r *http.Request) { s.callback(w, r, name) })
}
mux.HandleFunc("GET /windows/profile/{token}", s.windowsProfile)
mux.HandleFunc("/", http.NotFound)
return noStore(mux)
}
func noStore(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
h.ServeHTTP(w, r)
})
}
func random(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) redirectURI(name string) string { return s.cfg.PublicURL + "/" + name + "/callback" }
func setStateCookie(w http.ResponseWriter, name, value string, maxAge int) {
w.Header().Set("Set-Cookie", stateCookie+"="+value+"; Path=/"+name+"/; Max-Age="+strconv.Itoa(maxAge)+
"; HttpOnly; Secure; SameSite=Lax")
}
func redirect(w http.ResponseWriter, location string) {
w.Header().Set("Location", location)
w.WriteHeader(http.StatusFound)
}
func text(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}
// install starts an authorization-code sign-in at authentik; the user is already signed in there.
func (s *Server) install(w http.ResponseWriter, _ *http.Request, name string) {
state := random(24)
setStateCookie(w, name, state, 300)
redirect(w, s.cfg.AuthorizeURL+"?"+form(
"response_type", "code",
"client_id", clientID,
"redirect_uri", s.redirectURI(name),
"scope", "openid profile goauthentik.io/api",
"state", state,
))
}
func stateFrom(r *http.Request) string {
c, err := r.Cookie(stateCookie)
if err != nil {
return ""
}
return c.Value
}
// callback creates a new app password as the user and answers with the platform's profile.
func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) {
q := r.URL.Query()
state, code := stateFrom(r), q.Get("code")
if state == "" || code == "" || subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 {
text(w, http.StatusBadRequest, restart)
return
}
fail := func(reason string, args ...any) {
s.log.Error("wlan-setup "+name+": "+reason, args...)
text(w, http.StatusBadGateway, failed)
}
// Windows signs the profile: without key and chain there is nothing to hand out, so no app
// password is created.
var signer *Signer
if name == "windows" {
var err error
if signer, err = loadSigner(s.cfg.SigningKey, s.cfg.SigningCert); err != nil {
fail("cannot load the signing key", "err", err)
return
}
}
ctx := r.Context()
access, err := s.authentik.Exchange(ctx, code, s.redirectURI(name))
if err != nil {
fail("token exchange", "err", err)
return
}
user, err := s.authentik.Username(ctx, access)
if err != nil {
fail("user lookup", "err", err)
return
}
if !userPattern.MatchString(user) || user != r.Header.Get("X-authentik-username") {
s.log.Warn("wlan-setup "+name+": the token's user is not the signed-in user", "user", user)
w.WriteHeader(http.StatusForbidden)
return
}
now := s.now().UTC()
suffix := strings.Map(func(c rune) rune {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
return c
}
return 'x'
}, random(24)[:6])
identifier := "wlan-" + name + "-" + identifierUnsafe.ReplaceAllString(strings.ToLower(user), "-") + "-" +
now.Format("20060102T150405") + "-" + suffix
key, err := s.authentik.AppPassword(ctx, access, tokenRequest{
Identifier: identifier,
Intent: "app_password",
Description: "WLAN " + platformLabels[name] + ", " + now.Format("2006-01-02") + " (wlan.renner.uno)",
Expiring: true,
Expires: now.Add(lifetimeDays * 24 * time.Hour).Format("2006-01-02T15:04:05.000Z07:00"),
})
if err != nil {
fail("app password", "err", err)
return
}
switch name {
case "android":
body, err := androidProfile(user, key)
if err != nil {
fail("building the profile", "err", err)
return
}
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-wifi-config")
w.Header().Set("Content-Transfer-Encoding", "base64")
_, _ = w.Write(body)
case "apple":
body, err := appleProfile(user, key)
if err != nil {
fail("building the profile", "err", err)
return
}
setStateCookie(w, name, "", 0)
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`)
_, _ = w.Write(body)
case "windows":
body, err := signer.Sign(windowsProfile(user, key))
if err != nil {
fail("signing the profile", "err", err)
return
}
token := random(32)
if !s.profiles.Put(token, body) {
fail("too many profiles waiting to be fetched")
return
}
setStateCookie(w, name, "", 0)
redirect(w, "ms-settings:wifi-provisioning?uri="+s.cfg.PublicURL+"/windows/profile/"+token)
}
}
// windowsProfile hands out a signed profile once. Windows Settings fetches it without the
// authentik session: the unguessable single-use token is the only gate.
func (s *Server) windowsProfile(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if r.Method == http.MethodHead {
if !s.profiles.Has(token) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/xml")
return
}
body, ok := s.profiles.Take(token)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write(body)
}