Refactor project layout
All checks were successful
Release / release (push) Successful in 1m38s

This commit is contained in:
2026-04-01 13:16:06 +02:00
parent 5d2a80bd30
commit a0a7932f99
14 changed files with 796 additions and 447 deletions

240
cmd/client.go Normal file
View File

@@ -0,0 +1,240 @@
package cmd
import (
"cmp"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
krb5client "github.com/jcmturner/gokrb5/v8/client"
"github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/credentials"
"github.com/jcmturner/gokrb5/v8/spnego"
"git.shee.sh/james/ward/pkg/handler"
)
func credFetch(server string, noKerberos bool, logf func(string, ...any)) (*handler.ExecCredential, error) {
url := strings.TrimRight(server, "/") + "/credential"
// ── Kerberos SPNEGO ───────────────────────────────────────────────────────
if !noKerberos {
body, err := credFetchKerberos(url, logf)
if err != nil {
logf("Kerberos: %v", err)
} else {
return credParse(body)
}
}
return nil, fmt.Errorf("no valid credential — run 'ward login --server %s'", server)
}
func credFetchKerberos(url string, logf func(string, ...any)) ([]byte, error) {
krb5cfgPath := krb5ConfigPath()
logf("Kerberos: loading config from %s", krb5cfgPath)
var krb5cfg *config.Config
if _, statErr := os.Stat(krb5cfgPath); os.IsNotExist(statErr) && os.Getenv("KRB5_CONFIG") == "" {
logf("Kerberos: %s not found, using default config (KDC discovery via DNS)", krb5cfgPath)
krb5cfg = config.New()
krb5cfg.LibDefaults.DNSLookupKDC = true
} else {
var err error
krb5cfg, err = config.Load(krb5cfgPath)
if err != nil {
return nil, fmt.Errorf("loading krb5 config: %w", err)
}
}
ccPath, err := ccachePath()
if err != nil {
return nil, err
}
logf("Kerberos: loading credential cache from %s", ccPath)
ccache, err := credentials.LoadCCache(ccPath)
if err != nil {
hint := "run 'kinit'"
if runtime.GOOS == "darwin" && os.Getenv("KRB5CCNAME") == "" {
hint = fmt.Sprintf("on macOS, run: kinit -c /tmp/krb5cc_%d", os.Getuid())
}
return nil, fmt.Errorf("no credential cache (%w) — %s", err, hint)
}
cl, err := krb5client.NewFromCCache(ccache, krb5cfg, krb5client.DisablePAFXFAST(true))
if err != nil {
return nil, fmt.Errorf("creating Kerberos client: %w", err)
}
defer cl.Destroy()
logf("Kerberos: principal=%s@%s", cl.Credentials.UserName(), cl.Credentials.Domain())
spnegoClient := spnego.NewClient(cl, nil, "")
logf("HTTP: GET %s (Negotiate)", url)
resp, err := spnegoClient.Get(url)
if err != nil {
return nil, fmt.Errorf("HTTP request: %w", err)
}
defer resp.Body.Close()
logf("HTTP: %s", resp.Status)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("server rejected Kerberos token (check SPN and keytab)")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
logf("HTTP: received %d bytes", len(body))
return body, nil
}
func credFetchBasic(url, username, password string, logf func(string, ...any)) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
req.SetBasicAuth(username, password)
logf("HTTP: GET %s (Basic, user=%s)", url, username)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request: %w", err)
}
defer resp.Body.Close()
logf("HTTP: %s", resp.Status)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("authentication failed (wrong username or password)")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
logf("HTTP: received %d bytes", len(body))
return body, nil
}
func credParse(body []byte) (*handler.ExecCredential, error) {
var ec handler.ExecCredential
if err := json.Unmarshal(body, &ec); err != nil {
return nil, fmt.Errorf("parsing server response: %w", err)
}
if ec.Status == nil {
return nil, fmt.Errorf("server returned ExecCredential with no status field")
}
return &ec, nil
}
func credPrint(ec *handler.ExecCredential) error {
return json.NewEncoder(os.Stdout).Encode(ec)
}
// ── Local credential cache ─────────────────────────────────────────────────────
// Caches ExecCredential JSON in ~/.cache/ward/<sha256(serverURL)>.json.
// The cache is consulted before contacting ward; a hit avoids a round-trip
// and, for Kerberos, avoids acquiring a service ticket on every kubectl call.
func credCacheDir() string {
if d := os.Getenv("XDG_CACHE_HOME"); d != "" {
return filepath.Join(d, "ward")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "ward")
}
func credCacheFile(serverURL string) string {
h := sha256.Sum256([]byte(serverURL))
return filepath.Join(credCacheDir(), fmt.Sprintf("%x.json", h))
}
func credReadCache(serverURL string, logf func(string, ...any)) (*handler.ExecCredential, bool) {
path := credCacheFile(serverURL)
logf("cache: checking %s", path)
data, err := os.ReadFile(path)
if err != nil {
logf("cache: miss (%v)", err)
return nil, false
}
var ec handler.ExecCredential
if err := json.Unmarshal(data, &ec); err != nil {
logf("cache: corrupt, ignoring (%v)", err)
return nil, false
}
if ec.Status == nil || ec.Status.ExpirationTimestamp == "" {
logf("cache: no expiry stored, refreshing")
return nil, false
}
expiry, err := time.Parse(time.RFC3339, ec.Status.ExpirationTimestamp)
if err != nil {
logf("cache: unparseable expiry %q, refreshing", ec.Status.ExpirationTimestamp)
return nil, false
}
remaining := time.Until(expiry)
if remaining < 5*time.Minute {
logf("cache: expiring soon (%v remaining), refreshing", remaining.Truncate(time.Second))
return nil, false
}
logf("cache: hit — cert for expires %s (%v remaining)",
expiry.Format(time.RFC3339), remaining.Truncate(time.Second))
return &ec, true
}
func credWriteCache(serverURL string, ec *handler.ExecCredential, logf func(string, ...any)) {
dir := credCacheDir()
if err := os.MkdirAll(dir, 0700); err != nil {
logf("cache: failed to create dir: %v", err)
return
}
data, err := json.Marshal(ec)
if err != nil {
logf("cache: marshal failed: %v", err)
return
}
path := credCacheFile(serverURL)
if err := os.WriteFile(path, data, 0600); err != nil {
logf("cache: write failed: %v", err)
return
}
logf("cache: saved to %s", path)
}
// ── Kerberos helpers ───────────────────────────────────────────────────────────
func krb5ConfigPath() string {
return cmp.Or(os.Getenv("KRB5_CONFIG"), "/etc/krb5.conf")
}
// ccachePath returns the path to the active Kerberos credential cache, or an
// error if $KRB5CCNAME names a non-file cache type that gokrb5 cannot read.
//
// On macOS, kinit defaults to API: caches. Work around it with:
//
// kinit -c /tmp/krb5cc_$(id -u)
func ccachePath() (string, error) {
if v := os.Getenv("KRB5CCNAME"); v != "" {
for _, prefix := range []string{"API:", "KEYRING:", "DIR:", "KCM:"} {
if strings.HasPrefix(v, prefix) {
return "", fmt.Errorf(
"credential cache type %s is not supported (gokrb5 requires a file-based cache)\n"+
"hint: re-run kinit with: kinit -c /tmp/krb5cc_%d",
prefix, os.Getuid())
}
}
return strings.TrimPrefix(v, "FILE:"), nil
}
return fmt.Sprintf("/tmp/krb5cc_%d", os.Getuid()), nil
}

