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>
250 lines
7.3 KiB
Go
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)
|
|
}
|