Add built-in TLS (self-signed autogen, CA pinning) and optional closed registration
coordinator / test (push) Canceled after 0s
python / test (push) Canceled after 0s
release / binaries (amd64, darwin) (push) Canceled after 0s
release / binaries (amd64, linux) (push) Canceled after 0s
release / binaries (amd64, windows) (push) Canceled after 0s
release / binaries (arm64, darwin) (push) Canceled after 0s
release / binaries (arm64, linux) (push) Canceled after 0s
release / binaries (arm64, windows) (push) Canceled after 0s
release / wheel (push) Canceled after 0s
release / image (push) Canceled after 0s
users / test (push) Canceled after 0s
release / release (push) Canceled after 0s
coordinator / test (push) Canceled after 0s
python / test (push) Canceled after 0s
release / binaries (amd64, darwin) (push) Canceled after 0s
release / binaries (amd64, linux) (push) Canceled after 0s
release / binaries (amd64, windows) (push) Canceled after 0s
release / binaries (arm64, darwin) (push) Canceled after 0s
release / binaries (arm64, linux) (push) Canceled after 0s
release / binaries (arm64, windows) (push) Canceled after 0s
release / wheel (push) Canceled after 0s
release / image (push) Canceled after 0s
users / test (push) Canceled after 0s
release / release (push) Canceled after 0s
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user