merge: maestro/tsk_FUQws_DMIcXa [tsk_FUQws_DMIcXa] mTLS/CA + 引导 token 框架
解决与 Go 骨架(1A)的 add/add 冲突:go.mod 统一 module 路径为 github.com/wangjia/pangolin/server,require 取并集;setup.sh 合并两边意图。 go build ./... 与 go test ./internal/mtls/... 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+203
-2
@@ -1,8 +1,209 @@
|
||||
module github.com/wangjia/pangolin/server
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
google.golang.org/grpc v1.81.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.121.6 // indirect
|
||||
cloud.google.com/go/auth v0.16.4 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/spanner v1.85.0 // indirect
|
||||
cloud.google.com/go/storage v1.56.0 // indirect
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||
github.com/99designs/keyring v1.2.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 // indirect
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.16 // indirect
|
||||
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
|
||||
github.com/Azure/go-autorest/logger v0.2.1 // indirect
|
||||
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go v1.4.3 // indirect
|
||||
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/apache/arrow/go/v10 v10.0.1 // indirect
|
||||
github.com/apache/thrift v0.16.0 // indirect
|
||||
github.com/aws/aws-sdk-go v1.49.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.16.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.12.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.33 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.27.11 // indirect
|
||||
github.com/aws/smithy-go v1.13.3 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.1.1 // indirect
|
||||
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 // indirect
|
||||
github.com/danieljoos/wincred v1.1.2 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.1 // indirect
|
||||
github.com/getkin/kin-openapi v0.135.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-sql-driver/mysql v1.5.0 // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/goccy/go-json v0.9.11 // indirect
|
||||
github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/flatbuffers v2.0.8+incompatible // indirect
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.14.3 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgtype v1.14.0 // indirect
|
||||
github.com/jackc/pgx/v4 v4.18.2 // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.4 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/k0kubun/pp v2.3.0+incompatible // indirect
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/asmfmt v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.15.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/ktrysmt/go-bitbucket v0.6.4 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.0.0 // indirect
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/mtibben/percent v0.2.1 // indirect
|
||||
github.com/mutecomm/go-sqlcipher/v4 v4.4.0 // indirect
|
||||
github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8 // indirect
|
||||
github.com/neo4j/neo4j-go-driver v1.8.1-0.20200803113522-b626aa943eba // indirect
|
||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.9 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.16 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/rqlite/gorqlite v0.0.0-20230708021416-2acd02b70b79 // indirect
|
||||
github.com/shopspring/decimal v1.2.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/snowflakedb/gosnowflake v1.6.19 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
|
||||
github.com/speakeasy-api/openapi v1.19.2 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
github.com/xanzy/go-gitlab v0.15.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.1 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.3 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b // indirect
|
||||
go.mongodb.org/mongo-driver v1.7.5 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/api v0.247.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/b v1.0.0 // indirect
|
||||
modernc.org/cc/v3 v3.36.3 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.9 // indirect
|
||||
modernc.org/db v1.0.0 // indirect
|
||||
modernc.org/file v1.0.0 // indirect
|
||||
modernc.org/fileutil v1.0.0 // indirect
|
||||
modernc.org/golex v1.0.0 // indirect
|
||||
modernc.org/internal v1.0.0 // indirect
|
||||
modernc.org/libc v1.17.1 // indirect
|
||||
modernc.org/lldb v1.0.0 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.2.1 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/ql v1.0.0 // indirect
|
||||
modernc.org/sortutil v1.1.0 // indirect
|
||||
modernc.org/sqlite v1.18.1 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.0 // indirect
|
||||
modernc.org/zappy v1.0.0 // indirect
|
||||
)
|
||||
|
||||
+2253
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
bootstrapTokenPrefix = "enroll:token:"
|
||||
bootstrapTokenTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
// BootstrapTokenManager issues and validates one-time enrollment tokens.
|
||||
// Tokens are generated before a node is provisioned (called by task #14 during
|
||||
// cloud-init preparation) and consumed exactly once during the Enroll RPC.
|
||||
type BootstrapTokenManager struct {
|
||||
redis redis.Cmdable
|
||||
}
|
||||
|
||||
// NewBootstrapTokenManager creates a manager backed by the given Redis client.
|
||||
func NewBootstrapTokenManager(r redis.Cmdable) *BootstrapTokenManager {
|
||||
return &BootstrapTokenManager{redis: r}
|
||||
}
|
||||
|
||||
// IssueToken allocates a cryptographically random 32-byte token for a node.
|
||||
// The mapping enroll:token:{token} → nodeUUID is stored in Redis with a 15-minute TTL.
|
||||
//
|
||||
// Callers (task #14) invoke this pre-flight when a new node record is created;
|
||||
// the token is then injected into the node's cloud-init user-data.
|
||||
func (m *BootstrapTokenManager) IssueToken(ctx context.Context, nodeUUID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("mtls: generate token entropy: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw) // 64-char hex string
|
||||
|
||||
key := bootstrapTokenPrefix + token
|
||||
if err := m.redis.Set(ctx, key, nodeUUID, bootstrapTokenTTL).Err(); err != nil {
|
||||
return "", fmt.Errorf("mtls: store bootstrap token: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ConsumeToken atomically retrieves and deletes the token (GETDEL).
|
||||
// Returns the bound nodeUUID on the first and only successful call.
|
||||
// A second call—or any call after the 15-minute TTL—returns an error.
|
||||
func (m *BootstrapTokenManager) ConsumeToken(ctx context.Context, token string) (string, error) {
|
||||
key := bootstrapTokenPrefix + token
|
||||
nodeUUID, err := m.redis.GetDel(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return "", fmt.Errorf("mtls: bootstrap token not found, already used, or expired")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mtls: consume bootstrap token: %w", err)
|
||||
}
|
||||
return nodeUUID, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func newTestBootstrapManager(t *testing.T) (*BootstrapTokenManager, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return NewBootstrapTokenManager(rdb), mr
|
||||
}
|
||||
|
||||
func TestBootstrap_IssueToken_Format(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
|
||||
token, err := mgr.IssueToken(context.Background(), "node-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
if len(token) != 64 {
|
||||
t.Errorf("token length = %d; want 64 hex chars", len(token))
|
||||
}
|
||||
if strings.ContainsAny(token, "ghijklmnopqrstuvwxyz !@#") {
|
||||
t.Errorf("token contains non-hex chars: %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_ConsumeToken_OnceOnly(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const nodeUUID = "node-11111111-2222-3333-4444-555555555555"
|
||||
|
||||
token, err := mgr.IssueToken(ctx, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
// First consume: must succeed.
|
||||
got, err := mgr.ConsumeToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeToken (first): %v", err)
|
||||
}
|
||||
if got != nodeUUID {
|
||||
t.Errorf("ConsumeToken returned %q; want %q", got, nodeUUID)
|
||||
}
|
||||
|
||||
// Second consume: must fail (one-time guarantee).
|
||||
_, err = mgr.ConsumeToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("ConsumeToken (second): expected error for already-consumed token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_ConsumeToken_NeverIssued(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
|
||||
_, err := mgr.ConsumeToken(context.Background(), "deadbeefdeadbeef")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_ConsumeToken_ExpiredTTL(t *testing.T) {
|
||||
mgr, mr := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := mgr.IssueToken(ctx, "node-expired")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
// Advance miniredis clock past the 15-minute TTL.
|
||||
mr.FastForward(bootstrapTokenTTL + time.Second)
|
||||
|
||||
_, err = mgr.ConsumeToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after TTL expiry, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_IssueToken_Uniqueness(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t1, _ := mgr.IssueToken(ctx, "node-A")
|
||||
t2, _ := mgr.IssueToken(ctx, "node-B")
|
||||
if t1 == t2 {
|
||||
t.Error("IssueToken returned duplicate tokens for different nodes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package mtls provides the mTLS/CA security foundation for the Pangolin control plane.
|
||||
// It implements a lightweight self-signed CA, CSR signing, one-time bootstrap tokens,
|
||||
// CRL revocation, and gRPC identity extraction interceptors.
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CAConfig specifies where to persist the CA key and certificate.
|
||||
// Both paths must be writable on first run; subsequent runs load from disk.
|
||||
type CAConfig struct {
|
||||
// KeyPath is the file path for the CA private key (PEM-encoded ECDSA P-256).
|
||||
// The key never leaves the control-plane disk.
|
||||
KeyPath string
|
||||
// CertPath is the file path for the CA certificate (PEM-encoded X.509).
|
||||
CertPath string
|
||||
}
|
||||
|
||||
// CA is a lightweight self-signed certificate authority.
|
||||
// It signs node client certificates and provides the CA cert for agent validation.
|
||||
type CA struct {
|
||||
key *ecdsa.PrivateKey
|
||||
cert *x509.Certificate
|
||||
certPEM []byte
|
||||
}
|
||||
|
||||
// NewCA loads an existing CA from disk or generates a new ECDSA P-256 CA.
|
||||
// Generated key and certificate are persisted to the paths in cfg.
|
||||
func NewCA(cfg CAConfig) (*CA, error) {
|
||||
keyPEM, errKey := os.ReadFile(cfg.KeyPath)
|
||||
certPEM, errCert := os.ReadFile(cfg.CertPath)
|
||||
|
||||
if errKey == nil && errCert == nil {
|
||||
key, err := parseECPrivateKey(keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: parse CA key: %w", err)
|
||||
}
|
||||
cert, err := parseCertificate(certPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: parse CA cert: %w", err)
|
||||
}
|
||||
return &CA{key: key, cert: cert, certPEM: certPEM}, nil
|
||||
}
|
||||
|
||||
// Generate a new CA key pair and self-signed certificate.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: generate CA key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Pangolin"},
|
||||
CommonName: "Pangolin Node CA",
|
||||
},
|
||||
NotBefore: now.Add(-time.Minute), // slight back-date for clock skew
|
||||
NotAfter: now.Add(10 * 365 * 24 * time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: self-sign CA: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: parse self-signed CA: %w", err)
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: marshal CA key: %w", err)
|
||||
}
|
||||
keyPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
certPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
// Persist to disk; create parent directories as needed.
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.KeyPath), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("mtls: mkdir for CA key: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.CertPath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mtls: mkdir for CA cert: %w", err)
|
||||
}
|
||||
// Key: owner-only read (0600)
|
||||
if err := os.WriteFile(cfg.KeyPath, keyPEMBytes, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("mtls: write CA key: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.CertPath, certPEMBytes, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("mtls: write CA cert: %w", err)
|
||||
}
|
||||
|
||||
return &CA{key: key, cert: cert, certPEM: certPEMBytes}, nil
|
||||
}
|
||||
|
||||
// SignCSR signs a PEM-encoded CSR and issues a client certificate.
|
||||
// The certificate's Subject CN is overridden to nodeUUID regardless of what
|
||||
// the CSR requests. Validity is 90 days; EKU = ClientAuth only.
|
||||
func (ca *CA) SignCSR(csrPEM []byte, nodeUUID string) ([]byte, error) {
|
||||
block, _ := pem.Decode(csrPEM)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("mtls: invalid CSR PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("mtls: CSR signature invalid: %w", err)
|
||||
}
|
||||
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: nodeUUID,
|
||||
},
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(90 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, csr.PublicKey, ca.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: sign CSR: %w", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), nil
|
||||
}
|
||||
|
||||
// CAPEM returns the CA certificate in PEM format.
|
||||
// This is sent to agents during Enroll so they can pin the server's CA.
|
||||
func (ca *CA) CAPEM() []byte {
|
||||
return ca.certPEM
|
||||
}
|
||||
|
||||
// CACert returns the parsed CA certificate for building x509.CertPool entries.
|
||||
func (ca *CA) CACert() *x509.Certificate {
|
||||
return ca.cert
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func randomSerial() (*big.Int, error) {
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mtls: generate serial: %w", err)
|
||||
}
|
||||
return serial, nil
|
||||
}
|
||||
|
||||
func parseECPrivateKey(pemData []byte) (*ecdsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
return x509.ParseECPrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
func parseCertificate(pemData []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestCA creates a CA in the given temporary directory.
|
||||
func newTestCA(t *testing.T, dir string) *CA {
|
||||
t.Helper()
|
||||
ca, err := NewCA(CAConfig{
|
||||
KeyPath: filepath.Join(dir, "ca.key"),
|
||||
CertPath: filepath.Join(dir, "ca.crt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA: %v", err)
|
||||
}
|
||||
return ca
|
||||
}
|
||||
|
||||
// newTestCSR generates an ECDSA P-256 key and a CSR PEM for testing.
|
||||
func newTestCSR(t *testing.T) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CSR key: %v", err)
|
||||
}
|
||||
tmpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ignored-by-signer"},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
return csrPEM, key
|
||||
}
|
||||
|
||||
func TestCA_GenerateAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First call: generate a new CA.
|
||||
ca1 := newTestCA(t, dir)
|
||||
if len(ca1.CAPEM()) == 0 {
|
||||
t.Fatal("CAPEM returned empty")
|
||||
}
|
||||
|
||||
// Second call with same paths: must load from disk without error.
|
||||
ca2, err := NewCA(CAConfig{
|
||||
KeyPath: filepath.Join(dir, "ca.key"),
|
||||
CertPath: filepath.Join(dir, "ca.crt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA reload: %v", err)
|
||||
}
|
||||
|
||||
// The CA certificates must be byte-identical.
|
||||
if !bytes.Equal(ca1.CAPEM(), ca2.CAPEM()) {
|
||||
t.Fatal("reloaded CA cert differs from generated CA cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_CNOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
nodeUUID := "node-11111111-2222-3333-4444-555555555555"
|
||||
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("SignCSR returned empty PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// CN must match nodeUUID regardless of what the CSR contained.
|
||||
if cert.Subject.CommonName != nodeUUID {
|
||||
t.Errorf("CN = %q; want %q", cert.Subject.CommonName, nodeUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_VerifiesAgainstCAPool(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
certPEM, err := ca.SignCSR(csrPEM, "node-test")
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// Build a pool with only our CA and verify the issued cert against it.
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
|
||||
_, err = cert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("cert.Verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCA_SignCSR_RejectsInvalidPEM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
_, err := ca.SignCSR([]byte("not-a-pem"), "any-uuid")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CSR PEM, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// crlKey is the Redis SET that holds revoked node UUIDs.
|
||||
const crlKey = "mtls:revoked"
|
||||
|
||||
// DB is the minimal persistence interface for revocation records.
|
||||
// In production this is backed by Postgres; in tests a no-op is sufficient.
|
||||
type DB interface {
|
||||
RecordRevocation(ctx context.Context, nodeUUID string) error
|
||||
}
|
||||
|
||||
// CRL manages certificate revocation.
|
||||
//
|
||||
// Redis is the authoritative hot-path store (checked on every TLS handshake).
|
||||
// DB is the durable backing store consulted on restart to re-populate Redis.
|
||||
type CRL struct {
|
||||
redis redis.Cmdable
|
||||
db DB // may be nil in tests
|
||||
}
|
||||
|
||||
// NewCRL creates a CRL manager.
|
||||
// db may be nil; if non-nil, Revoke also persists to it.
|
||||
func NewCRL(r redis.Cmdable, db DB) *CRL {
|
||||
return &CRL{redis: r, db: db}
|
||||
}
|
||||
|
||||
// Revoke marks nodeUUID as revoked.
|
||||
// The UUID is written to the Redis revocation set immediately;
|
||||
// if db is non-nil the record is also persisted there.
|
||||
func (c *CRL) Revoke(ctx context.Context, nodeUUID string) error {
|
||||
if err := c.redis.SAdd(ctx, crlKey, nodeUUID).Err(); err != nil {
|
||||
return fmt.Errorf("mtls: revoke in redis: %w", err)
|
||||
}
|
||||
if c.db != nil {
|
||||
if err := c.db.RecordRevocation(ctx, nodeUUID); err != nil {
|
||||
return fmt.Errorf("mtls: revoke in db: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRevoked returns true when nodeUUID is in the revocation set.
|
||||
//
|
||||
// On Redis error the function returns true (fail-safe: reject rather than
|
||||
// silently allow a potentially revoked node to connect).
|
||||
func (c *CRL) IsRevoked(nodeUUID string) bool {
|
||||
ctx := context.Background()
|
||||
revoked, err := c.redis.SIsMember(ctx, crlKey, nodeUUID).Result()
|
||||
if err != nil {
|
||||
// Fail-safe: treat transient Redis errors as revoked.
|
||||
return true
|
||||
}
|
||||
return revoked
|
||||
}
|
||||
|
||||
// VerifyPeerCertificate is a tls.Config.VerifyPeerCertificate callback.
|
||||
// It is invoked by the TLS stack after standard chain validation succeeds.
|
||||
// If the leaf certificate's CN corresponds to a revoked node, the handshake
|
||||
// is aborted.
|
||||
//
|
||||
// When no client certificate is presented (e.g. during Enroll) verifiedChains
|
||||
// is empty and this function is a no-op; the identity interceptor handles
|
||||
// the per-method enforcement.
|
||||
func (c *CRL) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
|
||||
for _, chain := range verifiedChains {
|
||||
if len(chain) == 0 {
|
||||
continue
|
||||
}
|
||||
cn := chain[0].Subject.CommonName
|
||||
if cn != "" && c.IsRevoked(cn) {
|
||||
return fmt.Errorf("mtls: certificate revoked for node %q", cn)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// noopDB satisfies the DB interface without persisting anything.
|
||||
type noopDB struct{}
|
||||
|
||||
func (noopDB) RecordRevocation(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func newTestCRL(t *testing.T) (*CRL, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return NewCRL(rdb, noopDB{}), mr
|
||||
}
|
||||
|
||||
func TestCRL_RevokeAndIsRevoked(t *testing.T) {
|
||||
crl, _ := newTestCRL(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const nodeUUID = "node-revoked-uuid"
|
||||
|
||||
if crl.IsRevoked(nodeUUID) {
|
||||
t.Fatal("IsRevoked = true before Revoke; want false")
|
||||
}
|
||||
|
||||
if err := crl.Revoke(ctx, nodeUUID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
if !crl.IsRevoked(nodeUUID) {
|
||||
t.Fatal("IsRevoked = false after Revoke; want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRL_IsRevoked_UnknownNode(t *testing.T) {
|
||||
crl, _ := newTestCRL(t)
|
||||
|
||||
if crl.IsRevoked("never-revoked") {
|
||||
t.Fatal("IsRevoked = true for unknown node; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRL_VerifyPeerCertificate_Revoked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
crl, _ := newTestCRL(t)
|
||||
ctx := context.Background()
|
||||
|
||||
nodeUUID := "node-revoked-cert"
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
clientCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
|
||||
// Build verified chains as the TLS stack would.
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
chains, err := clientCert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cert.Verify: %v", err)
|
||||
}
|
||||
|
||||
// Before revocation: VerifyPeerCertificate must pass.
|
||||
if err := crl.VerifyPeerCertificate(nil, chains); err != nil {
|
||||
t.Fatalf("VerifyPeerCertificate before revoke: %v", err)
|
||||
}
|
||||
|
||||
// Revoke the node.
|
||||
if err := crl.Revoke(ctx, nodeUUID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
// After revocation: VerifyPeerCertificate must reject.
|
||||
if err := crl.VerifyPeerCertificate(nil, chains); err == nil {
|
||||
t.Fatal("VerifyPeerCertificate after revoke: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRL_VerifyPeerCertificate_NoClientCert(t *testing.T) {
|
||||
crl, _ := newTestCRL(t)
|
||||
|
||||
// Empty verifiedChains = no client cert presented (e.g. during Enroll).
|
||||
if err := crl.VerifyPeerCertificate(nil, nil); err != nil {
|
||||
t.Errorf("VerifyPeerCertificate with no chains: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// nodeUUIDKey is the unexported context key for the verified node UUID.
|
||||
type nodeUUIDKey struct{}
|
||||
|
||||
// enrollFullMethod is the one RPC exempt from mTLS enforcement.
|
||||
// The agent has no certificate yet when it calls Enroll; the bootstrap token
|
||||
// provides authentication for that call instead.
|
||||
const enrollFullMethod = "/pangolin.agent.v1.AgentService/Enroll"
|
||||
|
||||
// UnaryServerInterceptor returns a gRPC unary interceptor that enforces mTLS identity.
|
||||
//
|
||||
// For the Enroll method: the call is passed through without a certificate check.
|
||||
// For all other methods: a verified client certificate is required; its CN is
|
||||
// injected into the context as the authoritative node UUID.
|
||||
func UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
req interface{},
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (interface{}, error) {
|
||||
if info.FullMethod == enrollFullMethod {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
nodeUUID, err := extractNodeUUID(ctx, info.FullMethod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler(context.WithValue(ctx, nodeUUIDKey{}, nodeUUID), req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamServerInterceptor returns a gRPC stream interceptor that enforces mTLS identity.
|
||||
// Same whitelist logic as the unary interceptor applies.
|
||||
func StreamServerInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(
|
||||
srv interface{},
|
||||
ss grpc.ServerStream,
|
||||
info *grpc.StreamServerInfo,
|
||||
handler grpc.StreamHandler,
|
||||
) error {
|
||||
if info.FullMethod == enrollFullMethod {
|
||||
return handler(srv, ss)
|
||||
}
|
||||
|
||||
nodeUUID, err := extractNodeUUID(ss.Context(), info.FullMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enriched := &wrappedStream{
|
||||
ServerStream: ss,
|
||||
ctx: context.WithValue(ss.Context(), nodeUUIDKey{}, nodeUUID),
|
||||
}
|
||||
return handler(srv, enriched)
|
||||
}
|
||||
}
|
||||
|
||||
// NodeUUIDFromContext retrieves the verified node UUID that was injected by the
|
||||
// interceptor. Returns ("", false) when the context carries no UUID (e.g. in
|
||||
// Enroll handlers where the identity is not yet established).
|
||||
func NodeUUIDFromContext(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(nodeUUIDKey{}).(string)
|
||||
return v, ok && v != ""
|
||||
}
|
||||
|
||||
// ─── internals ───────────────────────────────────────────────────────────────
|
||||
|
||||
// extractNodeUUID reads the verified TLS peer certificate's CN from the context.
|
||||
// It returns an Unauthenticated status error when no valid cert is present.
|
||||
func extractNodeUUID(ctx context.Context, method string) (string, error) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: no peer info for method %s", method)
|
||||
}
|
||||
|
||||
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
|
||||
if !ok {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: non-TLS connection for method %s", method)
|
||||
}
|
||||
|
||||
chains := tlsInfo.State.VerifiedChains
|
||||
if len(chains) == 0 || len(chains[0]) == 0 {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: client certificate required for method %s", method)
|
||||
}
|
||||
|
||||
cn := chains[0][0].Subject.CommonName
|
||||
if cn == "" {
|
||||
return "", status.Errorf(codes.Unauthenticated,
|
||||
"mtls: empty CN in client certificate for method %s", method)
|
||||
}
|
||||
return cn, nil
|
||||
}
|
||||
|
||||
// wrappedStream overrides the context on a ServerStream.
|
||||
type wrappedStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (w *wrappedStream) Context() context.Context {
|
||||
return w.ctx
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// contextWithTLSPeer builds a context carrying a gRPC peer whose TLS state
|
||||
// includes the given verified certificate chain (as the TLS stack would set
|
||||
// after a successful mTLS handshake).
|
||||
func contextWithTLSPeer(chains [][]*x509.Certificate) context.Context {
|
||||
state := tls.ConnectionState{VerifiedChains: chains}
|
||||
p := &peer.Peer{AuthInfo: credentials.TLSInfo{State: state}}
|
||||
return peer.NewContext(context.Background(), p)
|
||||
}
|
||||
|
||||
// okHandler is a trivial gRPC handler that returns ("ok", nil).
|
||||
func okHandler(_ context.Context, _ interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// ─── Unary interceptor ───────────────────────────────────────────────────────
|
||||
|
||||
func TestUnaryInterceptor_EnrollWhitelisted(t *testing.T) {
|
||||
interceptor := UnaryServerInterceptor()
|
||||
|
||||
// Enroll must pass through even without any peer/TLS info.
|
||||
info := &grpc.UnaryServerInfo{FullMethod: enrollFullMethod}
|
||||
_, err := interceptor(context.Background(), nil, info, okHandler)
|
||||
if err != nil {
|
||||
t.Errorf("Enroll should be whitelisted, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryInterceptor_NoPeer_Unauthenticated(t *testing.T) {
|
||||
interceptor := UnaryServerInterceptor()
|
||||
|
||||
info := &grpc.UnaryServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/Heartbeat"}
|
||||
// Context has no peer at all.
|
||||
_, err := interceptor(context.Background(), nil, info, okHandler)
|
||||
if err == nil {
|
||||
t.Fatal("expected Unauthenticated, got nil")
|
||||
}
|
||||
if code := status.Code(err); code != codes.Unauthenticated {
|
||||
t.Errorf("status code = %v; want Unauthenticated", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryInterceptor_NoCert_Unauthenticated(t *testing.T) {
|
||||
interceptor := UnaryServerInterceptor()
|
||||
|
||||
// Peer present but no verified chains (client sent no cert).
|
||||
ctx := contextWithTLSPeer(nil)
|
||||
info := &grpc.UnaryServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/Heartbeat"}
|
||||
_, err := interceptor(ctx, nil, info, okHandler)
|
||||
if err == nil {
|
||||
t.Fatal("expected Unauthenticated with no cert, got nil")
|
||||
}
|
||||
if code := status.Code(err); code != codes.Unauthenticated {
|
||||
t.Errorf("status code = %v; want Unauthenticated", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryInterceptor_ValidCert_InjectsUUID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newTestCA(t, dir)
|
||||
|
||||
csrPEM, _ := newTestCSR(t)
|
||||
nodeUUID := "node-deadbeef-cafe"
|
||||
certPEM, err := ca.SignCSR(csrPEM, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPEM)
|
||||
clientCert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
chains, err := clientCert.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cert.Verify: %v", err)
|
||||
}
|
||||
|
||||
ctx := contextWithTLSPeer(chains)
|
||||
info := &grpc.UnaryServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/Heartbeat"}
|
||||
|
||||
interceptor := UnaryServerInterceptor()
|
||||
var capturedCtx context.Context
|
||||
captureHandler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
capturedCtx = ctx
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
_, err = interceptor(ctx, nil, info, captureHandler)
|
||||
if err != nil {
|
||||
t.Fatalf("interceptor with valid cert: %v", err)
|
||||
}
|
||||
|
||||
got, ok := NodeUUIDFromContext(capturedCtx)
|
||||
if !ok {
|
||||
t.Fatal("NodeUUIDFromContext: not found in context")
|
||||
}
|
||||
if got != nodeUUID {
|
||||
t.Errorf("NodeUUIDFromContext = %q; want %q", got, nodeUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stream interceptor (whitelist only) ─────────────────────────────────────
|
||||
|
||||
// mockServerStream is a minimal grpc.ServerStream for testing.
|
||||
type mockServerStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (m *mockServerStream) Context() context.Context { return m.ctx }
|
||||
|
||||
func TestStreamInterceptor_EnrollWhitelisted(t *testing.T) {
|
||||
interceptor := StreamServerInterceptor()
|
||||
|
||||
info := &grpc.StreamServerInfo{FullMethod: enrollFullMethod}
|
||||
ss := &mockServerStream{ctx: context.Background()}
|
||||
|
||||
err := interceptor(nil, ss, info, func(_ interface{}, _ grpc.ServerStream) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Enroll stream should be whitelisted, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamInterceptor_NoCert_Unauthenticated(t *testing.T) {
|
||||
interceptor := StreamServerInterceptor()
|
||||
|
||||
info := &grpc.StreamServerInfo{FullMethod: "/pangolin.agent.v1.AgentService/StreamEvents"}
|
||||
ss := &mockServerStream{ctx: contextWithTLSPeer(nil)}
|
||||
|
||||
err := interceptor(nil, ss, info, func(_ interface{}, _ grpc.ServerStream) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Unauthenticated, got nil")
|
||||
}
|
||||
if code := status.Code(err); code != codes.Unauthenticated {
|
||||
t.Errorf("status code = %v; want Unauthenticated", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NodeUUIDFromContext ──────────────────────────────────────────────────────
|
||||
|
||||
func TestNodeUUIDFromContext_Empty(t *testing.T) {
|
||||
_, ok := NodeUUIDFromContext(context.Background())
|
||||
if ok {
|
||||
t.Error("NodeUUIDFromContext on bare context should return ok=false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package mtls
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
)
|
||||
|
||||
// NewServerTLSConfig builds the *tls.Config for the Pangolin gRPC server.
|
||||
//
|
||||
// Single-port strategy:
|
||||
// - ClientAuth = tls.VerifyClientCertIfGiven: agents that have not yet enrolled
|
||||
// complete the TLS handshake without a client cert; the identity interceptor
|
||||
// enforces cert presence for all non-Enroll RPCs at the application layer.
|
||||
// - ClientCAs is set to a pool containing only the Pangolin Node CA, so the TLS
|
||||
// stack will verify any presented certificate against it.
|
||||
// - VerifyPeerCertificate is wired to CRL.VerifyPeerCertificate: if the leaf cert
|
||||
// CN is revoked the handshake fails immediately, before any RPC handler runs.
|
||||
// - Minimum TLS version is 1.3.
|
||||
//
|
||||
// The caller must set cfg.Certificates with the server's own TLS certificate
|
||||
// (typically from Let's Encrypt) before using this config.
|
||||
func NewServerTLSConfig(ca *CA, crl *CRL) *tls.Config {
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.CACert())
|
||||
|
||||
return &tls.Config{
|
||||
ClientAuth: tls.VerifyClientCertIfGiven,
|
||||
ClientCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
VerifyPeerCertificate: crl.VerifyPeerCertificate,
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run once after cloning to download dependencies and generate go.sum.
|
||||
# Run once after cloning: download dependencies, generate go.sum, run unit tests.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
go mod tidy
|
||||
go test ./internal/mtls/...
|
||||
echo "setup complete — run 'make build' to verify"
|
||||
|
||||
Reference in New Issue
Block a user