68
cmd/credential.go Normal file
View File

@@ -0,0 +1,68 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func newCredentialCmd() *cobra.Command {
var (
server string
noKerberos bool
noCache bool
debugFlag bool
)
cmd := &cobra.Command{
Use: "credential",
Short: "kubectl exec credential plugin — serve a cached ExecCredential to kubectl",
Long: `Acts as a kubectl exec credential plugin. Returns a cached ExecCredential
JSON to kubectl. On a cache miss, silently attempts Kerberos SPNEGO; if that
also fails, exits with an error directing the user to run 'ward login'.
Run 'ward login' once to authenticate and populate the cache. After that,
kubectl works silently until the credential expires.
Debug output goes to stderr (kubectl surfaces this to the terminal):
WARD_DEBUG=1 kubectl get nodes`,
RunE: func(cmd *cobra.Command, args []string) error {
if server == "" {
return fmt.Errorf("--server is required")
}
server = normalizeServer(server)
logf := func(format string, a ...any) {
if debugFlag {
fmt.Fprintf(os.Stderr, "[ward] "+format+"\n", a...)
}
}
if !noCache {
if ec, ok := credReadCache(server, logf); ok {
return credPrint(ec)
}
}
ec, err := credFetch(server, noKerberos, logf)
if err != nil {
return err
}
if !noCache {
credWriteCache(server, ec, logf)
}
return credPrint(ec)
},
}
cmd.Flags().StringVar(&server, "server", "", "ward server URL (required)")
cmd.Flags().BoolVar(&noKerberos, "no-kerberos", false, "skip Kerberos SPNEGO")
cmd.Flags().BoolVar(&noCache, "no-cache", false, "bypass local cache; always fetch a fresh credential")
cmd.Flags().BoolVar(&debugFlag, "debug", os.Getenv("WARD_DEBUG") != "", "verbose debug output to stderr (also: $WARD_DEBUG=1)")
return cmd
}

