wlan-setup/ is a Go service that will replace the nginx + njs backend of
wlan.renner.uno (switched over in a follow-up once the first image exists).
- Page, /whoami, /healthz; /{android,apple,windows}/install and /callback:
authentik OAuth code flow (client wlan-setup, client_secret_basic), user
check against X-authentik-username, per-device app password
wlan-<platform>-... (29 days), profile per platform: Android Passpoint
(application/x-wifi-config), Apple .mobileconfig with Passpoint, Windows
CarrierProvisioning with Passpoint.
- Windows: the profile is signed with XML-DSig in the form of Microsoft's
samples (enveloped, C14N 1.0, rsa-sha1, chain in KeyInfo) by the key in
/etc/wlan-signing, kept for 5 minutes under a single-use token, and the
browser is sent to ms-settings:wifi-provisioning?uri=.../windows/profile/<token>.
- Offline tests against a fake authentik; goxmldsig verifies the signature
in the tests only.
- CI: wlan-setup-test (gofmt, vet, test -race) on PRs; build-ci-images
builds gitea.renner.uno/gitea_admin/wlan-setup:YYYY-MM-DD-HHMM on main;
Flux image automation follows the newest tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
4.7 KiB
Go
170 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// authentikHost is the Host header of every call: authentik routes by it.
|
|
const authentikHost = "auth.renner.uno"
|
|
|
|
// maxReply bounds what is read of an authentik reply.
|
|
const maxReply = 1 << 20
|
|
|
|
var credential = regexp.MustCompile(`^[A-Za-z0-9._~+/=-]+$`)
|
|
|
|
// Authentik calls authentik's OAuth and API endpoints at its internal address.
|
|
type Authentik struct {
|
|
base string
|
|
secret string
|
|
http *http.Client
|
|
}
|
|
|
|
// call sends one request and decodes the JSON reply into out. Errors name the method, path and
|
|
// status, never the credential or the request body.
|
|
func (a *Authentik) call(ctx context.Context, method, path, auth, contentType string, body []byte, out any) error {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
rd = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, a.base+path, rd)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s: %w", method, path, err)
|
|
}
|
|
req.Host = authentikHost
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Accept", "application/json")
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
resp, err := a.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxReply+1))
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s: reading the reply: %w", method, path, err)
|
|
}
|
|
if len(data) > maxReply {
|
|
return fmt.Errorf("%s %s -> %d: reply larger than %d bytes", method, path, resp.StatusCode, maxReply)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return fmt.Errorf("%s %s -> %d %s", method, path, resp.StatusCode, snippet(data))
|
|
}
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("%s %s: decoding the reply: %w", method, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func snippet(b []byte) string {
|
|
s := strings.ToValidUTF8(string(b), "?")
|
|
if len(s) > 300 {
|
|
s = s[:300]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func bearer(token string) (string, error) {
|
|
if !credential.MatchString(token) {
|
|
return "", fmt.Errorf("no access token")
|
|
}
|
|
return "Bearer " + token, nil
|
|
}
|
|
|
|
// Exchange trades the authorization code for an access token.
|
|
func (a *Authentik) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
|
basic := "Basic " + base64.StdEncoding.EncodeToString([]byte(clientID+":"+a.secret))
|
|
body := form("grant_type", "authorization_code", "code", code, "redirect_uri", redirectURI)
|
|
var grant struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if err := a.call(ctx, http.MethodPost, "/application/o/token/", basic, "application/x-www-form-urlencoded",
|
|
[]byte(body), &grant); err != nil {
|
|
return "", err
|
|
}
|
|
if !credential.MatchString(grant.AccessToken) {
|
|
return "", fmt.Errorf("POST /application/o/token/: no access token in the reply")
|
|
}
|
|
return grant.AccessToken, nil
|
|
}
|
|
|
|
// Username returns the username of the access token's user.
|
|
func (a *Authentik) Username(ctx context.Context, token string) (string, error) {
|
|
auth, err := bearer(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var me struct {
|
|
User struct {
|
|
Username string `json:"username"`
|
|
} `json:"user"`
|
|
}
|
|
if err := a.call(ctx, http.MethodGet, "/api/v3/core/users/me/", auth, "", nil, &me); err != nil {
|
|
return "", err
|
|
}
|
|
return me.User.Username, nil
|
|
}
|
|
|
|
type tokenRequest struct {
|
|
Identifier string `json:"identifier"`
|
|
Intent string `json:"intent"`
|
|
Description string `json:"description"`
|
|
Expiring bool `json:"expiring"`
|
|
Expires string `json:"expires"`
|
|
}
|
|
|
|
// AppPassword creates an app password as the token's user and returns its key.
|
|
func (a *Authentik) AppPassword(ctx context.Context, token string, t tokenRequest) (string, error) {
|
|
auth, err := bearer(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body, err := json.Marshal(t)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var created json.RawMessage
|
|
if err := a.call(ctx, http.MethodPost, "/api/v3/core/tokens/", auth, "application/json", body, &created); err != nil {
|
|
return "", err
|
|
}
|
|
var view struct {
|
|
Key string `json:"key"`
|
|
}
|
|
path := "/api/v3/core/tokens/" + url.PathEscape(t.Identifier) + "/view_key/"
|
|
if err := a.call(ctx, http.MethodGet, path, auth, "", nil, &view); err != nil {
|
|
return "", err
|
|
}
|
|
if view.Key == "" {
|
|
return "", fmt.Errorf("GET %s: no key in the reply", path)
|
|
}
|
|
return view.Key, nil
|
|
}
|
|
|
|
// form encodes key/value pairs in the given order, like encodeURIComponent.
|
|
func form(pairs ...string) string {
|
|
var b strings.Builder
|
|
for i := 0; i+1 < len(pairs); i += 2 {
|
|
if i > 0 {
|
|
b.WriteByte('&')
|
|
}
|
|
b.WriteString(escape(pairs[i]))
|
|
b.WriteByte('=')
|
|
b.WriteString(escape(pairs[i+1]))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func escape(s string) string {
|
|
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
|
}
|