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
5 changes: 2 additions & 3 deletions dev/k8s/manifests/frontline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ metadata:
data:
unkey.toml: |
region = "local.dev"
http_port = 7070
https_port = 7443
challenge_port = 7070
http_port = 7443
apex_domain = "unkey.local"
ctrl_addr = "http://ctrl-api:7091"

[tls]
enabled = true
cert_file = "/certs/unkey.local.crt"
key_file = "/certs/unkey.local.key"

Expand Down
8 changes: 6 additions & 2 deletions pkg/config/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,13 @@ type VaultConfig struct {
Token string `toml:"token"`
}

// TLSFiles holds paths to PEM-encoded certificate and private key files for TLS.
// TLS holds paths to PEM-encoded certificate and private key files for TLS.
// Used for serving HTTPS or mTLS connections.
type TLSFiles struct {
// Disabled defaults to false (TLS enabled). Set Disabled = true to explicitly disable TLS.
type TLS struct {
// Disabled when set to true, disables TLS even when certificate sources are available.
Disabled bool `toml:"disabled"`

// CertFile is the path to a PEM-encoded TLS certificate.
CertFile string `toml:"cert_file"`

Expand Down
2 changes: 1 addition & 1 deletion svc/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ type Config struct {

// TLS provides filesystem paths for HTTPS certificate and key.
// See [config.TLSFiles].
TLS config.TLSFiles `toml:"tls"`
TLS config.TLS `toml:"tls"`
Comment on lines 93 to +95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale doc comment references TLSFiles.

The comment on line 94 still references [config.TLSFiles] but the type is now config.TLS.

📝 Proposed fix
 	// TLS provides filesystem paths for HTTPS certificate and key.
-	// See [config.TLSFiles].
+	// See [config.TLS].
 	TLS config.TLS `toml:"tls"`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@svc/api/config.go` around lines 93 - 95, The comment for the TLS field is
stale — it references config.TLSFiles but the field type is config.TLS; update
the comment above the TLS field (symbol TLS of the config struct in
svc/api/config.go) to either remove the [config.TLSFiles] link or replace it
with a correct reference to config.TLS (or the appropriate doc string/type name)
so the comment matches the actual type and points to the right documentation.


// Vault configures the encryption/decryption service. See [config.VaultConfig].
Vault config.VaultConfig `toml:"vault"`
Expand Down
3 changes: 2 additions & 1 deletion svc/api/integration/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ func (h *Harness) RunAPI(config ApiConfig) *ApiCluster {
PrometheusPort: 0,
},
},
TLS: sharedconfig.TLSFiles{
TLS: sharedconfig.TLS{
Disabled: true,
CertFile: "",
KeyFile: "",
},
Expand Down
1 change: 1 addition & 0 deletions svc/frontline/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go_library(
srcs = [
"config.go",
"run.go",
"tls.go",
],
importpath = "github.com/unkeyed/unkey/svc/frontline",
visibility = ["//visibility:public"],
Expand Down
23 changes: 14 additions & 9 deletions svc/frontline/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ type Config struct {
// Set at runtime; not read from the config file.
Image string `toml:"-"`

// HttpPort is the TCP port the HTTP challenge server binds to.
HttpPort int `toml:"http_port" config:"default=7070,min=1,max=65535"`
// ChallengePort is the TCP port the HTTP challenge server binds to.
// Used for ACME HTTP-01 challenges (Let's Encrypt).
ChallengePort int `toml:"challenge_port" config:"default=7070,min=1,max=65535"`

// HttpsPort is the TCP port the HTTPS frontline server binds to.
HttpsPort int `toml:"https_port" config:"default=7443,min=1,max=65535"`
// HttpPort is the TCP port the HTTP frontline server binds to.
// Serves general traffic over HTTPS by default.
HttpPort int `toml:"http_port" config:"default=7443,min=1,max=65535"`

// Region identifies the geographic region where this node is deployed.
// Used for observability, latency optimization, and cross-region routing.
Expand All @@ -48,9 +50,9 @@ type Config struct {
PrometheusPort int `toml:"prometheus_port"`

// TLS provides filesystem paths for HTTPS certificate and key.
// When nil (section omitted), TLS is disabled.
// See [config.TLSFiles].
TLS *config.TLSFiles `toml:"tls"`
// TLS is enabled by default even if omitted
// See [config.TLS].
TLS *config.TLS `toml:"tls"`

// Database configures MySQL connections. See [config.DatabaseConfig].
Database config.DatabaseConfig `toml:"database"`
Expand All @@ -68,9 +70,12 @@ type Config struct {
// Validate checks cross-field constraints that cannot be expressed through
// struct tags alone. It implements [config.Validator] so that [config.Load]
// calls it automatically after tag-level validation.
//
// Currently validates that TLS is either fully configured (both cert and key)
// or explicitly disabled — partial TLS configuration is an error.
func (c *Config) Validate() error {
if c.TLS != nil && (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return fmt.Errorf("both tls.cert_file and tls.key_file must be provided together")
if c.TLS != nil && !c.TLS.Disabled && (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
return fmt.Errorf("both tls.cert_file and tls.key_file must be provided together when TLS is not disabled")
}
return nil
}
66 changes: 27 additions & 39 deletions svc/frontline/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package frontline

import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
Expand All @@ -25,7 +24,6 @@ import (
"github.com/unkeyed/unkey/pkg/ptr"
"github.com/unkeyed/unkey/pkg/rpc/interceptor"
"github.com/unkeyed/unkey/pkg/runner"
pkgtls "github.com/unkeyed/unkey/pkg/tls"
"github.com/unkeyed/unkey/pkg/version"
"github.com/unkeyed/unkey/pkg/zen"
"github.com/unkeyed/unkey/svc/frontline/routes"
Expand Down Expand Up @@ -70,6 +68,9 @@ func Run(ctx context.Context, cfg Config) error {
if err != nil {
return fmt.Errorf("unable to init grafana: %w", err)
}
logger.Info("Grafana tracing initialized", "sampleRate", cfg.Observability.Tracing.SampleRate)
} else {
logger.Warn("Tracing not configured, skipping Grafana OTEL initialization")
}

// Configure global logger with base attributes
Expand Down Expand Up @@ -109,6 +110,8 @@ func Run(ctx context.Context, cfg Config) error {
}
return nil
})
} else {
logger.Warn("Prometheus not configured, skipping metrics server")
}

var vaultClient vault.VaultServiceClient
Expand All @@ -122,7 +125,7 @@ func Run(ctx context.Context, cfg Config) error {
))
logger.Info("Vault client initialized", "url", cfg.Vault.URL)
} else {
logger.Warn("Vault not configured - TLS certificate decryption will be unavailable")
logger.Warn("Vault not configured, dynamic TLS certificate decryption will be unavailable")
}

db, err := db.New(db.Config{
Expand All @@ -137,7 +140,7 @@ func Run(ctx context.Context, cfg Config) error {
// Initialize gossip-based cache invalidation
var broadcaster clustering.Broadcaster
if cfg.Gossip != nil {
logger.Info("Initializing gossip cluster for cache invalidation",
logger.Info("Gossip cluster configured, initializing cache invalidation",
"region", cfg.Region,
"instanceID", cfg.InstanceID,
)
Expand Down Expand Up @@ -177,6 +180,8 @@ func Run(ctx context.Context, cfg Config) error {
broadcaster = gossipBroadcaster
r.Defer(gossipCluster.Close)
}
} else {
logger.Warn("Gossip not configured, cache invalidation will be local only")
}

// Initialize caches
Expand All @@ -198,6 +203,9 @@ func Run(ctx context.Context, cfg Config) error {
TLSCertificateCache: cache.TLSCertificates,
Vault: vaultClient,
})
logger.Info("Certificate manager initialized with vault-backed decryption")
} else {
logger.Warn("Certificate manager not initialized, vault client is nil")
}

// Initialize router service
Expand Down Expand Up @@ -225,36 +233,9 @@ func Run(ctx context.Context, cfg Config) error {
return fmt.Errorf("unable to create proxy service: %w", err)
}

// Create TLS config - either from static files (dev mode) or dynamic certificates (production)
var tlsConfig *pkgtls.Config
if cfg.TLS != nil {
if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" {
// Dev mode: static file-based certificate
fileTLSConfig, tlsErr := pkgtls.NewFromFiles(cfg.TLS.CertFile, cfg.TLS.KeyFile)
if tlsErr != nil {
return fmt.Errorf("failed to load TLS certificate from files: %w", tlsErr)
}
tlsConfig = fileTLSConfig
logger.Info("TLS configured with static certificate files",
"certFile", cfg.TLS.CertFile,
"keyFile", cfg.TLS.KeyFile)
} else if certManager != nil {
// Production mode: dynamic certificates from database/vault
//nolint:exhaustruct
tlsConfig = &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return certManager.GetCertificate(context.Background(), hello.ServerName)
},
MinVersion: tls.VersionTLS12,
// Enable session resumption for faster subsequent connections
// Session tickets allow clients to skip the full TLS handshake
SessionTicketsDisabled: false,
// Let Go's TLS implementation choose optimal cipher suites
// This prefers TLS 1.3 when available (1-RTT vs 2-RTT for TLS 1.2)
PreferServerCipherSuites: false,
}
logger.Info("TLS configured with dynamic certificate manager")
}
tlsConfig, err := buildTlsConfig(cfg, certManager)
if err != nil {
return fmt.Errorf("unable to build tls config: %w", err)
}

acmeClient := ctrl.NewConnectAcmeServiceClient(ctrlv1connect.NewAcmeServiceClient(ptr.P(http.Client{}), cfg.CtrlAddr))
Expand All @@ -267,7 +248,7 @@ func Run(ctx context.Context, cfg Config) error {
}

// Start HTTPS frontline server (main proxy server)
if cfg.HttpsPort > 0 {
if cfg.HttpPort > 0 {
httpsSrv, httpsErr := zen.New(zen.Config{
TLS: tlsConfig,
ReadTimeout: 0,
Expand All @@ -285,23 +266,28 @@ func Run(ctx context.Context, cfg Config) error {
// Register all frontline routes on HTTPS server
routes.Register(httpsSrv, svcs)

httpsListener, httpsListenErr := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HttpsPort))
httpsListener, httpsListenErr := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HttpPort))
if httpsListenErr != nil {
return fmt.Errorf("unable to create HTTPS listener: %w", httpsListenErr)
}

r.Go(func(ctx context.Context) error {
logger.Info("HTTPS frontline server started", "addr", httpsListener.Addr().String())
logger.Info("HTTPS frontline server started",
"addr", httpsListener.Addr().String(),
"tlsEnabled", tlsConfig != nil,
)
serveErr := httpsSrv.Serve(ctx, httpsListener)
if serveErr != nil && !errors.Is(serveErr, context.Canceled) {
return fmt.Errorf("https server error: %w", serveErr)
}
return nil
})
} else {
logger.Warn("HTTPS server not configured, skipping", "httpsPort", cfg.HttpPort)
}

// Start HTTP challenge server (ACME only for Let's Encrypt)
if cfg.HttpPort > 0 {
if cfg.ChallengePort > 0 {
httpSrv, httpErr := zen.New(zen.Config{
TLS: nil,
Flags: nil,
Expand All @@ -319,7 +305,7 @@ func Run(ctx context.Context, cfg Config) error {
// Register only ACME challenge routes on HTTP server
routes.RegisterChallengeServer(httpSrv, svcs)

httpListener, httpListenErr := net.Listen("tcp", fmt.Sprintf(":%d", cfg.HttpPort))
httpListener, httpListenErr := net.Listen("tcp", fmt.Sprintf(":%d", cfg.ChallengePort))
if httpListenErr != nil {
return fmt.Errorf("unable to create HTTP listener: %w", httpListenErr)
}
Expand All @@ -332,6 +318,8 @@ func Run(ctx context.Context, cfg Config) error {
}
return nil
})
} else {
logger.Warn("HTTP challenge server not configured, ACME HTTP-01 challenges will not work", "challengePort", cfg.ChallengePort)
}

logger.Info("Frontline server initialized", "region", cfg.Region, "apexDomain", cfg.ApexDomain)
Expand Down
4 changes: 2 additions & 2 deletions svc/frontline/services/certmanager/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"strings"

vaultv1 "github.com/unkeyed/unkey/gen/proto/vault/v1"
Expand Down Expand Up @@ -92,7 +92,7 @@ func (s *service) GetCertificate(ctx context.Context, domain string) (*tls.Certi
}

if hit == cache.Null || db.IsNotFound(err) {
return nil, errors.New("certificate not found")
return nil, fmt.Errorf("certificate not found for [%v]", candidates)
}

return &cert, nil
Expand Down
68 changes: 68 additions & 0 deletions svc/frontline/tls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package frontline

import (
"context"
"crypto/tls"
"fmt"

"github.com/unkeyed/unkey/pkg/logger"
pkgtls "github.com/unkeyed/unkey/pkg/tls"
"github.com/unkeyed/unkey/svc/frontline/services/certmanager"
)

// buildTlsConfig creates a TLS configuration for the frontline server.
//
// The function supports three modes:
// - Disabled: TLS is explicitly disabled via config
// - Dynamic: Certificates are fetched from Vault via the cert manager (production)
// - Static: Certificates are loaded from filesystem (development)
//
// Dynamic certificates are preferred when Vault is configured because they support
// per-domain certificates without server restarts. Static files are a fallback for
// development environments or when Vault is unavailable.
//
// Returns nil TLS config when disabled, or an error if TLS is required but no
// certificate source is configured.
func buildTlsConfig(cfg Config, certManager certmanager.Service) (*tls.Config, error) {

tlsDisabled := cfg.TLS != nil && cfg.TLS.Disabled

if tlsDisabled {
logger.Warn("TLS explicitly disabled via config")

return nil, nil
}

if cfg.TLS != nil && cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" {
// Dev mode: static file-based certificate
logger.Info("TLS configured with static certificate files",
"certFile", cfg.TLS.CertFile,
"keyFile", cfg.TLS.KeyFile)
return pkgtls.NewFromFiles(cfg.TLS.CertFile, cfg.TLS.KeyFile)
}

if certManager != nil {
// Production mode: dynamic certificates from database/vault

logger.Info("TLS configured with dynamic certificate manager")

//nolint:exhaustruct
return &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return certManager.GetCertificate(context.Background(), hello.ServerName)
},
MinVersion: tls.VersionTLS12,
// Enable session resumption for faster subsequent connections
// Session tickets allow clients to skip the full TLS handshake
SessionTicketsDisabled: false,
// Let Go's TLS implementation choose optimal cipher suites
// This prefers TLS 1.3 when available (1-RTT vs 2-RTT for TLS 1.2)
PreferServerCipherSuites: false,
}, nil
}

return nil, fmt.Errorf("TLS is required but no certificate source configured: " +
"either enable Vault for dynamic certificates, provide [tls] cert_file and key_file, " +
"or explicitly disable TLS with [tls] disabled = true")

}