Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,20 @@ linters:
deny:
- pkg: github.com/MustardSeedNetworks/stem/internal/api
desc: "sse is a leaf — the broadcaster engine must not import the api transport layer; wire at the api layer"
# tlsutil is a leaf of internal/api (ADR-0011): it depends only on
# stdlib, golang.org/x/crypto (ACME), and internal/logging — never on
# the api transport layer itself. This depguard rule enforces that
# boundary so a future accidental upward import fails CI rather than
# silently coupling the leaf back into the transport layer.
# Test files are excluded: the _test.go external package imports the
# leaf itself, which is expected.
"api-tlsutil-isolated":
files:
- "**/internal/api/tlsutil/**"
- "!$test"
deny:
- pkg: github.com/MustardSeedNetworks/stem/internal/api
desc: "tlsutil is a leaf — cert provisioning, ACME, and TLS config take no api types, so it never imports the api transport layer"

embeddedstructfieldcheck:
# Checks that sync.Mutex and sync.RWMutex are not used as embedded fields.
Expand Down
29 changes: 26 additions & 3 deletions docs/adr/0011-internal-api-sub-package-decomposition.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ The api transport layer wires leaves at construction time; no leaf knows about
| Slice | Package | PR |
|-------|---------|-----|
| Rate limiter | `internal/api/ratelimit` | #451 |
| SSE broadcaster | `internal/api/sse` | this ADR |
| SSE broadcaster | `internal/api/sse` | #452 |
| TLS utilities | `internal/api/tlsutil` | this ADR |

### SSE slice (this ADR)

Expand All @@ -47,17 +48,39 @@ reference it without duplicating the value; the api-layer file (`sse.go`)
re-derives its own constant from the same numeric literal to avoid an import
dependency in the other direction.

### TLS slice (this ADR)

`internal/api/tlsutil` holds the TLS material provisioning: the `Config`/
`ACMEConfig` settings structs, `ServerConfig` (the TLS 1.3 [tls.Config]
template), `EnsureSelfSignedCert` (self-signed cert generation), the ACME
manager constructors (`NewACMEManager`, `ACMETLSConfig`), and the
`FingerprintCache` (active-cert SHA-256 fingerprint, exposed via `/__version`).
It depends only on the standard library, `golang.org/x/crypto` (ACME), and
`internal/logging`.

The HTTP serving lifecycle stays in `internal/api`: `startTLS`/`startTLSWithACME`
(listener binding + the port-80 HTTP-01 challenge server) and the two `Server`
methods that bridge the cache to the request path — `activeCertPath` (resolves
the served cert path mirroring `startTLS`'s priority order) and
`tlsFingerprintForResponse`. `DefaultCertsDir` is exported so the api layer
resolves the self-signed default path without duplicating the literal.

Two constants that lived in the old `tls.go` const block but are unrelated to
TLS were rehomed to the api layer rather than dragged into the leaf:
`refreshMultiplier` (auth cookie lifetime → `handlers_auth.go`) and
`acmeReadHeaderTimeoutSec` (transport-layer challenge-server timeout →
`server.go`).

### Future slices (candidates)

| Concern | Notes |
|---------|-------|
| TLS utilities | `ensureSelfSignedCert`, `createTLSConfig`, ACME helpers |
| CORS logic | RFC 1918 origin validation |

## Consequences

- The leaf boundary is statically enforced by depguard (`api-sse-isolated`,
`api-ratelimit-isolated` rules in `.golangci.yml`).
`api-ratelimit-isolated`, `api-tlsutil-isolated` rules in `.golangci.yml`).
- `go vet` + `golangci-lint` catch upward imports at CI time.
- `internal/api` package size decreases incrementally with each slice.
- No behaviour change: endpoints, event types, and publish sites are identical.
5 changes: 5 additions & 0 deletions internal/api/handlers_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import (
"github.com/MustardSeedNetworks/stem/internal/logging"
)

