From 63c8ef0b8adffda7e068bd57795f2350248e631f Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 3 Aug 2026 20:17:32 +0300 Subject: [PATCH] Add built-in TLS (self-signed autogen, CA pinning) and optional closed registration --- README.md | 8 ++ coordinator/cmd/coordinator/main.go | 12 ++- coordinator/cmd/coordinator/serve_cmd.go | 102 ++++++++++++++++++ coordinator/internal/agent/auth.go | 2 +- coordinator/internal/agent/check.go | 4 +- coordinator/internal/agent/client.go | 4 +- coordinator/internal/agent/client_test.go | 57 ++++++++++ coordinator/internal/agent/tls.go | 69 ++++++++++++ coordinator/internal/infra/config.go | 11 ++ coordinator/internal/infra/server.go | 16 ++- coordinator/internal/transport/http/server.go | 16 +++ .../internal/transport/http/ui_auth.go | 4 + .../transport/http/ui_auth_internal_test.go | 15 +++ .../userservice/transport/http/handlers.go | 7 ++ .../userservice/transport/http/server_test.go | 9 ++ 15 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 coordinator/internal/agent/tls.go diff --git a/README.md b/README.md index 2ca809f..8f3e94a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emi Set `SCIMESH_AUTO_START=0` to install without starting anything. The old demo control room was removed: `/ui` is the admin console. +**HTTPS (TLS):** serve can encrypt everything with a self-signed certificate — +`coordinator serve --tls-autogen` generates one into the data directory and +prints its fingerprint; workers trust it via `SCIMESH_CA_CERT=` (or the +explicit opt-in `SCIMESH_INSECURE_SKIP_VERIFY=1`). Custom certificates go +through `--tls-cert`/`--tls-key` (or `SCIMESH_TLS_CERT`/`SCIMESH_TLS_KEY`). +Without TLS, traffic on the LAN is plaintext. New UI accounts can be closed +with `--disable-registration` (or `SCIMESH_DISABLE_REGISTRATION=1`). + To remove a component, run the matching uninstaller (data is kept unless you pass `--purge`): diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 9b84204..1949c5d 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -223,8 +223,16 @@ func runWithConfig(cfg infra.Config) error { // deps.ready backs /health: readiness means the database answers, not just // that the process is alive. - api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir) - err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken)) + api := httptransport.NewServerWithOptions( + useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, + cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready, + httptransport.ServerOptions{DisableRegistration: cfg.DisableRegistration}, + cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir) + var tlsOpts []infra.TLSConfig + if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { + tlsOpts = []infra.TLSConfig{{CertFile: cfg.TLSCertFile, KeyFile: cfg.TLSKeyFile}} + } + err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken), tlsOpts...) // Shutdown order matters, and defers alone cannot express it (they run // LIFO, so the deferred stop() would fire *after* the wait below). diff --git a/coordinator/cmd/coordinator/serve_cmd.go b/coordinator/cmd/coordinator/serve_cmd.go index a19e4c0..2f8b62c 100644 --- a/coordinator/cmd/coordinator/serve_cmd.go +++ b/coordinator/cmd/coordinator/serve_cmd.go @@ -6,10 +6,16 @@ import ( "github.com/emil28092005/SciMesh/coordinator/internal/agent" "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" "encoding/hex" + "encoding/pem" "flag" "fmt" "log/slog" + "math/big" "net" "os" "os/exec" @@ -43,6 +49,10 @@ func runServe(args []string) error { email = flags.String("admin-email", "admin@scimesh.local", "admin account email") password = flags.String("admin-password", "", "admin password (generated on first run when empty)") publicURL = flags.String("public-url", "", "browser/worker-facing coordinator URL (default: http://)") + tlsCert = flags.String("tls-cert", "", "TLS certificate file (enables HTTPS together with --tls-key)") + tlsKey = flags.String("tls-key", "", "TLS private key file") + tlsGen = flags.Bool("tls-autogen", false, "generate a self-signed certificate in the data dir and serve HTTPS") + noReg = flags.Bool("disable-registration", false, "forbid new UI accounts") ) if err := flags.Parse(args); err != nil { return err @@ -107,10 +117,33 @@ func runServe(args []string) error { defer stopAgents(agents) // 6. The coordinator server itself. + // TLS: explicit cert/key win; --tls-autogen creates a self-signed pair in + // the data dir on first use (fingerprint printed for pinning). + tlsCertFile, tlsKeyFile := *tlsCert, *tlsKey + if tlsCertFile == "" && tlsKeyFile == "" && *tlsGen { + tlsCertFile = filepath.Join(*dataDir, "tls.crt") + tlsKeyFile = filepath.Join(*dataDir, "tls.key") + if _, err := os.Stat(tlsCertFile); err != nil { + fingerprint, err := generateSelfSigned(tlsCertFile, tlsKeyFile, *dataDir, *addr) + if err != nil { + return fmt.Errorf("generate TLS certificate: %w", err) + } + log.Info("generated a self-signed TLS certificate", "cert", tlsCertFile, "fingerprint", fingerprint) + fmt.Printf("TLS: self-signed certificate generated (SHA-256 fingerprint %s).\n", fingerprint) + fmt.Printf("Trust it on workers with SCIMESH_CA_CERT=%s (or SCIMESH_INSECURE_SKIP_VERIFY=1).\n", tlsCertFile) + } + } + if (tlsCertFile == "") != (tlsKeyFile == "") { + return fmt.Errorf("--tls-cert and --tls-key must be provided together") + } + cfg := infra.Config{ Addr: *addr, DatabaseEngine: "sqlite", DBPath: filepath.Join(*dataDir, "scimesh.db"), + TLSCertFile: tlsCertFile, + TLSKeyFile: tlsKeyFile, + DisableRegistration: *noReg || os.Getenv("SCIMESH_DISABLE_REGISTRATION") == "1", Token: workerToken, JWTSecret: jwtSecret, UserserviceURL: "http://" + usersAddr, @@ -369,3 +402,72 @@ func serveURLs(addr, publicURL string) (agentURL, resolvedPublic string) { return agentURL, "http://" + addr } } + +// generateSelfSigned writes a self-signed certificate for the listen host and +// the machine's LAN addresses, so HTTPS works without a CA on a trusted +// network. The returned value is the certificate's SHA-256 fingerprint. +func generateSelfSigned(certPath, keyPath, dataDir, addr string) (string, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + host = strings.Trim(host, "[]") + ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")} + if parsed := net.ParseIP(host); parsed != nil && !parsed.IsUnspecified() { + ips = append(ips, parsed) + } else if host == "" || parsed != nil { + // Wildcard listen addresses: add every local interface address. + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + ips = append(ips, ipnet.IP) + } + } + } + } + names := []string{"localhost", host} + if host != "" && host != "localhost" { + names = append(names, host) + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", err + } + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "SciMesh coordinator"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: names, + IPAddresses: ips, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return "", err + } + if err := os.MkdirAll(dataDir, 0o750); err != nil { + return "", err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + // The certificate is shared with workers via SCIMESH_CA_CERT, so it must + // stay readable; the key stays private. + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { //nolint:gosec // G306: cert is public by design + return "", err + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return "", err + } + sum := sha256.Sum256(der) + var parts []string + for _, b := range sum[:] { + parts = append(parts, fmt.Sprintf("%02x", b)) + } + return strings.Join(parts, ":"), nil +} diff --git a/coordinator/internal/agent/auth.go b/coordinator/internal/agent/auth.go index 546ca1f..12926cc 100644 --- a/coordinator/internal/agent/auth.go +++ b/coordinator/internal/agent/auth.go @@ -75,7 +75,7 @@ func (p *WorkerKeyToken) exchangeLocked() error { return err } request.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: p.timeout} + client := &http.Client{Timeout: p.timeout, Transport: tlsTransport(nil)} response, err := client.Do(request) if err != nil { return fmt.Errorf("worker key exchange request failed") diff --git a/coordinator/internal/agent/check.go b/coordinator/internal/agent/check.go index 306d9b9..fa612ba 100644 --- a/coordinator/internal/agent/check.go +++ b/coordinator/internal/agent/check.go @@ -42,7 +42,7 @@ func checkHTTP(ctx context.Context, url string, timeout time.Duration) (CheckIte if err != nil { return CheckItem{Name: "coordinator", OK: false, Detail: "invalid URL"}, "" } - resp, err := http.DefaultClient.Do(req) + resp, err := (&http.Client{Timeout: timeout, Transport: tlsTransport(nil)}).Do(req) if err != nil { detail := err.Error() if strings.Contains(detail, "connection refused") { @@ -148,7 +148,7 @@ func CheckAuth(ctx context.Context, url, token, workerKey, userserviceURL string item.Detail = "no credential configured — will be checked at registration" return item } - client := &http.Client{Timeout: 30 * time.Second} + client := &http.Client{Timeout: 30 * time.Second, Transport: tlsTransport(nil)} if workerKey != "" && userserviceURL != "" { payload, _ := json.Marshal(map[string]string{"key": workerKey}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(userserviceURL, "/")+"/worker-tokens/exchange", strings.NewReader(string(payload))) diff --git a/coordinator/internal/agent/client.go b/coordinator/internal/agent/client.go index 4469a48..2418522 100644 --- a/coordinator/internal/agent/client.go +++ b/coordinator/internal/agent/client.go @@ -60,10 +60,12 @@ func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Cli timeout: timeout, apiClient: &http.Client{ Timeout: timeout, + Transport: tlsTransport(nil), CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, dlClient: &http.Client{ - Timeout: transferTimeout, + Timeout: transferTimeout, + Transport: tlsTransport(nil), CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") diff --git a/coordinator/internal/agent/client_test.go b/coordinator/internal/agent/client_test.go index 8e21ae5..dd785b1 100644 --- a/coordinator/internal/agent/client_test.go +++ b/coordinator/internal/agent/client_test.go @@ -1,8 +1,10 @@ package agent import ( + "context" "crypto/sha256" "encoding/json" + "encoding/pem" "errors" "fmt" "net/http" @@ -237,3 +239,58 @@ func TestNewClientTransferTimeoutExceedsAPITimeout(t *testing.T) { t.Errorf("transfer timeout = %v, want 4x the api timeout", short.dlClient.Timeout) } } + +func TestTLSClientHonoursSkipVerify(t *testing.T) { + t.Setenv("SCIMESH_INSECURE_SKIP_VERIFY", "1") + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + client := tlsClient(5 * time.Second) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/health", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("TLS server must be reachable with skip-verify: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d", resp.StatusCode) + } +} + +func TestTLSClientFailsWithoutTrust(t *testing.T) { + t.Setenv("SCIMESH_INSECURE_SKIP_VERIFY", "") + t.Setenv("SCIMESH_CA_CERT", "") + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer server.Close() + client := tlsClient(5 * time.Second) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/health", nil) + if resp, err := client.Do(req); err == nil { + _ = resp.Body.Close() + t.Error("untrusted TLS server must fail verification") + } +} + +func TestTLSClientTrustsCAPool(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + ca := server.Certificate() + path := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw}), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SCIMESH_CA_CERT", path) + t.Setenv("SCIMESH_INSECURE_SKIP_VERIFY", "") + client := tlsClient(5 * time.Second) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/health", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("CA-trusted TLS server must verify: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d", resp.StatusCode) + } +} diff --git a/coordinator/internal/agent/tls.go b/coordinator/internal/agent/tls.go new file mode 100644 index 0000000..cf5554d --- /dev/null +++ b/coordinator/internal/agent/tls.go @@ -0,0 +1,69 @@ +package agent + +import ( + "crypto/tls" + "crypto/x509" + "log/slog" + "net" + "net/http" + "os" + "time" +) + +// tlsClient builds an HTTP client whose transport trusts the coordinator's +// TLS certificate: +// +// - SCIMESH_CA_CERT=/path/to/ca.pem adds a root CA (for self-signed or +// private-CA coordinators); +// - SCIMESH_INSECURE_SKIP_VERIFY=1 disables verification entirely — only +// for trusted LANs where a self-signed certificate was auto-generated. +// +// Both settings are deliberately opt-in and noisy: a coordinator without them +// fails to verify, never silently downgrades. +func tlsClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, Transport: tlsTransport(nil)} +} + +// tlsTransport configures a transport honouring the trust environment. +func tlsTransport(base *http.Transport) *http.Transport { + if base == nil { + base = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + } + caPath := os.Getenv("SCIMESH_CA_CERT") + skip := os.Getenv("SCIMESH_INSECURE_SKIP_VERIFY") == "1" + if caPath == "" && !skip { + return base + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} //nolint:gosec // G402: min TLS 1.2 by default + if caPath != "" { + //nolint:gosec // G304: SCIMESH_CA_CERT is operator-configured + pem, err := os.ReadFile(caPath) + if err != nil { + slog.Warn("could not read SCIMESH_CA_CERT", "path", caPath, "err", err) + return base + } + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pem) { + slog.Warn("SCIMESH_CA_CERT contained no usable certificates", "path", caPath) + return base + } + tlsConfig.RootCAs = pool + } + if skip { + // G402 is about production code paths; here the operator explicitly + // opts into an unverified LAN trust root, so the bypass is intended. + tlsConfig.InsecureSkipVerify = true //nolint:gosec // G402: operator opt-in for self-signed LAN certs + slog.Warn("SCIMESH_INSECURE_SKIP_VERIFY=1: TLS certificate verification is disabled") + } + base.TLSClientConfig = tlsConfig + return base +} diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go index 92c38b0..0bf6407 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -55,6 +55,14 @@ type Config struct { // Directory of the built MkDocs site (site/) served at /ui/docs/. Empty // disables the docs route; the UI shows a hint page instead. DocsDir string + // TLSCertFile and TLSKeyFile enable HTTPS when both are set. Self-signed + // certificates are fine for a trusted LAN; workers then need + // SCIMESH_CA_CERT or SCIMESH_INSECURE_SKIP_VERIFY to connect. + TLSCertFile string + TLSKeyFile string + // DisableRegistration forbids new UI accounts; the bootstrap admin still + // works. Existing accounts and worker keys are unaffected. + DisableRegistration bool // Upper bound on an uploaded dataset or artifact body, in bytes. MaxUploadBytes int64 @@ -123,6 +131,9 @@ func LoadConfig() (Config, error) { LogFile: os.Getenv("LOG_FILE"), StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), DocsDir: os.Getenv("SCIMESH_DOCS_DIR"), + TLSCertFile: os.Getenv("SCIMESH_TLS_CERT"), + TLSKeyFile: os.Getenv("SCIMESH_TLS_KEY"), + DisableRegistration: os.Getenv("SCIMESH_DISABLE_REGISTRATION") == "1", MaxUploadBytes: 1 << 30, // 1 GiB DBMaxConns: 10, DBConnectTimeout: 30 * time.Second, diff --git a/coordinator/internal/infra/server.go b/coordinator/internal/infra/server.go index 9858c19..bafa2a8 100644 --- a/coordinator/internal/infra/server.go +++ b/coordinator/internal/infra/server.go @@ -13,17 +13,25 @@ import ( const shutdownGrace = 15 * time.Second // Run serves handler until ctx is cancelled, then drains in-flight requests. -func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error { +func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler, tls ...TLSConfig) error { srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, } + secure := len(tls) > 0 && tls[0].CertFile != "" && tls[0].KeyFile != "" // Buffered so this goroutine can exit even when nobody reads the channel // (the ctx.Done branch below) — an unbuffered send would leak it forever. errCh := make(chan error, 1) go func() { + if secure { + log.Info("coordinator listening (https)", "addr", addr) + if err := srv.ListenAndServeTLS(tls[0].CertFile, tls[0].KeyFile); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + return + } log.Info("coordinator listening", "addr", addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err @@ -72,3 +80,9 @@ func RunPeriodic(ctx context.Context, log *slog.Logger, name string, interval ti } } } + +// TLSConfig enables HTTPS for the coordinator server. +type TLSConfig struct { + CertFile string + KeyFile string +} diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 14b2700..3c89ce8 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -51,6 +51,8 @@ type Server struct { // userserviceURL is the base URL the UI proxies login/registration to. Empty // keeps the static basic-auth UI. userserviceURL string + // disableRegistration forbids new accounts; login keeps working. + disableRegistration bool // publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs // rendered into the worker-enrollment command. Either may be empty; the // template falls back (own origin / userserviceURL respectively). @@ -70,6 +72,19 @@ type Server struct { func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration, maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error, publicURLs ...string) *Server { + return NewServerWithOptions(uc, log, requestTimeout, heartbeatInterval, maxUploadBytes, jwtSecret, userserviceURL, m, ready, ServerOptions{}, publicURLs...) +} + +// ServerOptions configures non-positional behaviour of the operator UI. +type ServerOptions struct { + DisableRegistration bool +} + +// NewServerWithOptions is NewServer plus explicit options; the option-less +// variant exists so existing call sites and tests need no change. +func NewServerWithOptions(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration, + maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error, + opts ServerOptions, publicURLs ...string) *Server { if m == nil { m = metrics.New() } @@ -88,6 +103,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval } return &Server{ uc: uc, + disableRegistration: opts.DisableRegistration, log: log, requestTimeout: requestTimeout, heartbeatInterval: heartbeatInterval, diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go index 405e0fd..1e16e37 100644 --- a/coordinator/internal/transport/http/ui_auth.go +++ b/coordinator/internal/transport/http/ui_auth.go @@ -108,6 +108,10 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) { // user to the login page. The new account is a plain user until an admin // promotes or verifies it. func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) { + if s.disableRegistration { + http.Redirect(w, r, "/ui/register?error=registration+disabled", http.StatusSeeOther) + return + } email, password := r.FormValue("email"), r.FormValue("password") status, _, err := s.callUserservice(r.Context(), "/register", email, password) diff --git a/coordinator/internal/transport/http/ui_auth_internal_test.go b/coordinator/internal/transport/http/ui_auth_internal_test.go index 6394b06..f99d3c9 100644 --- a/coordinator/internal/transport/http/ui_auth_internal_test.go +++ b/coordinator/internal/transport/http/ui_auth_internal_test.go @@ -226,3 +226,18 @@ func TestLoginFormRendersNext(t *testing.T) { t.Error("login form must not render next when absent") } } + +func TestRegistrationDisabledRejectsNewAccounts(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("userservice must not be called when registration is disabled") + })) + defer stub.Close() + s := newLoginServer(stub) + s.disableRegistration = true + + rec := httptest.NewRecorder() + s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"a@b.io"}, "password": {"pw"}})) + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "registration+disabled") { + t.Errorf("got %d -> %q, want 303 to the registration-disabled error", rec.Code, rec.Header().Get("Location")) + } +} diff --git a/coordinator/internal/userservice/transport/http/handlers.go b/coordinator/internal/userservice/transport/http/handlers.go index c2a421c..b26e844 100644 --- a/coordinator/internal/userservice/transport/http/handlers.go +++ b/coordinator/internal/userservice/transport/http/handlers.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "net/http" + "os" "github.com/google/uuid" @@ -38,6 +39,12 @@ func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) { // handleRegister creates an account. It returns 201 with the public user view, // 409 if the email is taken, or 400 on a malformed body / weak password. func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) { + // Standalone deployments can close self-service registration while keeping + // the bootstrap admin and existing accounts (USERSERVICE_DISABLE_REGISTRATION=1). + if os.Getenv("USERSERVICE_DISABLE_REGISTRATION") == "1" { + writeJSON(w, http.StatusForbidden, errorResponse{Error: "registration disabled", RequestID: requestIDFrom(r.Context())}) + return + } var req registerRequest if !decodeJSON(w, r, &req) { return diff --git a/coordinator/internal/userservice/transport/http/server_test.go b/coordinator/internal/userservice/transport/http/server_test.go index a1c7699..4789efc 100644 --- a/coordinator/internal/userservice/transport/http/server_test.go +++ b/coordinator/internal/userservice/transport/http/server_test.go @@ -479,3 +479,12 @@ func TestAdminListsUsersAndKeys(t *testing.T) { t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code) } } + +func TestRegistrationDisabledEnv(t *testing.T) { + t.Setenv("USERSERVICE_DISABLE_REGISTRATION", "1") + h := newTestServer() + rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": "blocked@x.io", "password": "pw"}) + if rec.Code != http.StatusForbidden { + t.Errorf("register when disabled: got %d, want 403", rec.Code) + } +}