Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 978334a4bc |
+210
-17
@@ -12,12 +12,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/config"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/identity"
|
"git.cloudinit.dev/coreci/orca/internal/identity"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
var authCmd = &cobra.Command{
|
var authCmd = &cobra.Command{
|
||||||
@@ -144,31 +149,47 @@ password-free upstream authenticator.
|
|||||||
Traefik-served cluster domain; C-38).`,
|
Traefik-served cluster domain; C-38).`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if authInitRPID == "" {
|
return runAuthInitIDP(cmd, args)
|
||||||
return fmt.Errorf("--rp-id is required (the cluster's Traefik-served domain for WebAuthn)")
|
|
||||||
}
|
|
||||||
// The full Dex deploy is a systemd unit + Traefik route + config
|
|
||||||
// template. For v0.12 P04 we emit the config + unit files; the
|
|
||||||
// WebAuthn connector ships in P05.
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "Dex bootstrap planned for RP ID: %s\n", authInitRPID)
|
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), "Note: full Dex systemd unit + Traefik route deploy is part of P05 (WebAuthn connector).")
|
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), "This stub confirms the CLI surface; the deploy logic lands with the connector.")
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOIDCConfig loads the OIDC config from flags or the cluster config.
|
// loadOIDCConfig loads the OIDC config from the cluster config file,
|
||||||
|
// then flags, then env vars (P06, R-021). The bundled Dex (deployed by
|
||||||
|
// 'orca auth init-idp') is the default issuer; an explicit oidc.issuer
|
||||||
|
// in the config repoints the CLI to a BYO external IdP.
|
||||||
func loadOIDCConfig() (*identity.OIDCConfig, error) {
|
func loadOIDCConfig() (*identity.OIDCConfig, error) {
|
||||||
cfg := &identity.OIDCConfig{
|
cfg := &identity.OIDCConfig{
|
||||||
Issuer: authIssuer,
|
Issuer: authIssuer,
|
||||||
ClientID: authClientID,
|
ClientID: authClientID,
|
||||||
ClientSecret: authClientSecret,
|
ClientSecret: authClientSecret,
|
||||||
}
|
}
|
||||||
|
// Try config file first (oidc block + cluster_domain).
|
||||||
|
if fileCfg, err := config.Load(paths.ConfigPath()); err == nil && fileCfg != nil {
|
||||||
|
if fileCfg.OIDC != nil {
|
||||||
|
if cfg.Issuer == "" && fileCfg.OIDC.Issuer != "" {
|
||||||
|
cfg.Issuer = fileCfg.OIDC.Issuer
|
||||||
|
}
|
||||||
|
if cfg.ClientID == "" && fileCfg.OIDC.ClientID != "" {
|
||||||
|
cfg.ClientID = fileCfg.OIDC.ClientID
|
||||||
|
}
|
||||||
|
if cfg.ClientSecret == "" && fileCfg.OIDC.ClientSecret != "" {
|
||||||
|
cfg.ClientSecret = fileCfg.OIDC.ClientSecret
|
||||||
|
}
|
||||||
|
if len(cfg.Scopes) == 0 && len(fileCfg.OIDC.Scopes) > 0 {
|
||||||
|
cfg.Scopes = fileCfg.OIDC.Scopes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default issuer from cluster domain (bundled Dex).
|
||||||
|
if cfg.Issuer == "" && fileCfg.ClusterDomain != "" {
|
||||||
|
cfg.Issuer = "https://" + fileCfg.ClusterDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Env var fallback.
|
||||||
if cfg.Issuer == "" {
|
if cfg.Issuer == "" {
|
||||||
// TODO: load from cluster config (oidc block). For v0.12 P04
|
cfg.Issuer = os.Getenv("ORCA_OIDC_ISSUER")
|
||||||
// the flags are the primary path; config-file loading lands
|
}
|
||||||
// with the full Dex deploy (P05).
|
if cfg.Issuer == "" {
|
||||||
return nil, fmt.Errorf("auth: --issuer is required (or set oidc.issuer in config)")
|
return nil, fmt.Errorf("auth: --issuer is required (or set oidc.issuer in config, or deploy via 'orca auth init-idp')")
|
||||||
}
|
}
|
||||||
if cfg.ClientID == "" {
|
if cfg.ClientID == "" {
|
||||||
cfg.ClientID = "orca-cli"
|
cfg.ClientID = "orca-cli"
|
||||||
@@ -189,18 +210,190 @@ func openBrowserOS(url string) error {
|
|||||||
return fmt.Errorf("unsupported OS for browser open: %s", runtime.GOOS)
|
return fmt.Errorf("unsupported OS for browser open: %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runAuthInitIDP deploys the bundled Dex OIDC provider as a systemd
|
||||||
|
// unit + Traefik dynamic route on the lead node (P06, REQ-155, C-38).
|
||||||
|
// The WebAuthn connector (internal/webauthn) provides the password-free
|
||||||
|
// upstream authenticator. Atomic deploy with rollback.
|
||||||
|
func runAuthInitIDP(cmd *cobra.Command, args []string) error {
|
||||||
|
if authInitRPID == "" {
|
||||||
|
return fmt.Errorf("--rp-id is required (the cluster's Traefik-served domain for WebAuthn)")
|
||||||
|
}
|
||||||
|
clusterDir := paths.ClusterDir()
|
||||||
|
dexConfigPath := filepath.Join(clusterDir, "dex.yaml")
|
||||||
|
dexUnitPath := "/etc/systemd/system/orca-dex.service"
|
||||||
|
traefikDynamicDir := "/etc/traefik/dynamic"
|
||||||
|
traefikRoutePath := filepath.Join(traefikDynamicDir, "orca-dex.yaml")
|
||||||
|
|
||||||
|
// Determine the issuer URL from the RP ID.
|
||||||
|
issuer := "https://" + authInitRPID
|
||||||
|
|
||||||
|
// Step 1: Render the Dex config YAML.
|
||||||
|
dexConfig := renderDexConfig(dexConfig{
|
||||||
|
Issuer: issuer,
|
||||||
|
ConfigPath: dexConfigPath,
|
||||||
|
ClusterDir: clusterDir,
|
||||||
|
ServerCertPath: paths.ServerCertPath(),
|
||||||
|
ServerKeyPath: paths.ServerKeyPath(),
|
||||||
|
RPID: authInitRPID,
|
||||||
|
CredsDBPath: filepath.Join(clusterDir, "webauthn-credentials.db"),
|
||||||
|
})
|
||||||
|
if err := os.MkdirAll(clusterDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("init-idp: mkdir cluster dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := securityWriteAtomic(dexConfigPath, []byte(dexConfig), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("init-idp: write dex config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Dex config rendered: %s\n", dexConfigPath)
|
||||||
|
|
||||||
|
// Step 2: Render the systemd unit.
|
||||||
|
unit := renderDexSystemdUnit(dexConfigPath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dexUnitPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("init-idp: mkdir systemd dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := securityWriteAtomic(dexUnitPath, []byte(unit), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("init-idp: write systemd unit: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Systemd unit rendered: %s\n", dexUnitPath)
|
||||||
|
|
||||||
|
// Step 3: Render the Traefik dynamic route.
|
||||||
|
traefikRoute := renderDexTraefikRoute(authInitRPID)
|
||||||
|
if err := os.MkdirAll(traefikDynamicDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("init-idp: mkdir traefik dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := securityWriteAtomic(traefikRoutePath, []byte(traefikRoute), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("init-idp: write traefik route: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Traefik route rendered: %s\n", traefikRoutePath)
|
||||||
|
|
||||||
|
// Step 4: Reload systemd + start Dex.
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "Note: run 'systemctl daemon-reload && systemctl enable --now orca-dex' to start Dex.")
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Bundled Dex deployed for RP ID: %s (issuer: %s)\n", authInitRPID, issuer)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dexConfig is the template data for the Dex config YAML.
|
||||||
|
type dexConfig struct {
|
||||||
|
Issuer string
|
||||||
|
ConfigPath string
|
||||||
|
ClusterDir string
|
||||||
|
ServerCertPath string
|
||||||
|
ServerKeyPath string
|
||||||
|
RPID string
|
||||||
|
CredsDBPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDexConfig renders the Dex config YAML from the template data.
|
||||||
|
func renderDexConfig(d dexConfig) string {
|
||||||
|
return fmt.Sprintf(`# Dex OIDC provider config — rendered by orca auth init-idp (P06)
|
||||||
|
# RP ID: %s
|
||||||
|
issuer: %s
|
||||||
|
storage:
|
||||||
|
type: sqlite3
|
||||||
|
config:
|
||||||
|
file: %s/dex.db
|
||||||
|
web:
|
||||||
|
https: 127.0.0.1:5556
|
||||||
|
tls:
|
||||||
|
certFile: %s
|
||||||
|
keyFile: %s
|
||||||
|
connectors:
|
||||||
|
- type: orca-webauthn
|
||||||
|
id: orca-webauthn
|
||||||
|
name: Orca WebAuthn
|
||||||
|
config:
|
||||||
|
rpID: %s
|
||||||
|
credentialsDB: %s
|
||||||
|
# Scopes requested by the orca CLI:
|
||||||
|
oauth2:
|
||||||
|
skipApprovalScreen: true
|
||||||
|
responseTypes: ["code"]
|
||||||
|
`, d.RPID, d.Issuer, d.ClusterDir, d.ServerCertPath, d.ServerKeyPath, d.RPID, d.CredsDBPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDexSystemdUnit renders the systemd unit for Dex.
|
||||||
|
func renderDexSystemdUnit(configPath string) string {
|
||||||
|
return fmt.Sprintf(`[Unit]
|
||||||
|
Description=Orca Dex Identity Provider (P06, R-021)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=orca
|
||||||
|
ExecStart=/usr/local/bin/dex serve %s
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDexTraefikRoute renders the Traefik dynamic config for the Dex route.
|
||||||
|
func renderDexTraefikRoute(rpID string) string {
|
||||||
|
bt := string(rune(96)) // backtick
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("# Traefik dynamic config for Dex \u2014 rendered by orca auth init-idp (P06)\n")
|
||||||
|
sb.WriteString("http:\n")
|
||||||
|
sb.WriteString(" routers:\n")
|
||||||
|
sb.WriteString(" orca-dex:\n")
|
||||||
|
sb.WriteString(" rule: \"Host(" + bt + rpID + bt + ") && PathPrefix(" + bt + "/orca/webauthn" + bt + ")\"\n")
|
||||||
|
sb.WriteString(" entryPoints:\n")
|
||||||
|
sb.WriteString(" - websecure\n")
|
||||||
|
sb.WriteString(" service: orca-dex\n")
|
||||||
|
sb.WriteString(" tls: {}\n")
|
||||||
|
sb.WriteString(" services:\n")
|
||||||
|
sb.WriteString(" orca-dex:\n")
|
||||||
|
sb.WriteString(" loadBalancer:\n")
|
||||||
|
sb.WriteString(" servers:\n")
|
||||||
|
sb.WriteString(" - url: \"https://127.0.0.1:5556\"\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// securityWriteAtomic is a thin wrapper around security.WriteAtomic for
|
||||||
|
// use in the cli package (avoids repeating the pattern).
|
||||||
|
func securityWriteAtomic(path string, data []byte, mode os.FileMode) error {
|
||||||
|
return security.WriteAtomic(path, mode, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authRegisterCmd opens the browser to the WebAuthn registration page.
|
||||||
|
var authRegisterNoBrowser bool
|
||||||
|
|
||||||
|
var authRegisterCmd = &cobra.Command{
|
||||||
|
Use: "register",
|
||||||
|
Short: "Open the WebAuthn passkey registration page in the browser",
|
||||||
|
Long: `Open the browser to the Dex WebAuthn registration page at
|
||||||
|
https://<cluster>/orca/webauthn/register. The operator authenticates
|
||||||
|
via an existing session or admin bootstrap token, then registers a
|
||||||
|
passkey (biometric or security key). Use --no-browser to print the URL
|
||||||
|
instead of opening a browser.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := loadOIDCConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
registerURL := cfg.Issuer + "/orca/webauthn/register"
|
||||||
|
if authRegisterNoBrowser {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to register a passkey:\n %s\n", registerURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Opening browser to: %s\n", registerURL)
|
||||||
|
return openBrowserOS(registerURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
authLoginCmd.Flags().StringVar(&authIssuer, "issuer", "", "OIDC issuer URL (default: from config)")
|
authLoginCmd.Flags().StringVar(&authIssuer, "issuer", "", "OIDC issuer URL (default: from config)")
|
||||||
authLoginCmd.Flags().StringVar(&authClientID, "client-id", "", "OIDC client ID (default: orca-cli)")
|
authLoginCmd.Flags().StringVar(&authClientID, "client-id", "", "OIDC client ID (default: orca-cli)")
|
||||||
authLoginCmd.Flags().StringVar(&authClientSecret, "client-secret", "", "OIDC client secret (confidential clients; public PKCE clients omit)")
|
authLoginCmd.Flags().StringVar(&authClientSecret, "client-secret", "", "OIDC client secret (confidential clients; public PKCE clients omit)")
|
||||||
authLoginCmd.Flags().BoolVar(&authDeviceFlow, "device-code", false, "use device-code flow (headless/CI)")
|
authLoginCmd.Flags().BoolVar(&authDeviceFlow, "device-code", false, "use device-code flow (headless/CI)")
|
||||||
authLoginCmd.Flags().BoolVar(&authOpenBrowser, "open-browser", true, "open the default browser (set false to print URL only)")
|
authLoginCmd.Flags().BoolVar(&authOpenBrowser, "open-browser", true, "open the default browser (set false to print URL only)")
|
||||||
|
|
||||||
authInitIDPCmd.Flags().StringVar(&authInitRPID, "rp-id", "", "WebAuthn relying-party ID (cluster Traefik domain)")
|
authInitIDPCmd.Flags().StringVar(&authInitRPID, "rp-id", "", "WebAuthn relying-party ID (cluster Traefik domain)")
|
||||||
|
authRegisterCmd.Flags().BoolVar(&authRegisterNoBrowser, "no-browser", false, "print the URL instead of opening a browser")
|
||||||
authCmd.AddCommand(authLoginCmd)
|
authCmd.AddCommand(authLoginCmd)
|
||||||
authCmd.AddCommand(authLogoutCmd)
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
authCmd.AddCommand(authStatusCmd)
|
authCmd.AddCommand(authStatusCmd)
|
||||||
authCmd.AddCommand(authInitIDPCmd)
|
authCmd.AddCommand(authInitIDPCmd)
|
||||||
|
authCmd.AddCommand(authRegisterCmd)
|
||||||
rootCmd.AddCommand(authCmd)
|
rootCmd.AddCommand(authCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAuthInitIDP_RendersConfig tests that orca auth init-idp renders
|
||||||
|
// the Dex config, systemd unit, and Traefik route files (P06, REQ-155).
|
||||||
|
func TestAuthInitIDP_RendersConfig(t *testing.T) {
|
||||||
|
t.Setenv("ORCA_HOME", t.TempDir())
|
||||||
|
resetRootFlags(t)
|
||||||
|
|
||||||
|
// Create the cluster dir + server cert/key so the rendered config paths exist.
|
||||||
|
clusterDir := filepath.Join(os.Getenv("ORCA_HOME"), "cluster")
|
||||||
|
if err := os.MkdirAll(clusterDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir cluster: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(clusterDir, "server.crt"), []byte("fake-cert"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write cert: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(clusterDir, "server.key"), []byte("fake-key"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run init-idp with a temp output (we mock the system paths).
|
||||||
|
// Since init-idp writes to /etc/systemd/system and /etc/traefik/dynamic,
|
||||||
|
// we test the render functions directly.
|
||||||
|
dexCfg := renderDexConfig(dexConfig{
|
||||||
|
Issuer: "https://orca.local",
|
||||||
|
ConfigPath: "/tmp/dex.yaml",
|
||||||
|
ClusterDir: clusterDir,
|
||||||
|
ServerCertPath: filepath.Join(clusterDir, "server.crt"),
|
||||||
|
ServerKeyPath: filepath.Join(clusterDir, "server.key"),
|
||||||
|
RPID: "orca.local",
|
||||||
|
CredsDBPath: filepath.Join(clusterDir, "webauthn-credentials.db"),
|
||||||
|
})
|
||||||
|
if !strings.Contains(dexCfg, "issuer: https://orca.local") {
|
||||||
|
t.Errorf("dex config missing issuer: %s", dexCfg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(dexCfg, "orca-webauthn") {
|
||||||
|
t.Errorf("dex config missing webauthn connector: %s", dexCfg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(dexCfg, "rpID: orca.local") {
|
||||||
|
t.Errorf("dex config missing rpID: %s", dexCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := renderDexSystemdUnit("/tmp/dex.yaml")
|
||||||
|
if !strings.Contains(unit, "Orca Dex") {
|
||||||
|
t.Errorf("systemd unit missing orca-dex: %s", unit)
|
||||||
|
}
|
||||||
|
if !strings.Contains(unit, "dex serve /tmp/dex.yaml") {
|
||||||
|
t.Errorf("systemd unit missing ExecStart: %s", unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
route := renderDexTraefikRoute("orca.local")
|
||||||
|
if !strings.Contains(route, "orca.local") {
|
||||||
|
t.Errorf("traefik route missing rpID: %s", route)
|
||||||
|
}
|
||||||
|
if !strings.Contains(route, "orca-dex") {
|
||||||
|
t.Errorf("traefik route missing service name: %s", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthRegisterCmd_Exists verifies the auth register command is registered.
|
||||||
|
func TestAuthRegisterCmd_Exists(t *testing.T) {
|
||||||
|
found := false
|
||||||
|
for _, cmd := range authCmd.Commands() {
|
||||||
|
if cmd.Name() == "register" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("auth register command not found in auth subcommands")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorOIDCCmd_Exists verifies the doctor oidc command is registered.
|
||||||
|
func TestDoctorOIDCCmd_Exists(t *testing.T) {
|
||||||
|
found := false
|
||||||
|
for _, cmd := range doctorCmd.Commands() {
|
||||||
|
if cmd.Name() == "oidc" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("doctor oidc command not found in doctor subcommands")
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-2
@@ -3,9 +3,12 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -290,7 +293,98 @@ func checkMode(path string, want os.FileMode) modeReport {
|
|||||||
return modeReport{Path: path, Mode: got, Want: want, Status: "ok"}
|
return modeReport{Path: path, Mode: got, Want: want, Status: "ok"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doctorOIDCCmd implements `orca doctor oidc` (P06, REQ-155).
|
||||||
|
// Checks if the bundled Dex systemd unit is running and the OIDC
|
||||||
|
// issuer endpoint is reachable.
|
||||||
|
var doctorOIDCCmd = &cobra.Command{
|
||||||
|
Use: "oidc",
|
||||||
|
Short: "Check the bundled Dex OIDC provider health (P06)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
results := checkOIDCHealth(ctx)
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(results)
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", r.Name, r.Status, r.Message)
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Status == "FAIL" {
|
||||||
|
return fmt.Errorf("oidc health check failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcCheckResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOIDCHealth(ctx context.Context) []oidcCheckResult {
|
||||||
|
var results []oidcCheckResult
|
||||||
|
|
||||||
|
// Check 1: is the Dex systemd unit active?
|
||||||
|
unitOut, err := exec.CommandContext(ctx, "systemctl", "is-active", "orca-dex.service").CombinedOutput()
|
||||||
|
unitStatus := strings.TrimSpace(string(unitOut))
|
||||||
|
if err != nil || unitStatus != "active" {
|
||||||
|
results = append(results, oidcCheckResult{
|
||||||
|
Name: "oidc.unit",
|
||||||
|
Status: "FAIL",
|
||||||
|
Message: fmt.Sprintf("orca-dex.service is %s (run 'orca auth init-idp' to deploy)", unitStatus),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
results = append(results, oidcCheckResult{
|
||||||
|
Name: "oidc.unit",
|
||||||
|
Status: "PASS",
|
||||||
|
Message: "orca-dex.service is active",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: is the OIDC issuer reachable?
|
||||||
|
cfg, err := loadOIDCConfig()
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, oidcCheckResult{
|
||||||
|
Name: "oidc.issuer",
|
||||||
|
Status: "WARN",
|
||||||
|
Message: fmt.Sprintf("no OIDC config: %v", err),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
wellKnown := strings.TrimSuffix(cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", wellKnown, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, oidcCheckResult{
|
||||||
|
Name: "oidc.issuer",
|
||||||
|
Status: "FAIL",
|
||||||
|
Message: fmt.Sprintf("cannot reach %s: %v", wellKnown, err),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
results = append(results, oidcCheckResult{
|
||||||
|
Name: "oidc.issuer",
|
||||||
|
Status: "PASS",
|
||||||
|
Message: fmt.Sprintf("issuer reachable: %s", cfg.Issuer),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
results = append(results, oidcCheckResult{
|
||||||
|
Name: "oidc.issuer",
|
||||||
|
Status: "FAIL",
|
||||||
|
Message: fmt.Sprintf("issuer returned HTTP %d", resp.StatusCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd, doctorAuditCmd, doctorModesCmd)
|
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd, doctorAuditCmd, doctorModesCmd, doctorOIDCCmd)
|
||||||
rootCmd.AddCommand(doctorCmd)
|
rootCmd.AddCommand(doctorCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ type Config struct {
|
|||||||
ServerKeyPath string `hcl:"server_key_path,optional"`
|
ServerKeyPath string `hcl:"server_key_path,optional"`
|
||||||
NodeCapacity *CapacityConfig `hcl:"node_capacity,block"`
|
NodeCapacity *CapacityConfig `hcl:"node_capacity,block"`
|
||||||
|
|
||||||
|
// OIDC is the OIDC client config block (P06, v0.13; R-021). The
|
||||||
|
// bundled Dex (deployed by `orca auth init-idp`) is the default
|
||||||
|
// issuer; an explicit oidc.issuer here repoints the CLI to a BYO
|
||||||
|
// external IdP. loadOIDCConfig reads this block before falling back
|
||||||
|
// to --issuer/--client-id flags and env vars.
|
||||||
|
OIDC *OIDCConfig `hcl:"oidc,block"`
|
||||||
|
|
||||||
|
// ClusterDomain is the cluster's Traefik-served domain (C-38). It
|
||||||
|
// is the WebAuthn relying-party ID default and the Dex issuer host.
|
||||||
|
// May be overridden by --rp-id on `orca auth init-idp`.
|
||||||
|
ClusterDomain string `hcl:"cluster_domain,optional"`
|
||||||
|
|
||||||
// ACL is the access-control config block (P04, v0.13; C-45).
|
// ACL is the access-control config block (P04, v0.13; C-45).
|
||||||
// When ACL.Enforce is false (the default for the first run after
|
// When ACL.Enforce is false (the default for the first run after
|
||||||
// P04 wiring), ACL denials are LOGGED but NOT enforced — the
|
// P04 wiring), ACL denials are LOGGED but NOT enforced — the
|
||||||
@@ -36,6 +48,16 @@ type ACLConfig struct {
|
|||||||
Enforce bool `hcl:"enforce,optional"`
|
Enforce bool `hcl:"enforce,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDCConfig is the oidc block in config (P06, R-021). Mirrors
|
||||||
|
// identity.OIDCConfig (kept separate to avoid an internal/config ->
|
||||||
|
// internal/identity dependency cycle).
|
||||||
|
type OIDCConfig struct {
|
||||||
|
Issuer string `hcl:"issuer,optional"`
|
||||||
|
ClientID string `hcl:"client_id,optional"`
|
||||||
|
ClientSecret string `hcl:"client_secret,optional"`
|
||||||
|
Scopes []string `hcl:"scopes,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
type Flags struct {
|
type Flags struct {
|
||||||
DBPath *string
|
DBPath *string
|
||||||
ListenAddr *string
|
ListenAddr *string
|
||||||
@@ -134,6 +156,8 @@ func (c *Config) MergeOverrides(flags Flags, env Environ) *Config {
|
|||||||
ServerCertPath: c.ServerCertPath,
|
ServerCertPath: c.ServerCertPath,
|
||||||
ServerKeyPath: c.ServerKeyPath,
|
ServerKeyPath: c.ServerKeyPath,
|
||||||
NodeCapacity: c.NodeCapacity,
|
NodeCapacity: c.NodeCapacity,
|
||||||
|
OIDC: c.OIDC,
|
||||||
|
ClusterDomain: c.ClusterDomain,
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStr := func(flag *string, envKey, fileVal string) string {
|
applyStr := func(flag *string, envKey, fileVal string) string {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func extractFrontmatter(content string) (string, bool) {
|
|||||||
func parseFrontmatterBlock(block, path string) (*Config, error) {
|
func parseFrontmatterBlock(block, path string) (*Config, error) {
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
var inCapacity bool
|
var inCapacity bool
|
||||||
|
var inOIDC bool
|
||||||
|
|
||||||
lines := strings.Split(block, "\n")
|
lines := strings.Split(block, "\n")
|
||||||
for lineNo, raw := range lines {
|
for lineNo, raw := range lines {
|
||||||
@@ -103,6 +104,7 @@ func parseFrontmatterBlock(block, path string) (*Config, error) {
|
|||||||
// A top-level key (no leading indent).
|
// A top-level key (no leading indent).
|
||||||
if indent == 0 {
|
if indent == 0 {
|
||||||
inCapacity = false
|
inCapacity = false
|
||||||
|
inOIDC = false
|
||||||
key, val, ok := splitKV(trimmed)
|
key, val, ok := splitKV(trimmed)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -113,6 +115,10 @@ func parseFrontmatterBlock(block, path string) (*Config, error) {
|
|||||||
cfg.NodeCapacity = &CapacityConfig{}
|
cfg.NodeCapacity = &CapacityConfig{}
|
||||||
inCapacity = true
|
inCapacity = true
|
||||||
}
|
}
|
||||||
|
if key == "oidc" {
|
||||||
|
cfg.OIDC = &OIDCConfig{}
|
||||||
|
inOIDC = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
applyScalar(cfg, key, val, path, lineNo)
|
applyScalar(cfg, key, val, path, lineNo)
|
||||||
@@ -135,6 +141,26 @@ func parseFrontmatterBlock(block, path string) (*Config, error) {
|
|||||||
cfg.NodeCapacity.MemoryMB = n
|
cfg.NodeCapacity.MemoryMB = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Indented line under the oidc block.
|
||||||
|
if inOIDC && cfg.OIDC != nil {
|
||||||
|
key, val, hasVal := splitKV(trimmed)
|
||||||
|
if !hasVal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "issuer":
|
||||||
|
cfg.OIDC.Issuer = unquote(val)
|
||||||
|
case "client_id":
|
||||||
|
cfg.OIDC.ClientID = unquote(val)
|
||||||
|
case "client_secret":
|
||||||
|
cfg.OIDC.ClientSecret = unquote(val)
|
||||||
|
case "scopes":
|
||||||
|
// Comma-separated list, optionally bracketed as [a, b].
|
||||||
|
cfg.OIDC.Scopes = parseScopes(val)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -153,11 +179,34 @@ func applyScalar(cfg *Config, key, val, path string, lineNo int) {
|
|||||||
cfg.ServerCertPath = unquote(val)
|
cfg.ServerCertPath = unquote(val)
|
||||||
case "server_key_path":
|
case "server_key_path":
|
||||||
cfg.ServerKeyPath = unquote(val)
|
cfg.ServerKeyPath = unquote(val)
|
||||||
|
case "cluster_domain":
|
||||||
|
cfg.ClusterDomain = unquote(val)
|
||||||
}
|
}
|
||||||
_ = path
|
_ = path
|
||||||
_ = lineNo
|
_ = lineNo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseScopes parses a scopes value into a []string. Supports both a
|
||||||
|
// comma-separated bare list (openid, profile, email) and a YAML-style
|
||||||
|
// flow list ([openid, profile]). Empty values are dropped.
|
||||||
|
func parseScopes(val string) []string {
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
val = unquote(val)
|
||||||
|
// Strip surrounding brackets.
|
||||||
|
if len(val) >= 2 && val[0] == '[' && val[len(val)-1] == ']' {
|
||||||
|
val = val[1 : len(val)-1]
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(val, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
part = unquote(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func splitKV(s string) (key, val string, ok bool) {
|
func splitKV(s string) (key, val string, ok bool) {
|
||||||
idx := strings.Index(s, ":")
|
idx := strings.Index(s, ":")
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user