// refreshMultiplier sets how much longer the refresh-token cookie lives than
// the access token: the refresh cookie expires after sessionDuration *
// refreshMultiplier. Shared by the login, refresh, and MFA auth handlers.
const refreshMultiplier = 24 // Refresh token lasts 24x longer than access token

// handleAuthLogin issues JWT tokens for valid credentials.
// Sets httpOnly cookies for browser auth and returns tokens for API clients.
//
Expand Down
60 changes: 53 additions & 7 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,15 @@ import (
"net/url"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"

"github.com/MustardSeedNetworks/stem/internal/api/ratelimit"
"github.com/MustardSeedNetworks/stem/internal/api/sse"
"github.com/MustardSeedNetworks/stem/internal/api/tlsutil"
"github.com/MustardSeedNetworks/stem/internal/auth"
"github.com/MustardSeedNetworks/stem/internal/license"
"github.com/MustardSeedNetworks/stem/internal/logging"
Expand Down Expand Up @@ -157,7 +159,7 @@ type Server struct {
currentModule string
authLimiter *ratelimit.RateLimiter // Rate limiter for auth endpoints (5/min)
apiLimiter *ratelimit.RateLimiter // Rate limiter for standard API endpoints (100/min)
tlsConfig TLSConfig // TLS configuration for HTTPS
tlsConfig tlsutil.Config // TLS configuration for HTTPS
cookieConfig auth.CookieConfig // Cookie configuration for secure auth
corsAllowPrivate bool // STEM_CORS_ALLOW_PRIVATE: reflect RFC1918 cross-origins (default off)
routeManifest []route // capability registry: routes registered via register() (route.go)
Expand All @@ -168,7 +170,7 @@ type Server struct {
recoveryTokenManager *auth.RecoveryTokenManager // Recovery token manager for password recovery
dataDir string // Application data directory for recovery files
acmeChallengeServer *http.Server // HTTP-01 challenge server for ACME
tlsFingerprint tlsFingerprintCache // Cached SHA-256 fingerprint of the active TLS cert (exposed via /__version)
tlsFingerprint tlsutil.FingerprintCache // Cached SHA-256 fingerprint of the active TLS cert (exposed via /__version)
background *BackgroundComponents // Run-scoped long-lived goroutines (reflector-stats SSE publisher); ordered Start/Stop (background.go)

// executorResolver maps a module name to a factory producing a
Expand Down Expand Up @@ -292,7 +294,7 @@ func NewServer(port int) (*Server, error) {
s.currentModule = ""
s.authLimiter = ratelimit.NewAuthRateLimiter()
s.apiLimiter = ratelimit.NewAPIRateLimiter()
s.tlsConfig = TLSConfig{
s.tlsConfig = tlsutil.Config{
Enabled: true,
CertFile: os.Getenv("STEM_TLS_CERT"),
KeyFile: os.Getenv("STEM_TLS_KEY"),
Expand Down Expand Up @@ -814,6 +816,10 @@ func (s *Server) Run() error {
}
}

// acmeReadHeaderTimeoutSec is the timeout for reading ACME HTTP-01 challenge
// request headers on the port-80 challenge server.
const acmeReadHeaderTimeoutSec = 10

// startTLS starts the server with TLS encryption on the already-bound
// listener. Priority order: ACME → manual certificates → self-signed.
func (s *Server) startTLS(ln net.Listener) error {
Expand All @@ -832,14 +838,14 @@ func (s *Server) startTLS(ln net.Listener) error {
// Priority 3: Self-signed certificate (fallback)
if certFile == "" || keyFile == "" {
var err error
certFile, keyFile, err = ensureSelfSignedCert(s.tlsConfig.CertsDir)
certFile, keyFile, err = tlsutil.EnsureSelfSignedCert(s.tlsConfig.CertsDir)
if err != nil {
return fmt.Errorf("failed to generate self-signed certificate: %w", err)
}
}

// Configure TLS 1.3 minimum.
s.httpServer.TLSConfig = createTLSConfig()
s.httpServer.TLSConfig = tlsutil.ServerConfig()

logging.Info("Starting HTTPS server",
"addr", s.httpServer.Addr,
Expand All @@ -858,13 +864,13 @@ func (s *Server) startTLS(ln net.Listener) error {
// on the already-bound listener. Ported from Seed project for automatic
// certificate management.
func (s *Server) startTLSWithACME(ln net.Listener) error {
manager, err := createACMEManager(s.tlsConfig.ACME)
manager, err := tlsutil.NewACMEManager(s.tlsConfig.ACME)
if err != nil {
return fmt.Errorf("create ACME manager: %w", err)
}

// Configure TLS with ACME
s.httpServer.TLSConfig = createACMETLSConfig(manager)
s.httpServer.TLSConfig = tlsutil.ACMETLSConfig(manager)

logging.Info("Starting HTTPS server with ACME",
"addr", s.httpServer.Addr,
Expand Down Expand Up @@ -892,6 +898,46 @@ func (s *Server) startTLSWithACME(ln net.Listener) error {
return nil
}

// activeCertPath returns the cert file path the server will use, or "" if the
// server is running in HTTP mode. Mirrors the priority order of startTLS so
// /__version reports the same cert that is actually served.
func (s *Server) activeCertPath() string {
if !s.tlsConfig.Enabled {
return ""
}
if s.tlsConfig.ACME.Enabled {
// ACME certs live in the autocert cache; they are not a single
// stable file path we can fingerprint here. Return empty rather
// than guessing.
return ""
}
if s.tlsConfig.CertFile != "" {
return s.tlsConfig.CertFile
}
// Fall back to the self-signed default path used by EnsureSelfSignedCert.
certsDir := s.tlsConfig.CertsDir
if certsDir == "" {
certsDir = tlsutil.DefaultCertsDir
}
return filepath.Join(certsDir, "server.crt")
}

// tlsFingerprintForResponse returns the cached fingerprint (computing it on
// first call). Errors are swallowed and reported as an empty string so
// /__version always returns a stable shape even if the cert is missing or
// unreadable; an empty value is a signal to the operator to investigate.
func (s *Server) tlsFingerprintForResponse() string {
path := s.activeCertPath()
if path == "" {
return ""
}
fp, err := s.tlsFingerprint.Get(path)
if err != nil {
return ""
}
return strings.TrimSpace(fp)
}

// Shutdown gracefully shuts down the server.
// Stops running tests and drains HTTP connections.
func (s *Server) Shutdown() error {
Expand Down
139 changes: 0 additions & 139 deletions internal/api/server_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/MustardSeedNetworks/stem/internal/api/ratelimit"
Expand Down Expand Up @@ -922,108 +921,6 @@ func TestSecurityHeadersMiddleware_AllHeaders(t *testing.T) {
}
}

// TestDefaultTLSConfig tests the DefaultTLSConfig function.
func TestDefaultTLSConfig(t *testing.T) {
config := DefaultTLSConfig()

if !config.Enabled {
t.Error("Expected TLS to be enabled by default")
}
if config.CertFile != "" {
t.Errorf("Expected empty CertFile, got '%s'", config.CertFile)
}
if config.KeyFile != "" {
t.Errorf("Expected empty KeyFile, got '%s'", config.KeyFile)
}
if config.CertsDir != defaultCertsDir {
t.Errorf("Expected CertsDir '%s', got '%s'", defaultCertsDir, config.CertsDir)
}
}

// TestCreateTLSConfig tests the createTLSConfig function.
func TestCreateTLSConfig(t *testing.T) {
config := createTLSConfig()

if config == nil {
t.Fatal("Expected non-nil TLS config")
}

if config.MinVersion != tls.VersionTLS13 {
t.Errorf("Expected TLS 1.3 min version, got %d", config.MinVersion)
}
}

// TestEnsureSelfSignedCert tests the ensureSelfSignedCert function.
func TestEnsureSelfSignedCert(t *testing.T) {
// Create a temporary directory for test certificates.
tempDir := t.TempDir()

t.Run("generate new certificates", func(t *testing.T) {
certFile, keyFile, err := ensureSelfSignedCert(tempDir)
if err != nil {
t.Fatalf("ensureSelfSignedCert() error: %v", err)
}

if certFile == "" {
t.Error("Expected non-empty certFile")
}
if keyFile == "" {
t.Error("Expected non-empty keyFile")
}

// Verify files exist.
_, certStatErr := os.Stat(certFile)
if certStatErr != nil {
t.Errorf("Certificate file does not exist: %v", certStatErr)
}
_, keyStatErr := os.Stat(keyFile)
if keyStatErr != nil {
t.Errorf("Key file does not exist: %v", keyStatErr)
}
})

t.Run("use existing certificates", func(t *testing.T) {
// Should reuse existing certificates.
certFile, keyFile, err := ensureSelfSignedCert(tempDir)
if err != nil {
t.Fatalf("ensureSelfSignedCert() error: %v", err)
}

if certFile == "" || keyFile == "" {
t.Error("Expected non-empty certificate paths")
}
})

t.Run("empty certs dir defaults to default", func(t *testing.T) {
// This would use the default certs dir.
// Skip if we don't want to pollute the filesystem.
t.Skip("Skipping to avoid creating files in default location")
})
}

// TestTLSConfigStruct tests the TLSConfig struct.
func TestTLSConfigStruct(t *testing.T) {
config := TLSConfig{
Enabled: true,
CertFile: "/path/to/cert.pem",
KeyFile: "/path/to/key.pem",
CertsDir: "/path/to/certs",
}

if !config.Enabled {
t.Error("Expected Enabled to be true")
}
if config.CertFile != "/path/to/cert.pem" {
t.Errorf("Unexpected CertFile: %s", config.CertFile)
}
if config.KeyFile != "/path/to/key.pem" {
t.Errorf("Unexpected KeyFile: %s", config.KeyFile)
}
if config.CertsDir != "/path/to/certs" {
t.Errorf("Unexpected CertsDir: %s", config.CertsDir)
}
}

// TestUpdateStats tests the UpdateStats function.
func TestUpdateStats(t *testing.T) {
t.Setenv("STEM_AUTH_USERNAME", "statsuser")
Expand Down Expand Up @@ -2855,42 +2752,6 @@ func TestReflectorStatsResponse(t *testing.T) {
}
}

// TestEnsureSelfSignedCertExistingFiles tests ensureSelfSignedCert with existing files.
func TestEnsureSelfSignedCertExistingFiles(t *testing.T) {
tempDir := t.TempDir()

// First call - generate.
certFile, keyFile, err := ensureSelfSignedCert(tempDir)
if err != nil {
t.Fatalf("First call error: %v", err)
}

// Get file info.
certInfo, _ := os.Stat(certFile)
keyInfo, _ := os.Stat(keyFile)

// Second call - should reuse.
certFile2, keyFile2, err := ensureSelfSignedCert(tempDir)
if err != nil {
t.Fatalf("Second call error: %v", err)
}

if certFile != certFile2 || keyFile != keyFile2 {
t.Error("Expected same paths on second call")
}

// Files should not have changed.
certInfo2, _ := os.Stat(certFile)
keyInfo2, _ := os.Stat(keyFile)

if certInfo.ModTime() != certInfo2.ModTime() {
t.Error("Cert file should not have been regenerated")
}
if keyInfo.ModTime() != keyInfo2.ModTime() {
t.Error("Key file should not have been regenerated")
}
}

// TestBuildReflectorConfigUpdateWithExecutor tests buildReflectorConfigUpdate with executor.
func TestBuildReflectorConfigUpdateWithExecutor(t *testing.T) {
t.Setenv("STEM_AUTH_USERNAME", "buildcfgexecuser")
Expand Down
Loading
Loading