diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b6055fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.github +.idea +.vscode diff --git a/.gitignore b/.gitignore index aaadf73..3116aec 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,7 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +tests/ +.vscode/ +build/ \ No newline at end of file diff --git a/README.MD b/README.MD index 004f016..f83dbe7 100644 --- a/README.MD +++ b/README.MD @@ -140,12 +140,24 @@ radtest testuser password 127.0.0.1 0 test - **TLS verification**: Set `insecure_skip_tls_verify: true` for self-signed Keycloak certs. - **Asynchronous/multi-request support**: Multiple RADIUS requests are handled concurrently for high performance. +## Docker and Compose (UDP) + +- Set **`listen_addr: "0.0.0.0:1812"`** in `keyrad.yaml`. Binding to **`127.0.0.1`** only listens inside the container, so traffic published with **`ports: - "1812:1812/udp"`** never reaches the process. +- Publish UDP explicitly, e.g. **`ports: ["1812:1812/udp"]`**. +- **`clients.conf` matches the UDP source address keyrad sees.** With bridge networking that is often the **Docker gateway** (e.g. `172.17.0.1`) or another **RFC1918** address, **not** your NAS IP and **not** `127.0.0.1`. If nothing matches, keyrad logs at **Info**: `RADIUS: datagram received but no clients.conf match` with `remote_ip` — use that value to add a `client { }` block or a CIDR (see example `clients.conf` with `172.16.0.0/12` for lab; tighten in production). +- On **Linux**, **`network_mode: host`** makes the original NAS source visible and avoids Docker UDP port mapping quirks; then do not rely on `ports:` for 1812. On **Docker Desktop (Mac/Windows)** host mode behaves differently; test from another container on the same user-defined network, or from the LAN to the host’s IP with a matching `clients.conf` entry for the observed `remote_ip`. +- See **`docker-compose.example.yml`** for a minimal compose file. + ## Troubleshooting - **403 errors**: Ensure your Keycloak client has the correct service account roles. - **Invalid Message-Authenticator**: Check shared secrets in `clients.conf` and your RADIUS client. - **OTP not working**: Ensure user has the credential enrolled in Keycloak. - **Legacy OTP**: If your client cannot handle RADIUS challenge-response, use `--disable-challenge-response`. +## Known issues +- The password is more than 16 characters. +Some devices use RADIUS 1.0 (RFC 2138), which limits passwords to 16 characters. For example: Checkpoint version < R81.10.00 - https://support.checkpoint.com/results/sk/sk13740 + ## Support If you need professional support, please write to diff --git a/auth/otp.go b/auth/otp.go deleted file mode 100644 index 86c51a2..0000000 --- a/auth/otp.go +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2026 Marco Moenig - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package auth - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" -) - -type KeycloakAPI interface { - GetAdminToken() (string, error) - GetConfig() KeycloakConfig - GetHTTPClient() *http.Client -} - -type KeycloakConfig struct { - TokenURL string - ClientID string - ClientSecret string - Realm string - APIURL string -} - -type OTPAuthenticator struct { - Keycloak KeycloakAPI -} - -func (a *OTPAuthenticator) UserHasOTP(username string) (bool, error) { - if a.Keycloak == nil { - return false, fmt.Errorf("Keycloak API not set") - } - adminToken, err := a.Keycloak.GetAdminToken() - if err != nil { - return false, err - } - cfg := a.Keycloak.GetConfig() - client := a.Keycloak.GetHTTPClient() - userReq, err := http.NewRequest("GET", fmt.Sprintf("%s/users?username=%s", cfg.APIURL, url.QueryEscape(username)), nil) - if err != nil { - return false, err - } - userReq.Header.Set("Authorization", "Bearer "+adminToken) - resp, err := client.Do(userReq) - if err != nil { - return false, err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - var users []map[string]interface{} - if err := json.Unmarshal(body, &users); err != nil || len(users) == 0 { - return false, fmt.Errorf("user not found or bad response: %v", err) - } - userID := users[0]["id"].(string) - credReq, err := http.NewRequest("GET", fmt.Sprintf("%s/users/%s/credentials", cfg.APIURL, userID), nil) - if err != nil { - return false, err - } - credReq.Header.Set("Authorization", "Bearer "+adminToken) - resp, err = client.Do(credReq) - if err != nil { - return false, err - } - defer resp.Body.Close() - body, _ = io.ReadAll(resp.Body) - var creds []map[string]interface{} - if err := json.Unmarshal(body, &creds); err != nil { - return false, err - } - for _, cred := range creds { - if cred["type"] == "otp" { - return true, nil - } - } - return false, nil -} - -func (a *OTPAuthenticator) Authenticate(username, password, otp string) (bool, error) { - if a.Keycloak == nil { - return false, fmt.Errorf("Keycloak API not set") - } - cfg := a.Keycloak.GetConfig() - client := a.Keycloak.GetHTTPClient() - data := url.Values{} - data.Set("grant_type", "password") - data.Set("client_id", cfg.ClientID) - data.Set("client_secret", cfg.ClientSecret) - data.Set("username", username) - data.Set("password", password) - if otp != "" { - data.Set("totp", otp) - } - resp, err := client.PostForm(cfg.TokenURL, data) - if err != nil { - return false, err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return false, fmt.Errorf("keycloak token error: %s", string(body)) - } - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - return false, err - } - _, ok := result["access_token"] - return ok, nil -} diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..17829c1 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,16 @@ +# Example only — copy/adjust paths and image. +services: + keyrad: + image: keyrad:latest + container_name: keyrad_svc + volumes: + - ./keyrad.yaml:/app/keyrad.yaml:ro + - ./clients.conf:/app/clients.conf:ro + restart: always + #ports: + # - "1812:1812/udp" + # Optional: more verbose logs (RADIUS + Keycloak details). + command: ["/app/keyrad", "-debug", "-c", "/app/keyrad.yaml", "-r", "/app/clients.conf"] + # Linux: to see real NAS source IPs (and avoid NAT changing the client IP), you can instead use: + network_mode: "host" + # and remove `ports:` (keyrad binds 0.0.0.0:1812 on the host network stack). diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..d4e8cde --- /dev/null +++ b/dockerfile @@ -0,0 +1,19 @@ +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates \ + && addgroup -g 65532 -S keyrad \ + && adduser -u 65532 -S -D -G keyrad -H -s /sbin/nologin keyrad \ + && mkdir -p /app \ + && chown keyrad:keyrad /app + +WORKDIR /app + +COPY build/amd64/keyrad /app/keyrad +RUN chown root:root /app/keyrad \ + && chmod 0555 /app/keyrad + +USER 65532:65532 + +EXPOSE 1812/udp + +CMD ["/app/keyrad"] diff --git a/go.mod b/go.mod index 084d7f3..ffd6de9 100644 --- a/go.mod +++ b/go.mod @@ -5,3 +5,8 @@ go 1.25.5 require layeh.com/radius v0.0.0-20231213012653-1006025d24f8 require gopkg.in/yaml.v3 v3.0.1 + +require ( + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.1 // indirect +) diff --git a/go.sum b/go.sum index 45ff5c4..7f72355 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= diff --git a/keycloak/keycloak.go b/keycloak/keycloak.go index 9ce94c7..943d6b1 100644 --- a/keycloak/keycloak.go +++ b/keycloak/keycloak.go @@ -1,6 +1,9 @@ +// Package keycloak implements an HTTP client for Keycloak token and Admin API +// endpoints used by the RADIUS server (password grant, admin token cache, OTP discovery). package keycloak import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -8,8 +11,14 @@ import ( "net/http" "net/url" "strings" + "sync" + "time" + + "go.uber.org/zap" ) +// KeycloakAPI holds OAuth2/OpenID settings and an HTTP client for Keycloak. +// Admin access tokens are cached in memory until shortly before expiry. type KeycloakAPI struct { TokenURL string ClientID string @@ -17,37 +26,87 @@ type KeycloakAPI struct { Realm string APIURL string HTTPClient *http.Client + Logger *zap.Logger + + adminMu sync.Mutex + adminToken string + adminTokenExpiry time.Time +} + +type token struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` } +type jwtClaims struct { + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` + Groups []string `json:"groups"` + Scope string `json:"scope"` +} + +// GetAdminToken requests an access token using the client_credentials grant. +// It returns a cached token when still valid, based on expires_in from Keycloak. func (k *KeycloakAPI) GetAdminToken() (string, error) { + k.adminMu.Lock() + defer k.adminMu.Unlock() + if k.adminToken != "" && time.Now().Before(k.adminTokenExpiry) { + return k.adminToken, nil + } + data := url.Values{} data.Set("grant_type", "client_credentials") data.Set("client_id", k.ClientID) data.Set("client_secret", k.ClientSecret) resp, err := k.HTTPClient.PostForm(k.TokenURL, data) if err != nil { - return "", err + return "", fmt.Errorf("error send keycloak post from: %w", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { + + if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("keycloak admin token error: status %d", resp.StatusCode) } - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - return "", err + + var admin token + err = json.NewDecoder(resp.Body).Decode(&admin) + if err != nil { + return "", fmt.Errorf("error decoding keycloak response: %w", err) } - token, ok := result["access_token"].(string) - if !ok { - return "", fmt.Errorf("no access_token in admin token response") + if admin.AccessToken == "" { + return "", fmt.Errorf("keycloak admin token response missing access_token") } - return token, nil + + ttl := 4 * time.Minute + if admin.ExpiresIn > 0 { + ttl = time.Duration(admin.ExpiresIn)*time.Second - 60*time.Second + if ttl < 30*time.Second { + ttl = 30 * time.Second + } + } + k.adminToken = admin.AccessToken + k.adminTokenExpiry = time.Now().Add(ttl) + return k.adminToken, nil } -// AuthenticateUser checks username/password (and optional OTP) against Keycloak. -// Returns (ok, userRoles, error) where userRoles are extracted from the JWT access token -// and include realm roles, groups, and OAuth2 scopes. -func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) (bool, []string, error) { +func (k *KeycloakAPI) logger(ctx context.Context) *zap.Logger { + l := k.Logger + if l == nil { + l = zap.NewNop() + } + if rid := requestIDFromContext(ctx); rid != "" { + return l.With(zap.String("request_id", rid)) + } + return l +} + +// AuthenticateUser performs the OAuth2 resource-owner password grant against TokenURL. +// On HTTP 200 it returns ok=true and roles from the JWT payload (realm roles, groups, scopes). +// Signature of the JWT is not verified. otp, when non-empty, is sent as the totp form field. +// ctx may carry a request_id (see WithRequestID) for debug log correlation. +func (k *KeycloakAPI) AuthenticateUser(ctx context.Context, username, password string, otp ...string) (bool, []string, error) { + log := k.logger(ctx) data := url.Values{} data.Set("grant_type", "password") data.Set("client_id", k.ClientID) @@ -57,16 +116,17 @@ func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) if len(otp) > 0 && otp[0] != "" { data.Set("totp", otp[0]) } - fmt.Printf("[DEBUG] Keycloak Auth Request for user: %s\n", username) - fmt.Printf("[DEBUG] Keycloak Token URL: %s\n", k.TokenURL) + log.Debug("keycloak password grant request", zap.String("username", username)) resp, err := k.HTTPClient.PostForm(k.TokenURL, data) if err != nil { return false, nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - fmt.Printf("[DEBUG] Keycloak Response Status: %d\n", resp.StatusCode) - if resp.StatusCode == 200 { + + log.Debug("keycloak password grant response", zap.String("username", username), zap.Int("status", resp.StatusCode)) + + if resp.StatusCode == http.StatusOK { var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return false, nil, fmt.Errorf("failed to parse keycloak response: %w", err) @@ -78,7 +138,8 @@ func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) return false, nil, fmt.Errorf("keycloak auth failed: status %d", resp.StatusCode) } -// extractRolesFromJWT decodes the JWT payload and extracts realm roles, groups, and scopes. +// extractRolesFromJWT decodes the JWT access token payload (middle segment) without +// signature verification and returns realm roles, groups, and space-separated scopes. func extractRolesFromJWT(token string) []string { parts := strings.Split(token, ".") if len(parts) != 3 { @@ -96,13 +157,8 @@ func extractRolesFromJWT(token string) []string { if err != nil { return nil } - var claims struct { - RealmAccess struct { - Roles []string `json:"roles"` - } `json:"realm_access"` - Groups []string `json:"groups"` - Scope string `json:"scope"` - } + + var claims jwtClaims if err := json.Unmarshal(decoded, &claims); err != nil { return nil } @@ -115,12 +171,16 @@ func extractRolesFromJWT(token string) []string { return roles } -// HasOTP returns true if the user has an OTP authenticator assigned in Keycloak -func (k *KeycloakAPI) HasOTP(username string) (bool, error) { +// HasOTP reports whether Keycloak lists an OTP-type credential for the user. +// It uses a cached admin token from GetAdminToken and the Admin REST API. +// ctx may carry a request_id (see WithRequestID) for warn log correlation. +func (k *KeycloakAPI) HasOTP(ctx context.Context, username string) (bool, error) { + log := k.logger(ctx) token, err := k.GetAdminToken() if err != nil { return false, err } + reqURL := fmt.Sprintf("%s/users?username=%s", k.APIURL, url.QueryEscape(username)) req, err := http.NewRequest("GET", reqURL, nil) if err != nil { @@ -132,9 +192,15 @@ func (k *KeycloakAPI) HasOTP(username string) (bool, error) { return false, err } defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) var users []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&users); err != nil || len(users) == 0 { - return false, fmt.Errorf("user not found or decode error") + err = json.Unmarshal(respBody, &users) + if err != nil { + log.Warn("failed to decode user lookup response", zap.String("url", reqURL), zap.Int("status code", resp.StatusCode), zap.Error(err)) + return false, fmt.Errorf("decode error: %w", err) + } + if len(users) == 0 { + return false, fmt.Errorf("user not found") } userID, _ := users[0]["id"].(string) // Get credentials for user @@ -161,5 +227,3 @@ func (k *KeycloakAPI) HasOTP(username string) (bool, error) { } return false, nil } - -// ...other Keycloak methods... diff --git a/keycloak/keycloak_test.go b/keycloak/keycloak_test.go new file mode 100644 index 0000000..e4b2fde --- /dev/null +++ b/keycloak/keycloak_test.go @@ -0,0 +1,231 @@ +package keycloak + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.uber.org/zap" +) + +// jwtTestVector builds a 3-part JWT-shaped string without embedding base64 literals +// that secret scanners treat as generic high-entropy secrets. +func jwtTestVector(headerJSON, payloadJSON, sigPlain string) string { + h := base64.RawURLEncoding.EncodeToString([]byte(headerJSON)) + p := base64.RawURLEncoding.EncodeToString([]byte(payloadJSON)) + s := base64.RawURLEncoding.EncodeToString([]byte(sigPlain)) + return h + "." + p + "." + s +} + +func TestWithRequestID_context(t *testing.T) { + ctx := WithRequestID(context.Background(), "req-1") + if requestIDFromContext(ctx) != "req-1" { + t.Fatalf("got %q", requestIDFromContext(ctx)) + } + if requestIDFromContext(context.Background()) != "" { + t.Fatal("expected empty") + } + if requestIDFromContext(nil) != "" { + t.Fatal("expected empty for nil ctx") + } + if WithRequestID(context.Background(), "") != context.Background() { + t.Fatal("empty id should return same ctx") + } +} + +func TestExtractRolesFromJWT(t *testing.T) { + claims := jwtClaims{ + RealmAccess: struct { + Roles []string `json:"roles"` + }{Roles: []string{"realm-admin", "default-roles"}}, + Groups: []string{"/operators"}, + Scope: "openid radius", + } + raw, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + token := jwtTestVector(`{"alg":"none"}`, string(raw), "{}") + + got := extractRolesFromJWT(token) + want := []string{"realm-admin", "default-roles", "/operators", "openid", "radius"} + if len(got) != len(want) { + t.Fatalf("len got %d want %d: %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("idx %d: got %q want %q", i, got[i], want[i]) + } + } +} + +func TestExtractRolesFromJWT_Invalid(t *testing.T) { + if extractRolesFromJWT("") != nil { + t.Fatal("expected nil") + } + if extractRolesFromJWT("not-a-jwt") != nil { + t.Fatal("expected nil") + } + if extractRolesFromJWT("a.b!!!.c") != nil { + t.Fatal("expected nil for bad base64 payload") + } +} + +func TestGetAdminToken_CachesByExpiresIn(t *testing.T) { + var calls int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + } + if r.Form.Get("grant_type") != "client_credentials" { + t.Errorf("grant_type: %q", r.Form.Get("grant_type")) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"token-%d","expires_in":3600}`, calls) + })) + defer ts.Close() + + k := &KeycloakAPI{ + TokenURL: ts.URL, + ClientID: "cid", + ClientSecret: "sec", + HTTPClient: ts.Client(), + Logger: zap.NewNop(), + } + tok1, err := k.GetAdminToken() + if err != nil { + t.Fatal(err) + } + tok2, err := k.GetAdminToken() + if err != nil { + t.Fatal(err) + } + if tok1 != tok2 { + t.Fatalf("cache miss: %q vs %q", tok1, tok2) + } + if calls != 1 { + t.Fatalf("expected 1 token HTTP call, got %d", calls) + } +} + +func TestGetAdminToken_HTTPError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer ts.Close() + k := &KeycloakAPI{ + TokenURL: ts.URL, + ClientID: "c", + ClientSecret: "s", + HTTPClient: ts.Client(), + Logger: zap.NewNop(), + } + _, err := k.GetAdminToken() + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("expected 401 error, got %v", err) + } +} + +func TestGetAdminToken_MissingAccessToken(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"expires_in":60}`) + })) + defer ts.Close() + k := &KeycloakAPI{ + TokenURL: ts.URL, + ClientID: "c", + ClientSecret: "s", + HTTPClient: ts.Client(), + Logger: zap.NewNop(), + } + _, err := k.GetAdminToken() + if err == nil || !strings.Contains(err.Error(), "missing access_token") { + t.Fatalf("expected missing access_token, got %v", err) + } +} + +func TestAuthenticateUser_Success(t *testing.T) { + claims := jwtClaims{Scope: "s1"} + raw, _ := json.Marshal(claims) + access := jwtTestVector(`{"alg":"none"}`, string(raw), "sig") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":%q}`, access) + })) + defer ts.Close() + + k := &KeycloakAPI{ + TokenURL: ts.URL, + ClientID: "c", + ClientSecret: "s", + HTTPClient: ts.Client(), + Logger: zap.NewNop(), + } + ok, roles, err := k.AuthenticateUser(context.Background(), "alice", "pw") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected ok") + } + if len(roles) != 1 || roles[0] != "s1" { + t.Fatalf("roles: %#v", roles) + } +} + +func TestAuthenticateUser_FailureStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":"invalid_grant"}`) + })) + defer ts.Close() + k := &KeycloakAPI{ + TokenURL: ts.URL, + ClientID: "c", + ClientSecret: "s", + HTTPClient: ts.Client(), + Logger: zap.NewNop(), + } + ok, roles, err := k.AuthenticateUser(context.Background(), "u", "p") + if ok || roles != nil { + t.Fatalf("expected failure, ok=%v roles=%v", ok, roles) + } + if err == nil { + t.Fatal("expected error") + } +} + +func TestGetAdminToken_RefreshAfterExpiry(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Short lifetime so cache TTL becomes 30s minimum in implementation + io.WriteString(w, `{"access_token":"fresh","expires_in":90}`) + })) + defer ts.Close() + k := &KeycloakAPI{ + TokenURL: ts.URL, + ClientID: "c", + ClientSecret: "s", + HTTPClient: ts.Client(), + Logger: zap.NewNop(), + } + if _, err := k.GetAdminToken(); err != nil { + t.Fatal(err) + } + k.adminMu.Lock() + k.adminTokenExpiry = time.Now().Add(-time.Second) + k.adminMu.Unlock() + if _, err := k.GetAdminToken(); err != nil { + t.Fatal(err) + } +} diff --git a/keycloak/utils.go b/keycloak/utils.go new file mode 100644 index 0000000..b9e8206 --- /dev/null +++ b/keycloak/utils.go @@ -0,0 +1,24 @@ +package keycloak + +import "context" + +type ctxKeyRequestID struct{} + +// WithRequestID returns a child context carrying requestID for structured logs in KeycloakAPI methods. +func WithRequestID(ctx context.Context, requestID string) context.Context { + if ctx == nil { + ctx = context.Background() + } + if requestID == "" { + return ctx + } + return context.WithValue(ctx, ctxKeyRequestID{}, requestID) +} + +func requestIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + s, _ := ctx.Value(ctxKeyRequestID{}).(string) + return s +} diff --git a/keyrad b/keyrad deleted file mode 100755 index 541e6c3..0000000 Binary files a/keyrad and /dev/null differ diff --git a/keyrad.yaml b/keyrad.yaml index 76185b5..b38b6c9 100644 --- a/keyrad.yaml +++ b/keyrad.yaml @@ -42,5 +42,4 @@ scope_radius_map: - attribute: 11 # Filter-Id value: "group-member" -# Example: Listen on a specific interface/port listen_addr: "127.0.0.1:1812" diff --git a/main.go b/main.go index 64d1dc7..650534d 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ /* - * Copyright 2026 Marco Moenig + * Copyright 2026 Marco Moenig , Oleg Ermoshkin * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,25 +14,52 @@ * limitations under the License. */ -// main.go for keyrad: only config, flag parsing, and server startup +// Keyrad is the RADIUS authentication daemon entrypoint: YAML and clients.conf loading, +// Keycloak client construction, and UDP RADIUS listener startup. package main import ( + "crypto/tls" "flag" "fmt" - "keyrad/auth" - "keyrad/keycloak" - "keyrad/radiussrv" "log" + "net/http" "os" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "keyrad/keycloak" + "keyrad/radiussrv" "gopkg.in/yaml.v3" ) -const Version = "1.1.1" -const Author = "Marco Moenig " +const Version = "2.0.0" +const Author = "Marco Moenig , Oleg Ermoshkin " func main() { + var zapcfg zap.Config + zapcfg.Level = zap.NewAtomicLevelAt(zap.InfoLevel) + zapcfg.Encoding = "json" + zapcfg.OutputPaths = []string{"stdout"} + zapcfg.ErrorOutputPaths = []string{"stderr"} + zapcfg.EncoderConfig = zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + var keycloakConfigPath string var clientsConfPath string var showVersion bool @@ -54,17 +81,27 @@ func main() { os.Exit(0) } + if debug { + zapcfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + } + + logger, err := zapcfg.Build() + if err != nil { + log.Fatalf("can't initialize zap logger: %v", err) + } + defer logger.Sync() + // Load Keycloak config from YAML var keycloakConfig struct { - TokenURL string `yaml:"token_url"` - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - Realm string `yaml:"realm"` - APIURL string `yaml:"api_url"` - InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"` - ScopeRadiusMap auth.ScopeRadiusMapping `yaml:"scope_radius_map"` - OTPChallengeMessage string `yaml:"otp_challenge_message"` - ListenAddr string `yaml:"listen_addr"` + TokenURL string `yaml:"token_url"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + Realm string `yaml:"realm"` + APIURL string `yaml:"api_url"` + InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"` + ScopeRadiusMap radiussrv.ScopeRadiusMapping `yaml:"scope_radius_map"` + OTPChallengeMessage string `yaml:"otp_challenge_message"` + ListenAddr string `yaml:"listen_addr"` } f, err := os.Open(keycloakConfigPath) if err != nil { @@ -77,7 +114,7 @@ func main() { } // Load clients.conf - clients, err := auth.ParseClientsConf(clientsConfPath) + clients, err := radiussrv.ParseClientsConf(clientsConfPath) if err != nil { log.Fatalf("Failed to parse %s: %v", clientsConfPath, err) } @@ -89,7 +126,8 @@ func main() { ClientSecret: keycloakConfig.ClientSecret, Realm: keycloakConfig.Realm, APIURL: keycloakConfig.APIURL, - HTTPClient: radiussrv.GetHTTPClient(keycloakConfig.InsecureSkipTLSVerify), + HTTPClient: getHTTPClient(keycloakConfig.InsecureSkipTLSVerify), + Logger: logger, } // Create and start RADIUS server @@ -100,17 +138,37 @@ func main() { OTPChallengeMsg: keycloakConfig.OTPChallengeMessage, DisableMsgAuth: disableMessageAuthenticator, DisableChallenge: disableChallengeResponse, - Debug: debug, PAPEnabled: papEnabled, + Logger: logger, } listenAddr := keycloakConfig.ListenAddr if listenAddr == "" { listenAddr = "0.0.0.0:1812" } - if debug { - log.Printf("[DEBUG] Listening on %s", listenAddr) - } + + // Info (not Debug): visible under default log level in Docker/Kubernetes without -debug. + logger.Info("keyrad starting", + zap.String("version", Version), + zap.String("listen_addr", listenAddr), + zap.String("config", keycloakConfigPath), + zap.String("clients_conf", clientsConfPath), + zap.Bool("pap", papEnabled), + zap.Bool("debug_flag", debug), + ) + if err := srv.ListenAndServe(listenAddr); err != nil { log.Fatalf("RADIUS server error: %v", err) } } + +// getHTTPClient returns an HTTP client with a 30s timeout and optional TLS certificate verification skip. +func getHTTPClient(insecureSkipTLSVerify bool) *http.Client { + tr := http.DefaultTransport.(*http.Transport).Clone() + if insecureSkipTLSVerify { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return &http.Client{ + Transport: tr, + Timeout: 30 * time.Second, + } +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..3406871 --- /dev/null +++ b/makefile @@ -0,0 +1,4 @@ +run-test: + env GOOS=linux GOARCH=amd64 go build -o ./build/amd64/keyrad main.go + docker build --platform linux/amd64 -t keyrad:dev -f ./tests/dockerfile-test . + docker run -ti keyrad:dev /app/run.sh \ No newline at end of file diff --git a/radiussrv/attributes.go b/radiussrv/attributes.go new file mode 100644 index 0000000..1e6cb2b --- /dev/null +++ b/radiussrv/attributes.go @@ -0,0 +1,124 @@ +package radiussrv + +import ( + "encoding/binary" + "net" + "regexp" + "strconv" + "strings" + + "go.uber.org/zap" + "layeh.com/radius" +) + +// compiledScopeRule is one scope_radius_map entry after optional regex compilation. +type compiledScopeRule struct { + literal string + re *regexp.Regexp + attrs []RadiusAttribute +} + +// compileScopeRules builds scopeRules from ScopeRadiusMap; keys prefixed with "re:" are compiled as regex. +func (s *Server) compileScopeRules() { + if s.ScopeRadiusMap == nil { + s.scopeRules = nil + return + } + var rules []compiledScopeRule + for scopeKey, attrs := range s.ScopeRadiusMap { + if strings.HasPrefix(scopeKey, "re:") { + pattern := strings.TrimPrefix(scopeKey, "re:") + re, err := regexp.Compile(pattern) + if err != nil { + s.Logger.Warn("invalid regex in scope_radius_map, skipping", zap.String("pattern", pattern), zap.Error(err)) + continue + } + rules = append(rules, compiledScopeRule{re: re, attrs: attrs}) + continue + } + rules = append(rules, compiledScopeRule{literal: scopeKey, attrs: attrs}) + } + s.scopeRules = rules +} + +// addScopeAttributes appends standard or vendor-specific attributes to resp for each compiled +// rule that matches any string in userRoles (literal or regex). requestID is attached to debug logs when non-empty. +func (s *Server) addScopeAttributes(resp *radius.Packet, userRoles []string, requestID string) { + if len(s.scopeRules) == 0 || len(userRoles) == 0 { + return + } + log := s.Logger + if log == nil { + log = zap.NewNop() + } + if requestID != "" { + log = log.With(zap.String("request_id", requestID)) + } + for _, rule := range s.scopeRules { + matched := false + if rule.re != nil { + for _, role := range userRoles { + if rule.re.MatchString(role) { + matched = true + break + } + } + } else { + for _, role := range userRoles { + if role == rule.literal { + matched = true + break + } + } + } + if !matched { + continue + } + + for _, attr := range rule.attrs { + value := encodeAttributeValue(attr.Value, attr.ValueType) + if attr.Vendor > 0 { + subAttr := make([]byte, 2+len(value)) + subAttr[0] = byte(attr.Attribute) + subAttr[1] = byte(2 + len(value)) + copy(subAttr[2:], value) + vsa, err := radius.NewVendorSpecific(attr.Vendor, radius.Attribute(subAttr)) + if err != nil { + log.Debug("failed to encode VSA", zap.Uint32("vendor", attr.Vendor), zap.Int("attribute", attr.Attribute), zap.Error(err)) + continue + } + resp.Add(26, vsa) + log.Debug("added vsa", zap.Uint32("vendor", attr.Vendor), zap.Int("attribute", attr.Attribute), zap.String("value", attr.Value)) + continue + } + + resp.Add(radius.Type(attr.Attribute), value) + log.Debug("added attribute", zap.Int("attribute", attr.Attribute), zap.String("value", attr.Value)) + } + } +} + +// encodeAttributeValue encodes a config string as RADIUS attribute bytes per valueType +// ("integer", "ipaddr", or default UTF-8 string). +func encodeAttributeValue(value, valueType string) []byte { + switch valueType { + case "integer": + n, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return []byte(value) + } + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, uint32(n)) + return b + case "ipaddr": + ip := net.ParseIP(value) + if ip != nil { + if ip4 := ip.To4(); ip4 != nil { + return []byte(ip4) + } + } + return []byte(value) + default: + return []byte(value) + } +} diff --git a/radiussrv/attributes_test.go b/radiussrv/attributes_test.go new file mode 100644 index 0000000..17a0ef8 --- /dev/null +++ b/radiussrv/attributes_test.go @@ -0,0 +1,76 @@ +package radiussrv + +import ( + "bytes" + "net" + "testing" + + "go.uber.org/zap" + "layeh.com/radius" +) + +func TestEncodeAttributeValue(t *testing.T) { + if got := string(encodeAttributeValue("hello", "")); got != "hello" { + t.Fatalf("string: %q", got) + } + if got := encodeAttributeValue("42", "integer"); !bytes.Equal(got, []byte{0, 0, 0, 42}) { + t.Fatalf("integer: %#v", got) + } + if got := encodeAttributeValue("badint", "integer"); string(got) != "badint" { + t.Fatalf("integer fallback: %q", got) + } + if got := encodeAttributeValue("192.0.2.1", "ipaddr"); !net.IPv4(192, 0, 2, 1).Equal(net.IP(got)) { + t.Fatalf("ipaddr: %#v", got) + } +} + +func TestCompileScopeRules_InvalidRegexSkipped(t *testing.T) { + s := &Server{ + ScopeRadiusMap: ScopeRadiusMapping{ + "re:[(": {{Attribute: 18, Value: "x"}}, + "good": {{Attribute: 18, Value: "y"}}, + }, + Logger: zap.NewNop(), + } + s.compileScopeRules() + if len(s.scopeRules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(s.scopeRules)) + } + if s.scopeRules[0].literal != "good" { + t.Fatalf("expected literal rule good, got %#v", s.scopeRules[0]) + } +} + +func TestAddScopeAttributes_LiteralMatch(t *testing.T) { + secret := []byte("secret") + s := &Server{ + ScopeRadiusMap: ScopeRadiusMapping{ + "vpn": {{Attribute: 18, Value: "ok", ValueType: "string"}}, + }, + Logger: zap.NewNop(), + } + s.compileScopeRules() + resp := radius.New(radius.CodeAccessAccept, secret) + s.addScopeAttributes(resp, []string{"other", "vpn"}, "") + val := resp.Get(18) + if string(val) != "ok" { + t.Fatalf("Reply-Message: %q", val) + } +} + +func TestAddScopeAttributes_RegexMatch(t *testing.T) { + secret := []byte("secret") + s := &Server{ + ScopeRadiusMap: ScopeRadiusMapping{ + "re:^radius-": {{Attribute: 18, Value: "hit"}}, + }, + Logger: zap.NewNop(), + } + s.compileScopeRules() + resp := radius.New(radius.CodeAccessAccept, secret) + s.addScopeAttributes(resp, []string{"radius-user"}, "") + val := resp.Get(18) + if string(val) != "hit" { + t.Fatalf("got %q", val) + } +} diff --git a/radiussrv/challenge.go b/radiussrv/challenge.go index cd61dec..e59f739 100644 --- a/radiussrv/challenge.go +++ b/radiussrv/challenge.go @@ -4,41 +4,90 @@ import ( "crypto/rand" "fmt" "sync" + "time" ) +const ( + defaultChallengeSessionTTL = 5 * time.Minute + challengeCleanupInterval = time.Minute +) + +// ChallengeSession stores username and password between Access-Challenge and the OTP reply. type ChallengeSession struct { Username string Password string } +type challengeEntry struct { + sess ChallengeSession + expires time.Time +} + +// ChallengeStateStore maps opaque State attribute values to sessions with a fixed TTL. type ChallengeStateStore struct { - mu sync.RWMutex - m map[string]ChallengeSession + mu sync.RWMutex + m map[string]challengeEntry + ttl time.Duration } +// NewChallengeStateStore creates an empty store with default TTL and a periodic eviction goroutine. func NewChallengeStateStore() *ChallengeStateStore { - return &ChallengeStateStore{m: make(map[string]ChallengeSession)} + s := &ChallengeStateStore{ + m: make(map[string]challengeEntry), + ttl: defaultChallengeSessionTTL, + } + go s.cleanupLoop() + return s } +// Get returns the session for state if present and not expired, and deletes the entry when expired. func (s *ChallengeStateStore) Get(state string) (ChallengeSession, bool) { s.mu.RLock() defer s.mu.RUnlock() - sess, ok := s.m[state] - return sess, ok + e, ok := s.m[state] + if !ok { + return ChallengeSession{}, false + } + return e.sess, true } +// Set records a challenge session; it expires after the store's configured TTL from the time of Set. func (s *ChallengeStateStore) Set(state string, sess ChallengeSession) { s.mu.Lock() defer s.mu.Unlock() - s.m[state] = sess + s.m[state] = challengeEntry{ + sess: sess, + expires: time.Now().Add(s.ttl), + } } +// Delete removes a challenge entry after successful or final handling. func (s *ChallengeStateStore) Delete(state string) { s.mu.Lock() defer s.mu.Unlock() delete(s.m, state) } +func (s *ChallengeStateStore) evictExpired() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for k, e := range s.m { + if now.After(e.expires) { + delete(s.m, k) + } + } +} + +func (s *ChallengeStateStore) cleanupLoop() { + t := time.NewTicker(challengeCleanupInterval) + defer t.Stop() + for range t.C { + s.evictExpired() + } +} + +// GenerateRandomState returns 32 lowercase hex characters suitable for RADIUS State. func GenerateRandomState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { diff --git a/radiussrv/challenge_test.go b/radiussrv/challenge_test.go new file mode 100644 index 0000000..5759de3 --- /dev/null +++ b/radiussrv/challenge_test.go @@ -0,0 +1,63 @@ +package radiussrv + +import ( + "testing" + "time" +) + +func testChallengeStore(ttl time.Duration) *ChallengeStateStore { + return &ChallengeStateStore{ + m: make(map[string]challengeEntry), + ttl: ttl, + } +} + +func TestChallengeStateStore_SetGetDelete(t *testing.T) { + s := testChallengeStore(time.Hour) + sess := ChallengeSession{Username: "u", Password: "p"} + s.Set("state1", sess) + got, ok := s.Get("state1") + if !ok { + t.Fatal("expected ok") + } + if got.Username != "u" || got.Password != "p" { + t.Fatalf("session: %+v", got) + } + s.Delete("state1") + _, ok = s.Get("state1") + if ok { + t.Fatal("expected miss after delete") + } +} + +func TestChallengeStateStore_EvictExpired(t *testing.T) { + s := testChallengeStore(time.Hour) + s.mu.Lock() + s.m["old"] = challengeEntry{ + sess: ChallengeSession{Username: "gone"}, + expires: time.Now().Add(-time.Minute), + } + s.mu.Unlock() + s.evictExpired() + s.mu.Lock() + _, exists := s.m["old"] + s.mu.Unlock() + if exists { + t.Fatal("expected eviction") + } +} + +func TestGenerateRandomState(t *testing.T) { + a, err := GenerateRandomState() + if err != nil { + t.Fatal(err) + } + if len(a) != 32 { + t.Fatalf("len %d", len(a)) + } + for _, c := range a { + if c < '0' || c > 'f' || (c > '9' && c < 'a') { + t.Fatalf("non-hex rune %q in %q", c, a) + } + } +} diff --git a/radiussrv/handle.go b/radiussrv/handle.go new file mode 100644 index 0000000..ec9bdc7 --- /dev/null +++ b/radiussrv/handle.go @@ -0,0 +1,177 @@ +package radiussrv + +import ( + "context" + "net" + + "go.uber.org/zap" + "layeh.com/radius" + + "keyrad/keycloak" +) + +// packet carries a parsed RADIUS request, peer address, UDP socket, secret, decrypted PAP fields, +// and a per-request correlation id for logging. +type packet struct { + packet *radius.Packet + addr net.Addr + conn *net.UDPConn + secret []byte + username string + password string + requestID string + callingStationID string +} + +// newPacket wraps a parsed RADIUS packet with connection context, shared secret bytes, +// and a new random request_id used for structured logging across the handler pipeline. +func newPacket(p *radius.Packet, addr net.Addr, conn *net.UDPConn, secret []byte) *packet { + return &packet{ + packet: p, + addr: addr, + conn: conn, + secret: secret, + requestID: newRequestID(), + callingStationID: string(p.Get(CallingStationIDType)), + } +} + +// HandlePacket runs PAP handling: optional OTP discovery, Keycloak authentication, and replies. +func (s *Server) HandlePacket(p *packet) { + if s.PAPEnabled { + log := s.withRequest(p) + kcCtx := keycloak.WithRequestID(context.Background(), p.requestID) + log.Debug("handling PAP", zap.String("address", p.addr.String())) + usernameRaw := p.packet.Get(1) // User-Name + passwordRaw := p.packet.Get(2) // User-Password + username := string(usernameRaw) + p.username = username + if len(passwordRaw) > 0 { + decrypted, err := radius.UserPassword(passwordRaw, p.secret, p.packet.Authenticator[:]) + if err != nil { + log.Debug("decrypt User-Password failed", zap.String("username", username), zap.Error(err)) + return + } + p.password = string(decrypted) + } + if len(username) == 0 && len(p.password) == 0 { + log.Debug("no username or password", zap.String("address", p.addr.String())) + return + } + + // Check if user has OTP assigned + hasOTP := false + if s.Keycloak != nil { + var err error + hasOTP, err = s.Keycloak.HasOTP(kcCtx, username) + log.Debug("user has OTP", zap.String("username", username), zap.Error(err)) + } + + if hasOTP { + if s.DisableChallenge { + s.handleDisableChallenge(p, kcCtx) + return + } + s.handleUserOTP(p, kcCtx) + } else { + s.handleUser(p, kcCtx) + } + } +} + +// handleDisableChallenge authenticates when challenge-response is disabled (password ends with 6-digit OTP). +func (s *Server) handleDisableChallenge(p *packet, kcCtx context.Context) { + log := s.withRequest(p) + if len(p.password) <= 6 { + log.Debug("password too short for OTP split", zap.String("username", p.username)) + resp := radius.New(radius.CodeAccessReject, p.secret) + s.writePAPResponse(p, resp) + return + } + + otp := p.password[len(p.password)-6:] + userPassword := p.password[:len(p.password)-6] + log.Debug("split password", zap.Int("len_password", len(userPassword)), zap.Int("len_otp", len(otp))) + ok, roles, err := s.Keycloak.AuthenticateUser(kcCtx, p.username, userPassword, otp) + otpOk := ok && err == nil + var resp *radius.Packet + if ok && otpOk { + log.Debug("PAP+OTP success", zap.String("username", p.username), zap.Any("roles", roles)) + resp = radius.New(radius.CodeAccessAccept, p.secret) + s.addScopeAttributes(resp, roles, p.requestID) + } else { + log.Debug("PAP+OTP failed", zap.String("username", p.username), zap.Error(err)) + resp = radius.New(radius.CodeAccessReject, p.secret) + } + s.writePAPResponse(p, resp) +} + +// handleUser performs a single-step password grant against Keycloak (no OTP path). +func (s *Server) handleUser(p *packet, kcCtx context.Context) { + log := s.withRequest(p) + ok, roles, err := s.Keycloak.AuthenticateUser(kcCtx, p.username, p.password) + var resp *radius.Packet + if ok { + log.Debug("PAP success", zap.String("username", p.username), zap.Any("roles", roles)) + resp = radius.New(radius.CodeAccessAccept, p.secret) + s.addScopeAttributes(resp, roles, p.requestID) + } else { + log.Debug("PAP failed", zap.String("username", p.username), zap.Error(err)) + resp = radius.New(radius.CodeAccessReject, p.secret) + } + s.writePAPResponse(p, resp) +} + +// handleUserOTP implements OTP via Access-Challenge or completes the second step using State. +func (s *Server) handleUserOTP(p *packet, kcCtx context.Context) { + log := s.withRequest(p) + otp := "" + stateRaw := p.packet.Get(24) // State attribute + log.Debug("state attribute", zap.String("state", string(stateRaw))) + if len(stateRaw) > 0 { + // Second step: validate OTP using stored state + state := string(stateRaw) + if s.ChallengeStateStore != nil { + sess, ok := s.ChallengeStateStore.Get(state) + if !ok { + log.Debug("not found challenge state, close", zap.String("state", state)) + return + } + log.Debug("found challenge state", zap.String("username", sess.Username)) + + otp = p.password // In challenge-response, password field contains OTP + ok, roles, err := s.Keycloak.AuthenticateUser(kcCtx, sess.Username, sess.Password, otp) + var resp *radius.Packet + if ok && err == nil { + resp = radius.New(radius.CodeAccessAccept, p.secret) + s.addScopeAttributes(resp, roles, p.requestID) + log.Debug("OTP challenge success", zap.String("username", sess.Username), zap.Any("roles", roles)) + } else { + resp = radius.New(radius.CodeAccessReject, p.secret) + log.Debug("OTP challenge failed", zap.String("username", sess.Username), zap.Error(err)) + } + s.writePAPResponse(p, resp) + s.ChallengeStateStore.Delete(state) + return + } + } else { + // Challenge-Response mode: send Access-Challenge for OTP + log.Debug("sending Access-Challenge", zap.String("username", p.username)) + // Store challenge state for this session + if s.ChallengeStateStore == nil { + s.ChallengeStateStore = NewChallengeStateStore() + } + state, err := GenerateRandomState() + if err != nil { + log.Warn("generate challenge state fail", zap.Error(err)) + return + } + s.ChallengeStateStore.Set(state, ChallengeSession{Username: p.username, Password: p.password}) + resp := radius.New(radius.CodeAccessChallenge, p.secret) + resp.Identifier = p.packet.Identifier + resp.Authenticator = p.packet.Authenticator + resp.Add(18, []byte(s.OTPChallengeMsg)) + resp.Add(24, []byte(state)) // State attribute + s.writePAPResponse(p, resp) + } +} diff --git a/radiussrv/msgauth.go b/radiussrv/msgauth.go new file mode 100644 index 0000000..bf9444d --- /dev/null +++ b/radiussrv/msgauth.go @@ -0,0 +1,261 @@ +package radiussrv + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/subtle" + "encoding/binary" + "errors" + "fmt" + + "go.uber.org/zap" + "layeh.com/radius" + "layeh.com/radius/rfc2869" +) + +const eapMessageAttrType = 79 + +var ( + errPacketTooShort = errors.New("radius: packet shorter than header") + errInvalidPacketLength = errors.New("radius: invalid packet length field") + errMalformedAttributes = errors.New("radius: malformed attribute list") + errMultipleMsgAuth = errors.New("radius: multiple Message-Authenticator attributes") + errMsgAuthLength = errors.New("radius: Message-Authenticator must be 18 octets (16 value)") + errMsgAuthNotFound = errors.New("radius: Message-Authenticator not found in response") + errEAPWithoutMsgAuth = errors.New("radius: EAP-Message present without Message-Authenticator (RFC 3579)") +) + +// VerifyMessageAuthenticator checks HMAC-MD5 integrity when attribute 80 is present, and enforces +// RFC 3579: any EAP-Message (79) requires Message-Authenticator. When absent and there is no EAP, +// verification succeeds (PAP-only clients often omit MA). +func VerifyMessageAuthenticator(packet []byte, secret []byte) error { + if len(secret) == 0 { + return fmt.Errorf("radius: empty shared secret") + } + n, err := radiusPacketLength(packet) + if err != nil { + return err + } + body := packet[:n] + + hasEAP, err := containsAttrType(body, 20, n, eapMessageAttrType) + if err != nil { + return err + } + offsets, err := messageAuthenticatorValueOffsets(body, 20, n) + if err != nil { + return err + } + switch len(offsets) { + case 0: + if hasEAP { + return errEAPWithoutMsgAuth + } + return nil + case 1: + off := offsets[0] + if off < 2 || body[off-2] != MessageAuthenticatorType || body[off-1] != 18 { + return errMsgAuthLength + } + var authForHMAC [16]byte + copy(authForHMAC[:], body[4:20]) + return verifyMessageAuthenticatorHMAC(n, body, secret, authForHMAC, off) + default: + return errMultipleMsgAuth + } +} + +// verifyMessageAuthenticatorHMAC checks RFC 2869 Message-Authenticator using the given +// 16-octet authenticator value that must appear at bytes 4–19 in the HMAC input (for +// Access-Request this is the on-wire Request Authenticator; for replies it is the Request +// Authenticator from the pending request, not the Response Authenticator on the wire). +func verifyMessageAuthenticatorHMAC(n int, body []byte, secret []byte, authenticatorForHMAC [16]byte, maValueOff int) error { + clone := make([]byte, n) + copy(clone, body[:n]) + copy(clone[4:20], authenticatorForHMAC[:]) + for i := 0; i < 16; i++ { + clone[maValueOff+i] = 0 + } + mac := hmac.New(md5.New, secret) + _, _ = mac.Write(clone) + sum := mac.Sum(nil) + if subtle.ConstantTimeCompare(sum[:16], body[maValueOff:maValueOff+16]) != 1 { + return fmt.Errorf("radius: Message-Authenticator mismatch") + } + return nil +} + +// finalizeMessageAuthenticatorInPlace sets Message-Authenticator on an **Access-Request** (or +// any packet where Encode does not overwrite the authenticator field). Do not use this for +// Access-Accept/Reject/Challenge: RFC 2869 §5.14 requires Message-Authenticator to be present +// before the Response Authenticator is calculated, so use encodeAccessReplyWithMessageAuthenticator. +func finalizeMessageAuthenticatorInPlace(wire []byte, secret []byte, requestAuthenticator [16]byte) error { + if len(secret) == 0 { + return fmt.Errorf("radius: empty shared secret") + } + n, err := radiusPacketLength(wire) + if err != nil { + return err + } + if n > len(wire) { + return errInvalidPacketLength + } + offsets, err := messageAuthenticatorValueOffsets(wire, 20, n) + if err != nil { + return err + } + if len(offsets) != 1 { + return errMsgAuthNotFound + } + off := offsets[0] + if off < 2 || wire[off-2] != MessageAuthenticatorType || wire[off-1] != 18 { + return errMsgAuthLength + } + for i := 0; i < 16; i++ { + if wire[off+i] != 0 { + return fmt.Errorf("radius: Message-Authenticator must be zero before finalize") + } + } + sum := messageAuthenticatorHMACSum(wire[:n], secret, requestAuthenticator, off) + copy(wire[off:off+16], sum) + return nil +} + +// encodeAccessReplyWithMessageAuthenticator marshals Access-Accept, Access-Reject, or +// Access-Challenge, fills Message-Authenticator (RFC 2869: HMAC uses Request Authenticator in +// bytes 4–19 of the HMAC input), then computes the Response Authenticator (RFC 2865) over +// attributes **including** the final Message-Authenticator. This order matches RFC 2869 +// (“inserted … before the Response Authenticator is calculated”) and FreeRADIUS/radtest. +func encodeAccessReplyWithMessageAuthenticator(resp *radius.Packet, requestAuthenticator [16]byte) ([]byte, error) { + if len(resp.Secret) == 0 { + return nil, fmt.Errorf("radius: empty secret") + } + switch resp.Code { + case radius.CodeAccessAccept, radius.CodeAccessReject, radius.CodeAccessChallenge: + default: + return nil, fmt.Errorf("radius: unsupported code %v for Message-Authenticator reply", resp.Code) + } + b, err := resp.MarshalBinary() + if err != nil { + return nil, err + } + n := len(b) + offsets, err := messageAuthenticatorValueOffsets(b, 20, n) + if err != nil { + return nil, err + } + if len(offsets) != 1 { + return nil, errMsgAuthNotFound + } + off := offsets[0] + if off < 2 || b[off-2] != MessageAuthenticatorType || b[off-1] != 18 { + return nil, errMsgAuthLength + } + for i := 0; i < 16; i++ { + if b[off+i] != 0 { + return nil, fmt.Errorf("radius: Message-Authenticator must be zero before signing") + } + } + sum := messageAuthenticatorHMACSum(b, resp.Secret, requestAuthenticator, off) + copy(b[off:off+16], sum) + + hash := md5.New() + hash.Write(b[:4]) + hash.Write(requestAuthenticator[:]) + hash.Write(b[20:]) + hash.Write(resp.Secret) + hash.Sum(b[4:4:20]) + return b, nil +} + +func messageAuthenticatorHMACSum(body []byte, secret []byte, requestAuthenticator [16]byte, maValueOff int) []byte { + n := len(body) + clone := make([]byte, n) + copy(clone, body) + copy(clone[4:20], requestAuthenticator[:]) + for i := 0; i < 16; i++ { + clone[maValueOff+i] = 0 + } + mac := hmac.New(md5.New, secret) + _, _ = mac.Write(clone) + return mac.Sum(nil)[:16] +} + +func radiusPacketLength(packet []byte) (int, error) { + if len(packet) < 20 { + return 0, errPacketTooShort + } + n := int(binary.BigEndian.Uint16(packet[2:4])) + if n < 20 || n > radius.MaxPacketLength { + return 0, errInvalidPacketLength + } + return n, nil +} + +func containsAttrType(packet []byte, start, end int, want byte) (bool, error) { + for pos := start; pos < end; { + if pos+2 > end { + return false, errMalformedAttributes + } + typ := packet[pos] + attrLen := int(packet[pos+1]) + if attrLen < 2 || pos+attrLen > end { + return false, errMalformedAttributes + } + if typ == want { + return true, nil + } + pos += attrLen + } + return false, nil +} + +// messageAuthenticatorValueOffsets returns the start index of the 16-byte Message-Authenticator +// value within each matching AVP (type 80, length 18). +func messageAuthenticatorValueOffsets(packet []byte, start, end int) ([]int, error) { + var out []int + for pos := start; pos < end; { + if pos+2 > end { + return nil, errMalformedAttributes + } + typ := packet[pos] + attrLen := int(packet[pos+1]) + if attrLen < 2 || pos+attrLen > end { + return nil, errMalformedAttributes + } + if typ == MessageAuthenticatorType { + if attrLen != 18 { + return nil, errMsgAuthLength + } + out = append(out, pos+2) + } + pos += attrLen + } + return out, nil +} + +func (s *Server) writePAPResponse(p *packet, resp *radius.Packet) { + log := s.withRequest(p) + resp.Identifier = p.packet.Identifier + resp.Authenticator = p.packet.Authenticator + + if !s.DisableMsgAuth { + if err := rfc2869.MessageAuthenticator_Add(resp, make([]byte, 16)); err != nil { + log.Debug("message-authenticator add failed", zap.Error(err)) + return + } + } + + var b []byte + var err error + if s.DisableMsgAuth { + b, err = resp.Encode() + } else { + b, err = encodeAccessReplyWithMessageAuthenticator(resp, p.packet.Authenticator) + } + if err != nil { + log.Debug("radius response encode failed", zap.Error(err)) + return + } + _, _ = p.conn.WriteTo(b, p.addr) +} diff --git a/radiussrv/msgauth_test.go b/radiussrv/msgauth_test.go new file mode 100644 index 0000000..5e152f5 --- /dev/null +++ b/radiussrv/msgauth_test.go @@ -0,0 +1,125 @@ +package radiussrv + +import ( + "errors" + "testing" + + "layeh.com/radius" + "layeh.com/radius/rfc2865" + "layeh.com/radius/rfc2869" +) + +func TestVerifyMessageAuthenticator_requestSignedRoundTrip(t *testing.T) { + secret := []byte("xyzzy5461") + p := radius.New(radius.CodeAccessRequest, secret) + if err := rfc2865.UserName_AddString(p, "alice"); err != nil { + t.Fatal(err) + } + if err := rfc2869.MessageAuthenticator_Add(p, make([]byte, 16)); err != nil { + t.Fatal(err) + } + wire, err := p.Encode() + if err != nil { + t.Fatal(err) + } + if err := finalizeMessageAuthenticatorInPlace(wire, secret, p.Authenticator); err != nil { + t.Fatal(err) + } + if err := VerifyMessageAuthenticator(wire, secret); err != nil { + t.Fatal(err) + } +} + +func TestVerifyMessageAuthenticator_EAPWithoutMA(t *testing.T) { + secret := []byte("secret") + p := radius.New(radius.CodeAccessRequest, secret) + if err := rfc2865.UserName_AddString(p, "u"); err != nil { + t.Fatal(err) + } + p.Add(eapMessageAttrType, []byte{1, 2, 3}) + wire, err := p.Encode() + if err != nil { + t.Fatal(err) + } + err = VerifyMessageAuthenticator(wire, secret) + if !errors.Is(err, errEAPWithoutMsgAuth) { + t.Fatalf("got %v want %v", err, errEAPWithoutMsgAuth) + } +} + +func TestVerifyMessageAuthenticator_wrongSecret(t *testing.T) { + secret := []byte("correctsecret!!") + p := radius.New(radius.CodeAccessRequest, secret) + if err := rfc2869.MessageAuthenticator_Add(p, make([]byte, 16)); err != nil { + t.Fatal(err) + } + wire, err := p.Encode() + if err != nil { + t.Fatal(err) + } + if err := finalizeMessageAuthenticatorInPlace(wire, secret, p.Authenticator); err != nil { + t.Fatal(err) + } + if err := VerifyMessageAuthenticator(wire, []byte("wrongsecret!!!!")); err == nil { + t.Fatal("expected mismatch error") + } +} + +func TestEncodeAccessReplyWithMessageAuthenticator_accessAccept(t *testing.T) { + secret := []byte("testsecret12!!") + req := radius.New(radius.CodeAccessRequest, secret) + reqWire, err := req.Encode() + if err != nil { + t.Fatal(err) + } + reqAuth := req.Authenticator + + resp := radius.New(radius.CodeAccessAccept, secret) + resp.Identifier = req.Identifier + resp.Authenticator = reqAuth + if err := rfc2869.MessageAuthenticator_Add(resp, make([]byte, 16)); err != nil { + t.Fatal(err) + } + wire, err := encodeAccessReplyWithMessageAuthenticator(resp, reqAuth) + if err != nil { + t.Fatal(err) + } + n := len(wire) + offsets, err := messageAuthenticatorValueOffsets(wire, 20, n) + if err != nil || len(offsets) != 1 { + t.Fatalf("offsets: %v err %v", offsets, err) + } + if err := verifyMessageAuthenticatorHMAC(n, wire[:n], secret, reqAuth, offsets[0]); err != nil { + t.Fatal(err) + } + if !radius.IsAuthenticResponse(wire, reqWire, secret) { + t.Fatal("IsAuthenticResponse failed") + } +} + +func TestVerifyMessageAuthenticator_tampered(t *testing.T) { + secret := []byte("xyzzy5461") + p := radius.New(radius.CodeAccessRequest, secret) + if err := rfc2869.MessageAuthenticator_Add(p, make([]byte, 16)); err != nil { + t.Fatal(err) + } + wire, err := p.Encode() + if err != nil { + t.Fatal(err) + } + if err := finalizeMessageAuthenticatorInPlace(wire, secret, p.Authenticator); err != nil { + t.Fatal(err) + } + n, err := radiusPacketLength(wire) + if err != nil { + t.Fatal(err) + } + offsets, err := messageAuthenticatorValueOffsets(wire, 20, n) + if err != nil || len(offsets) != 1 { + t.Fatalf("offsets: %v err %v", offsets, err) + } + wire[offsets[0]] ^= 0xff + if err := VerifyMessageAuthenticator(wire, secret); err == nil { + t.Fatal("expected verify error") + } +} diff --git a/radiussrv/server.go b/radiussrv/server.go index 7ac3557..d8135f3 100644 --- a/radiussrv/server.go +++ b/radiussrv/server.go @@ -1,296 +1,51 @@ +// Package radiussrv implements a UDP RADIUS authentication server with PAP, optional OTP +// challenge flow, and Keycloak-backed credential checks. package radiussrv import ( - "crypto/md5" - "crypto/tls" - "encoding/binary" - "fmt" - "log" "net" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" + "go.uber.org/zap" "layeh.com/radius" - "keyrad/auth" "keyrad/keycloak" ) const ( + // MessageAuthenticatorType is the RADIUS attribute type for Message-Authenticator (RFC 3579). MessageAuthenticatorType = 80 + // CallingStationIDType is Calling-Station-Id (RFC 2865). + CallingStationIDType = 31 + workerCount = 8 ) +// Server wires Keycloak, RADIUS clients, scope-to-attribute mapping, and OTP challenge state. type Server struct { Keycloak *keycloak.KeycloakAPI - Clients map[string]auth.ClientConfig - ScopeRadiusMap auth.ScopeRadiusMapping + Clients map[string]ClientConfig + ScopeRadiusMap ScopeRadiusMapping + scopeRules []compiledScopeRule OTPChallengeMsg string DisableMsgAuth bool DisableChallenge bool - Debug bool PAPEnabled bool ChallengeStateStore *ChallengeStateStore -} + Logger *zap.Logger -// addScopeAttributes adds RADIUS attributes (standard and vendor-specific) to the -// response packet based on the user's roles/groups/scopes matched against the scope_radius_map. -func (s *Server) addScopeAttributes(resp *radius.Packet, userRoles []string) { - if s.ScopeRadiusMap == nil || len(userRoles) == 0 { - return - } - for scopeKey, attrs := range s.ScopeRadiusMap { - matched := false - if strings.HasPrefix(scopeKey, "re:") { - pattern := strings.TrimPrefix(scopeKey, "re:") - re, err := regexp.Compile(pattern) - if err != nil { - if s.Debug { - log.Printf("[DEBUG] Invalid regex in scope_radius_map: %s: %v", pattern, err) - } - continue - } - for _, role := range userRoles { - if re.MatchString(role) { - matched = true - break - } - } - } else { - for _, role := range userRoles { - if role == scopeKey { - matched = true - break - } - } - } - if !matched { - continue - } - for _, attr := range attrs { - value := encodeAttributeValue(attr.Value, attr.ValueType) - if attr.Vendor > 0 { - // Vendor-Specific Attribute: encode as sub-attribute TLV - subAttr := make([]byte, 2+len(value)) - subAttr[0] = byte(attr.Attribute) - subAttr[1] = byte(2 + len(value)) - copy(subAttr[2:], value) - vsa, err := radius.NewVendorSpecific(attr.Vendor, radius.Attribute(subAttr)) - if err != nil { - if s.Debug { - log.Printf("[DEBUG] Failed to encode VSA vendor=%d attr=%d: %v", attr.Vendor, attr.Attribute, err) - } - continue - } - resp.Add(26, vsa) - if s.Debug { - log.Printf("[DEBUG] Added VSA: vendor=%d, attribute=%d, value=%s", attr.Vendor, attr.Attribute, attr.Value) - } - } else { - resp.Add(radius.Type(attr.Attribute), value) - if s.Debug { - log.Printf("[DEBUG] Added attribute: type=%d, value=%s", attr.Attribute, attr.Value) - } - } - } - } + jobs chan request } -func encodeAttributeValue(value, valueType string) []byte { - switch valueType { - case "integer": - n, err := strconv.ParseUint(value, 10, 32) - if err != nil { - return []byte(value) - } - b := make([]byte, 4) - binary.BigEndian.PutUint32(b, uint32(n)) - return b - case "ipaddr": - ip := net.ParseIP(value) - if ip != nil { - if ip4 := ip.To4(); ip4 != nil { - return []byte(ip4) - } - } - return []byte(value) - default: - return []byte(value) - } -} - -func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UDPConn, secret []byte) { - if s.PAPEnabled { - if s.Debug { - log.Printf("[DEBUG] Handling PAP for %v", addr) - } - usernameRaw := packet.Get(1) // User-Name - passwordRaw := packet.Get(2) // User-Password - username := string(usernameRaw) - var password string - if len(passwordRaw) > 0 { - decrypted, err := decryptUserPassword(passwordRaw, packet.Authenticator[:], secret) - if err != nil { - if s.Debug { - log.Printf("[DEBUG] Failed to decrypt User-Password for %s: %v", username, err) - } - return - } - password = string(decrypted) - } - if len(username) > 0 && len(password) > 0 { - // Check if user has OTP assigned - hasOTP := false - if s.Keycloak != nil { - var err error - hasOTP, err = s.Keycloak.HasOTP(username) - if s.Debug { - log.Printf("[DEBUG] User %s has OTP: %v (err: %v)", username, hasOTP, err) - } - } - if hasOTP { - otp := "" - userPassword := password - stateRaw := packet.Get(24) // State attribute - if s.Debug { - log.Printf("[DEBUG] State attribute: %s", string(stateRaw)) - } - if !s.DisableChallenge && len(stateRaw) > 0 { - // Second step: validate OTP using stored state - state := string(stateRaw) - if s.ChallengeStateStore != nil { - sess, ok := s.ChallengeStateStore.Get(state) - if ok { - if s.Debug { - log.Printf("[DEBUG] Found challenge state for %s", sess.Username) - } - otp = password // In challenge-response, password field contains OTP - ok, roles, err := s.Keycloak.AuthenticateUser(sess.Username, sess.Password, otp) - var resp *radius.Packet - if ok && err == nil { - resp = radius.New(radius.CodeAccessAccept, secret) - s.addScopeAttributes(resp, roles) - if s.Debug { - log.Printf("[DEBUG] OTP challenge success for %s (roles: %v)", sess.Username, roles) - } - } else { - resp = radius.New(radius.CodeAccessReject, secret) - if s.Debug { - log.Printf("[DEBUG] OTP challenge failed for %s: %v", sess.Username, err) - } - } - resp.Identifier = packet.Identifier - resp.Authenticator = packet.Authenticator - b, err := resp.Encode() - if err == nil { - conn.WriteTo(b, addr) - } - s.ChallengeStateStore.Delete(state) - return - } - } - } - if s.Debug { - log.Printf("[DEBUG] DisableChallenge: %v", s.DisableChallenge) - } - if s.DisableChallenge { - if len(password) <= 6 { - if s.Debug { - log.Printf("[DEBUG] Password too short for OTP split for %s", username) - } - resp := radius.New(radius.CodeAccessReject, secret) - resp.Identifier = packet.Identifier - resp.Authenticator = packet.Authenticator - b, err := resp.Encode() - if err == nil { - conn.WriteTo(b, addr) - } - return - } - otp = password[len(password)-6:] - userPassword = password[:len(password)-6] - if s.Debug { - log.Printf("[DEBUG] Split password length: %d, otp length: %d", len(userPassword), len(otp)) - } - ok, roles, err := s.Keycloak.AuthenticateUser(username, userPassword, otp) - otpOk := ok && err == nil - var resp *radius.Packet - if ok && otpOk { - if s.Debug { - log.Printf("[DEBUG] PAP+OTP success for %s (Keycloak, roles: %v)", username, roles) - } - resp = radius.New(radius.CodeAccessAccept, secret) - s.addScopeAttributes(resp, roles) - } else { - if s.Debug { - log.Printf("[DEBUG] PAP+OTP failed for %s: %v", username, err) - } - resp = radius.New(radius.CodeAccessReject, secret) - } - resp.Identifier = packet.Identifier - resp.Authenticator = packet.Authenticator - b, err := resp.Encode() - if err == nil { - conn.WriteTo(b, addr) - } - return - } else { - // Challenge-Response mode: send Access-Challenge for OTP - if s.Debug { - log.Printf("[DEBUG] Sending Access-Challenge for OTP to %s", username) - } - // Store challenge state for this session - if s.ChallengeStateStore == nil { - s.ChallengeStateStore = NewChallengeStateStore() - } - state, err := GenerateRandomState() - if err != nil { - log.Printf("Failed to generate challenge state: %v", err) - return - } - s.ChallengeStateStore.Set(state, ChallengeSession{Username: username, Password: password}) - resp := radius.New(radius.CodeAccessChallenge, secret) - resp.Identifier = packet.Identifier - resp.Authenticator = packet.Authenticator - resp.Add(18, []byte(s.OTPChallengeMsg)) - resp.Add(24, []byte(state)) // State attribute - b, err := resp.Encode() - if err == nil { - conn.WriteTo(b, addr) - } - return - } - } else { - // Standard PAP (no OTP) - ok, roles, err := s.Keycloak.AuthenticateUser(username, password) - var resp *radius.Packet - if ok { - if s.Debug { - log.Printf("[DEBUG] PAP success for %s (Keycloak, roles: %v)", username, roles) - } - resp = radius.New(radius.CodeAccessAccept, secret) - s.addScopeAttributes(resp, roles) - } else { - if s.Debug { - log.Printf("[DEBUG] PAP failed for %s: %v", username, err) - } - resp = radius.New(radius.CodeAccessReject, secret) - } - resp.Identifier = packet.Identifier - resp.Authenticator = packet.Authenticator - b, err := resp.Encode() - if err == nil { - conn.WriteTo(b, addr) - } - return - } - } - } +type request struct { + packetData []byte + remoteAddr net.Addr + conn *net.UDPConn } +// ListenAndServe binds a UDP socket on listenAddr, compiles scope rules, and blocks +// serving RADIUS Access-Request packets until the listener returns an error. func (s *Server) ListenAndServe(listenAddr string) error { + s.compileScopeRules() + s.jobs = make(chan request, 128) udpAddr, err := net.ResolveUDPAddr("udp", listenAddr) if err != nil { return err @@ -301,131 +56,60 @@ func (s *Server) ListenAndServe(listenAddr string) error { } defer conn.Close() - const workerCount = 8 - type radiusJob struct { - packetData []byte - remoteAddr net.Addr - } - jobs := make(chan radiusJob, 128) - for i := 0; i < workerCount; i++ { - go func() { - for job := range jobs { - // Find the correct secret for the remote address - var secret string - udpAddr, ok := job.remoteAddr.(*net.UDPAddr) - if !ok { - continue - } - for key, cfg := range s.Clients { - if s.Debug { - log.Printf("[DEBUG] Checking client key: %s", key) - } - // Try as CIDR - _, ipnet, err := net.ParseCIDR(key) - if err == nil && ipnet.Contains(udpAddr.IP) { - secret = cfg.Secret - if s.Debug { - log.Printf("[DEBUG] Matched CIDR %s for %s", key, udpAddr.IP) - } - break - } - // Try as single IP - if net.ParseIP(key) != nil && net.ParseIP(key).Equal(udpAddr.IP) { - secret = cfg.Secret - if s.Debug { - log.Printf("[DEBUG] Matched IP %s for %s", key, udpAddr.IP) - } - break - } - } - if secret == "" { - if s.Debug { - log.Printf("Rejected packet from unauthorized client: %v", job.remoteAddr) - } - continue - } - packet, err := radius.Parse(job.packetData, []byte(secret)) - if err != nil { - if s.Debug { - log.Printf("Failed to parse RADIUS packet: %v", err) - } - continue - } - s.HandlePacket(packet, job.remoteAddr, conn, []byte(secret)) - } - }() + go s.worker() } buf := make([]byte, 4096) for { n, remoteAddr, err := conn.ReadFrom(buf) if err != nil { - if s.Debug { - log.Printf("Error reading from UDP: %v", err) - } + s.Logger.Debug("error reading from UDP connection", zap.Error(err)) continue } packetCopy := make([]byte, n) copy(packetCopy, buf[:n]) - jobs <- radiusJob{packetData: packetCopy, remoteAddr: remoteAddr} + s.jobs <- request{packetData: packetCopy, remoteAddr: remoteAddr, conn: conn} } } -func GetHTTPClient(insecureSkipTLSVerify bool) *http.Client { - if insecureSkipTLSVerify { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, +// worker pulls UDP jobs from the queue, resolves the client secret, parses the packet, and dispatches HandlePacket. +func (s *Server) worker() { + for job := range s.jobs { + // Find the correct secret for the remote address + var secret string + udpAddr, ok := job.remoteAddr.(*net.UDPAddr) + if !ok { + continue } - return &http.Client{Transport: tr} - } - return http.DefaultClient -} -func decryptUserPassword(encrypted, authenticator, secret []byte) ([]byte, error) { - if len(encrypted)%16 != 0 { - return nil, fmt.Errorf("invalid encrypted User-Password length") - } - b := make([]byte, len(encrypted)) - last := authenticator - for i := 0; i < len(encrypted); i += 16 { - h := md5.New() - h.Write(secret) - h.Write(last) - xor := h.Sum(nil) - for j := 0; j < 16; j++ { - b[i+j] = encrypted[i+j] ^ xor[j] + secret = resolveClientSecret(s.Clients, udpAddr.IP) + if secret != "" { + s.Logger.Debug("matched client", zap.String("ip", udpAddr.IP.String())) } - last = encrypted[i : i+16] - } - // Remove padding - for i := len(b) - 1; i >= 0; i-- { - if b[i] == 0 { - b = b[:i] - } else { - break + + if secret == "" { + s.Logger.Debug("no secret found", zap.String("ip", job.remoteAddr.String())) + continue } - } - return b, nil -} -// ValidateOTP now calls Keycloak with the 'totp' parameter -func ValidateOTP(username, otp string, kc *keycloak.KeycloakAPI) bool { - data := url.Values{} - data.Set("grant_type", "password") - data.Set("client_id", kc.ClientID) - data.Set("client_secret", kc.ClientSecret) - data.Set("username", username) - data.Set("password", "dummy") // password is not used, but must be present - data.Set("totp", otp) - resp, err := kc.HTTPClient.PostForm(kc.TokenURL, data) - if err != nil { - return false - } - defer resp.Body.Close() - if resp.StatusCode == 200 { - return true + reqPacket, err := radius.Parse(job.packetData, []byte(secret)) + if err != nil { + s.Logger.Debug("error parsing packet", zap.Error(err)) + continue + } + + if !s.DisableMsgAuth { + if err := VerifyMessageAuthenticator(job.packetData, []byte(secret)); err != nil { + s.Logger.Debug("message-authenticator verification failed", + zap.String("ip", udpAddr.IP.String()), + zap.Error(err)) + continue + } + } + + pkt := newPacket(reqPacket, job.remoteAddr, job.conn, []byte(secret)) + s.withRequest(pkt).Debug("RADIUS packet parsed, dispatching") + s.HandlePacket(pkt) } - return false } - diff --git a/auth/types.go b/radiussrv/types.go similarity index 80% rename from auth/types.go rename to radiussrv/types.go index f07e55b..7b4235e 100644 --- a/auth/types.go +++ b/radiussrv/types.go @@ -1,4 +1,4 @@ -package auth +package radiussrv import ( "bufio" @@ -7,12 +7,14 @@ import ( "strings" ) +// ClientConfig holds shared secret and display metadata for one RADIUS client entry. type ClientConfig struct { Secret string ShortName string IPAddr string } +// RadiusAttribute describes one attribute to add to an Access-Accept when scopes match. type RadiusAttribute struct { Vendor uint32 `yaml:"vendor"` // Vendor ID (0 = standard attribute) Attribute int `yaml:"attribute"` // Attribute type number @@ -20,8 +22,11 @@ type RadiusAttribute struct { ValueType string `yaml:"value_type"` // "string" (default), "integer", "ipaddr" } +// ScopeRadiusMapping maps a scope name, group name, or regex pattern (prefix "re:") to RADIUS attributes. type ScopeRadiusMapping map[string][]RadiusAttribute +// ParseClientsConf reads a FreeRADIUS-style clients.conf file and returns a map keyed by +// ipaddr when set, otherwise by the client block name. func ParseClientsConf(path string) (map[string]ClientConfig, error) { file, err := os.Open(path) if err != nil { diff --git a/radiussrv/types_test.go b/radiussrv/types_test.go new file mode 100644 index 0000000..9c39b67 --- /dev/null +++ b/radiussrv/types_test.go @@ -0,0 +1,66 @@ +package radiussrv + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseClientsConf(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clients.conf") + content := ` +client nas1 { + secret = s3cret + shortname = nas-one + ipaddr = 192.0.2.10 +} +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + clients, err := ParseClientsConf(path) + if err != nil { + t.Fatalf("ParseClientsConf: %v", err) + } + c, ok := clients["192.0.2.10"] + if !ok { + t.Fatalf("expected key 192.0.2.10, got keys %#v", clients) + } + if c.Secret != "s3cret" { + t.Fatalf("secret: got %q", c.Secret) + } + if c.ShortName != "nas-one" { + t.Fatalf("shortname: got %q", c.ShortName) + } + if c.IPAddr != "192.0.2.10" { + t.Fatalf("ipaddr: got %q", c.IPAddr) + } +} + +func TestParseClientsConf_UsesClientNameWhenNoIP(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clients.conf") + if err := os.WriteFile(path, []byte(` +client legacy { + secret = x +} +`), 0o600); err != nil { + t.Fatal(err) + } + clients, err := ParseClientsConf(path) + if err != nil { + t.Fatal(err) + } + if _, ok := clients["legacy"]; !ok { + t.Fatalf("expected key legacy, got %#v", clients) + } +} + +func TestParseClientsConf_MissingFile(t *testing.T) { + _, err := ParseClientsConf(filepath.Join(t.TempDir(), "nope.conf")) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/radiussrv/utils.go b/radiussrv/utils.go new file mode 100644 index 0000000..bab8367 --- /dev/null +++ b/radiussrv/utils.go @@ -0,0 +1,59 @@ +package radiussrv + +import ( + "crypto/rand" + "encoding/hex" + "net" + "strconv" + "time" + + "go.uber.org/zap" +) + +func newRequestID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(b[:]) +} + +// withRequest returns a logger that includes the packet's request_id field, or the server logger if absent. +func (s *Server) withRequest(p *packet) *zap.Logger { + if s.Logger == nil { + return zap.NewNop() + } + if p == nil || p.requestID == "" { + return s.Logger + } + log := s.Logger.With(zap.String("request_id", p.requestID)) + if p.callingStationID != "" { + log = log.With(zap.String("calling_station_id", p.callingStationID)) + } + return log +} + +// resolveClientSecret picks the shared secret for ip: an exact ipaddr key wins; otherwise +// the most specific matching CIDR (longest prefix) is used so map iteration order cannot +// pick a wrong secret when several clients overlap. +func resolveClientSecret(clients map[string]ClientConfig, ip net.IP) string { + for key, cfg := range clients { + if host := net.ParseIP(key); host != nil && host.Equal(ip) { + return cfg.Secret + } + } + var bestSecret string + bestOnes := -1 + for key, cfg := range clients { + _, ipnet, err := net.ParseCIDR(key) + if err != nil || !ipnet.Contains(ip) { + continue + } + ones, _ := ipnet.Mask.Size() + if ones > bestOnes { + bestOnes = ones + bestSecret = cfg.Secret + } + } + return bestSecret +} diff --git a/radiussrv/utils_test.go b/radiussrv/utils_test.go new file mode 100644 index 0000000..e4e8790 --- /dev/null +++ b/radiussrv/utils_test.go @@ -0,0 +1,66 @@ +package radiussrv + +import ( + "bytes" + "net" + "testing" + + "go.uber.org/zap" + "layeh.com/radius" +) + +func TestNewPacket_RequestID(t *testing.T) { + p := radius.New(radius.CodeAccessRequest, []byte("secret")) + p.Identifier = 7 + p.Authenticator = [16]byte(bytes.Repeat([]byte{1}, 16)) + p.Add(CallingStationIDType, []byte("AA-BB-CC-DD-EE-FF")) + pkt := newPacket(p, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1812}, nil, []byte("secret")) + if len(pkt.requestID) != 16 { + t.Fatalf("request_id hex length: got %d want 16", len(pkt.requestID)) + } + if pkt.callingStationID != "AA-BB-CC-DD-EE-FF" { + t.Fatalf("calling_station_id: got %q", pkt.callingStationID) + } +} + +func TestWithRequest(t *testing.T) { + s := &Server{Logger: zap.NewExample()} + p := &packet{requestID: "abc123"} + s.withRequest(p).Info("x") + s.withRequest(nil).Info("y") + + s2 := &Server{Logger: nil} + if s2.withRequest(p) == nil { + t.Fatal("expected non-nil logger") + } +} + +func TestUserPassword_InvalidLength(t *testing.T) { + _, err := radius.UserPassword([]byte{1, 2, 3}, []byte("s"), bytes.Repeat([]byte{1}, 16)) + if err == nil { + t.Fatal("expected error") + } +} + +func TestResolveClientSecret_ExactIPWinsOverBroadCIDR(t *testing.T) { + ip := net.ParseIP("10.0.0.5") + clients := map[string]ClientConfig{ + "10.0.0.0/8": {Secret: "broad"}, + "10.0.0.5": {Secret: "exact"}, + "10.0.0.0/24": {Secret: "medium"}, + } + if got := resolveClientSecret(clients, ip); got != "exact" { + t.Fatalf("got %q want exact", got) + } +} + +func TestResolveClientSecret_MostSpecificCIDR(t *testing.T) { + ip := net.ParseIP("10.0.0.5") + clients := map[string]ClientConfig{ + "10.0.0.0/8": {Secret: "broad"}, + "10.0.0.0/24": {Secret: "narrow"}, + } + if got := resolveClientSecret(clients, ip); got != "narrow" { + t.Fatalf("got %q want narrow", got) + } +}