139
cmd/login.go Normal file
View File

@@ -0,0 +1,139 @@
package cmd
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"git.shee.sh/james/ward/pkg/handler"
"git.shee.sh/james/ward/pkg/kubeconfig"
)
func newLoginCmd() *cobra.Command {
var (
server string
username string
contextName string
noKerberos bool
noSetContext bool
debugFlag bool
)
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate to ward and configure kubectl",
Long: `Authenticates to the ward server, caches a long-lived credential, and
updates ~/.kube/config with the cluster, user (exec plugin), and context.
After login, kubectl works silently until the credential expires, at
which point you run 'ward login' again.
Authentication priority:
1. Kerberos SPNEGO using the active credential cache (from kinit)
2. Password prompt`,
RunE: func(cmd *cobra.Command, args []string) error {
if server == "" {
return fmt.Errorf("--server is required")
}
server = normalizeServer(server)
logf := func(format string, a ...any) {
if debugFlag {
fmt.Fprintf(os.Stderr, "[ward] "+format+"\n", a...)
}
}
if username == "" {
username = os.Getenv("USER")
}
ec, err := loginFetch(server, username, noKerberos, logf)
if err != nil {
return err
}
credWriteCache(server, ec, logf)
bootstrap, err := fetchBootstrap(server, username, logf)
if err != nil {
return fmt.Errorf("fetching bootstrap kubeconfig: %w", err)
}
if contextName != "" {
kubeconfig.RenameContext(bootstrap, contextName)
}
contextApplied := bootstrap.CurrentContext
if err := kubeconfig.Merge(bootstrap, !noSetContext); err != nil {
return fmt.Errorf("updating kubeconfig: %w", err)
}
fmt.Fprintf(os.Stderr, "ward: logged in — context %q configured in %s\n",
contextApplied, kubeconfig.FilePath())
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "ward server URL (required)")
cmd.Flags().StringVar(&username, "username", "", "username (default: $USER)")
cmd.Flags().StringVar(&contextName, "context", "", "kubectl context/cluster name (overrides server default)")
cmd.Flags().BoolVar(&noKerberos, "no-kerberos", false, "skip Kerberos; use password auth")
cmd.Flags().BoolVar(&noSetContext, "no-set-context", false, "do not set as current-context")
cmd.Flags().BoolVar(&debugFlag, "debug", os.Getenv("WARD_DEBUG") != "", "verbose debug output to stderr")
return cmd
}
func loginFetch(server, username string, noKerberos bool, logf func(string, ...any)) (*handler.ExecCredential, error) {
loginURL := strings.TrimRight(server, "/") + "/credential?login=true"
if !noKerberos {
body, err := credFetchKerberos(loginURL, logf)
if err != nil {
logf("Kerberos: %v — falling back to password auth", err)
fmt.Fprintf(os.Stderr, "ward: Kerberos failed (%v); using password auth\nhint: run 'kinit' to avoid the password prompt next time\n", err)
} else {
return credParse(body)
}
}
password, err := promptPassword(username)
if err != nil {
return nil, err
}
body, err := credFetchBasic(loginURL, username, password, logf)
if err != nil {
return nil, err
}
return credParse(body)
}
func fetchBootstrap(server, username string, logf func(string, ...any)) (*kubeconfig.KubeConfig, error) {
bootstrapURL := strings.TrimRight(server, "/") + "/bootstrap?user=" + url.QueryEscape(username)
logf("HTTP: GET %s", bootstrapURL)
resp, err := http.Get(bootstrapURL) //nolint:gosec // URL derived from user-supplied server flag
if err != nil {
return nil, fmt.Errorf("HTTP request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
var cfg kubeconfig.KubeConfig
if err := yaml.Unmarshal(body, &cfg); err != nil {
return nil, fmt.Errorf("parsing bootstrap kubeconfig: %w", err)
}
return &cfg, nil
}

46
cmd/root.go Normal file
View File

@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
// Execute builds the root command and runs it.
func Execute() {
root := newRootCmd()
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "ward",
Short: "Kubernetes credential gateway",
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(newServeCmd())
root.AddCommand(newCredentialCmd())
root.AddCommand(newLoginCmd())
return root
}
// normalizeServer ensures server has an https:// scheme and a port.
// Shared by the credential and login commands.
func normalizeServer(server string) string {
if !strings.Contains(server, "://") {
server = "https://" + server
}
parts := strings.Split(server, ":")
if len(parts) == 2 {
server += ":8443"
}
return server
}

252
cmd/serve.go Normal file
View File

@@ -0,0 +1,252 @@
package cmd
import (
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"git.shee.sh/james/ward/pkg/auth"
"git.shee.sh/james/ward/pkg/cert"
"git.shee.sh/james/ward/pkg/handler"
)
func newServeCmd() *cobra.Command {
fqdn := detectFQDN()
domain := domainPart(fqdn)
realm := strings.ToUpper(domain)
var (
addr string
tlsCert string
tlsKey string
k3sServer string
serverCACert string
clientCACert string
clientCAKey string
certDuration time.Duration
clusterName string
ldapDomain string
ldapOn bool
ldapURI string
ldapBindDN string
loginCertDuration time.Duration
kerberosOn bool
keytabPath string
spn string
htpasswdPath string
debugFlag bool
)
cmd := &cobra.Command{
Use: "serve",
Short: "Run the ward authentication server",
RunE: func(cmd *cobra.Command, args []string) error {
dbg := log.New(io.Discard, "[ward] ", 0)
if debugFlag {
dbg = log.New(os.Stderr, "[ward] ", log.Ltime)
dbg.Print("debug logging enabled")
}
if k3sServer == "" {
return fmt.Errorf("--k3s-server is required (e.g. https://k3s.example.com:6443)")
}
// ── LDAP ──────────────────────────────────────────────────────────────────
ldapBindPassword := os.Getenv("WARD_LDAP_BIND_PASSWORD")
var ldapAuth *auth.LDAPAuth
if ldapURI != "" || ldapOn {
var la *auth.LDAPAuth
var err error
if ldapURI != "" {
la, err = auth.NewLDAPAuthFromURI(ldapURI, ldapDomain, ldapBindDN, ldapBindPassword, dbg)
} else {
la, err = auth.NewLDAPAuth(ldapDomain, ldapBindDN, ldapBindPassword, dbg)
}
if err != nil {
return fmt.Errorf("LDAP: %w", err)
}
ldapAuth = la
anon := ldapBindDN == ""
log.Printf("LDAP: %s:%d (TLS=%v) domain=%s anon=%v", la.Host(), la.Port(), la.UseTLS(), ldapDomain, anon)
}
if ldapAuth == nil && !kerberosOn && htpasswdPath == "" {
return fmt.Errorf("no authentication providers configured: use at least one of --ldap, --ldap-uri, --kerberos, or --htpasswd")
}
// ── Kerberos ──────────────────────────────────────────────────────────────
var krbAuth *auth.KerberosAuth
if kerberosOn {
ka, err := auth.NewKerberosAuth(keytabPath, spn)
if err != nil {
return fmt.Errorf("Kerberos: %w", err)
}
krbAuth = ka
log.Printf("Kerberos: keytab=%s SPN=%s realm=%s", keytabPath, spn, realm)
}
// ── htpasswd ──────────────────────────────────────────────────────────────
var htpasswdAuth *auth.HtpasswdAuth
if htpasswdPath != "" {
ha, err := auth.NewHtpasswdAuth(htpasswdPath)
if err != nil {
return fmt.Errorf("htpasswd: %w", err)
}
htpasswdAuth = ha
log.Printf("htpasswd: %s (%d entries)", htpasswdPath, ha.Len())
sighup := make(chan os.Signal, 1)
signal.Notify(sighup, syscall.SIGHUP)
go func() {
for range sighup {
if err := htpasswdAuth.Reload(); err != nil {
log.Printf("SIGHUP: htpasswd reload failed: %v", err)
} else {
log.Printf("SIGHUP: htpasswd reloaded (%d entries)", htpasswdAuth.Len())
}
}
}()
}
// ── Handler ───────────────────────────────────────────────────────────────
h, err := handler.NewHandler(ldapAuth, krbAuth, htpasswdAuth, &cert.CertConfig{
ServerURL: k3sServer,
ServerCACert: serverCACert,
ClientCACert: clientCACert,
ClientCAKey: clientCAKey,
Duration: certDuration,
LoginDuration: loginCertDuration,
ClusterName: clusterName,
}, dbg)
if err != nil {
return fmt.Errorf("handler: %w", err)
}
// ── TLS ───────────────────────────────────────────────────────────────────
if _, err := tls.LoadX509KeyPair(tlsCert, tlsKey); err != nil {
return fmt.Errorf("TLS: loading certificate: %w", err)
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
c, err := tls.LoadX509KeyPair(tlsCert, tlsKey)
if err != nil {
return nil, fmt.Errorf("reloading TLS cert: %w", err)
}
return &c, nil
},
}
ln, err := tls.Listen("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/kubeconfig", h.ServeHTTP)
mux.HandleFunc("/credential", h.ServeCredential)
mux.HandleFunc("/bootstrap", h.ServeBootstrap)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "ward — Kubernetes credential gateway\n\n"+
" GET /bootstrap kubeconfig with exec plugin pre-wired (no auth required)\n"+
" GET /credential ExecCredential JSON for kubectl exec plugin\n"+
" GET /kubeconfig kubeconfig with embedded client certificate\n")
})
srv := &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
log.Printf("ward listening on %s (cert-duration=%s)", addr, certDuration)
return srv.Serve(ln)
},
}
cmd.Flags().StringVar(&addr, "addr", ":8443", "Listen address")
cmd.Flags().StringVar(&tlsCert, "tls-cert", fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", fqdn), "TLS certificate file (Let's Encrypt fullchain)")
cmd.Flags().StringVar(&tlsKey, "tls-key", fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", fqdn), "TLS private key file")
cmd.Flags().StringVar(&k3sServer, "k3s-server", "", "k3s API server URL written into returned kubeconfigs (required, e.g. https://k3s.example.com:6443)")
cmd.Flags().StringVar(&serverCACert, "server-ca-cert", "/var/lib/rancher/k3s/server/tls/server-ca.crt", "k3s server CA certificate (embedded in returned kubeconfig)")
cmd.Flags().StringVar(&clientCACert, "client-ca-cert", "/var/lib/rancher/k3s/server/tls/client-ca.crt", "k3s client CA certificate (signs user certs)")
cmd.Flags().StringVar(&clientCAKey, "client-ca-key", "/var/lib/rancher/k3s/server/tls/client-ca.key", "k3s client CA key")
cmd.Flags().DurationVar(&certDuration, "cert-duration", 24*time.Hour, "Validity period of generated client certificates")
cmd.Flags().DurationVar(&loginCertDuration, "login-cert-duration", 168*time.Hour, "Validity period of certificates issued by 'ward login' (default 7 days)")
cmd.Flags().StringVar(&clusterName, "cluster-name", firstLabel(domain), "Cluster/context name written into generated kubeconfigs")
cmd.Flags().StringVar(&ldapDomain, "domain", domain, "Domain for LDAP SRV discovery and Kerberos realm derivation")
cmd.Flags().BoolVar(&ldapOn, "ldap", false, "Enable LDAP authentication (auto-discovered via DNS SRV)")
cmd.Flags().StringVar(&ldapURI, "ldap-uri", "", "LDAP server URI, e.g. ldaps://ldap.example.com (implies --ldap; overrides DNS SRV)")
cmd.Flags().StringVar(&ldapBindDN, "ldap-bind-dn", os.Getenv("WARD_LDAP_BIND_DN"), "LDAP bind DN for search (default: anonymous; env: WARD_LDAP_BIND_DN)")
// LDAP bind password is read exclusively from $WARD_LDAP_BIND_PASSWORD to avoid
// exposure in process listings.
cmd.Flags().BoolVar(&kerberosOn, "kerberos", false, "Enable Kerberos SPNEGO authentication (Authorization: Negotiate)")
cmd.Flags().StringVar(&keytabPath, "keytab", "/etc/krb5.keytab", "Kerberos service keytab path")
cmd.Flags().StringVar(&spn, "spn", "HTTP/"+fqdn, fmt.Sprintf("Kerberos service principal name (SPN) (default realm %s — create with: kadmin: addprinc -randkey HTTP/%s@%s)", realm, fqdn, realm))
cmd.Flags().StringVar(&htpasswdPath, "htpasswd", "", "Path to an Apache-compatible htpasswd file (bcrypt recommended: htpasswd -B -c file user)")
cmd.Flags().BoolVar(&debugFlag, "debug", os.Getenv("WARD_DEBUG") != "", "Enable verbose debug logging (also: $WARD_DEBUG=1)")
return cmd
}
// detectFQDN returns the fully-qualified domain name of the local host,
// falling back to the short hostname if DNS resolution fails.
func detectFQDN() string {
hostname, err := os.Hostname()
if err != nil {
return "localhost"
}
if strings.Contains(hostname, ".") {
return hostname
}
addrs, err := net.LookupHost(hostname)
if err != nil || len(addrs) == 0 {
return hostname
}
names, err := net.LookupAddr(addrs[0])
if err != nil || len(names) == 0 {
return hostname
}
return strings.TrimSuffix(names[0], ".")
}
// domainPart strips the first label from a FQDN.
// "host.example.com" → "example.com"
func domainPart(fqdn string) string {
if idx := strings.IndexByte(fqdn, '.'); idx >= 0 {
return fqdn[idx+1:]
}
return fqdn
}
// firstLabel returns the first dot-separated label of a domain name.
// "example.com" → "example"
func firstLabel(domain string) string {
if idx := strings.IndexByte(domain, '.'); idx >= 0 {
return domain[:idx]
}
return domain
}

40
cmd/tty.go Normal file
View File

@@ -0,0 +1,40 @@
package cmd
import (
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/term"
)
func promptPassword(username string) (string, error) {
terminal, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return "", fmt.Errorf("cannot open terminal for password prompt\nhint: run 'kinit' for Kerberos auth")
}
oldState, err := term.MakeRaw(int(terminal.Fd()))
if err != nil {
return "", fmt.Errorf("setting terminal raw mode: %w", err)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
term.Restore(int(terminal.Fd()), oldState)
os.Exit(1)
}()
fmt.Fprintf(terminal, "Password for %s: ", username)
pw, err := term.ReadPassword(int(terminal.Fd()))
fmt.Fprintf(terminal, "\r\n")
signal.Stop(sigCh)
term.Restore(int(terminal.Fd()), oldState)
if err != nil {
return "", fmt.Errorf("reading password: %w", err)
}
return string(pw), nil
}