Record project continuation changes
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-bookworm AS build
|
||||
FROM golang:1.25-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
+10
-9
@@ -1,23 +1,24 @@
|
||||
module github.com/example/remote-access-platform/backend
|
||||
|
||||
go 1.23.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/redis/go-redis/v9 v9.8.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
|
||||
+24
-18
@@ -7,12 +7,10 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -21,25 +19,33 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
|
||||
github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -14,12 +14,13 @@ const (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
MFAEnabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
MFAEnabled bool `json:"mfa_enabled"`
|
||||
PlatformRole string `json:"platform_role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
@@ -40,7 +41,7 @@ type AuthSession struct {
|
||||
ID string
|
||||
UserID string
|
||||
DeviceID string
|
||||
RefreshTokenHash string
|
||||
RefreshTokenHash string `json:"-"`
|
||||
RefreshExpiresAt time.Time
|
||||
LastSeenAt *time.Time
|
||||
LastRotatedAt *time.Time
|
||||
@@ -69,6 +70,13 @@ type BootstrapOwnerCommand struct {
|
||||
ActivationSignature string `json:"activation_signature"`
|
||||
}
|
||||
|
||||
type CreateUserCommand struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
PlatformRole string `json:"platform_role"`
|
||||
}
|
||||
|
||||
type RevokeAuthSessionCommand struct {
|
||||
UserID string `json:"user_id"`
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
|
||||
@@ -34,6 +34,10 @@ func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
r.Get("/devices", m.handleTrustedDevices)
|
||||
r.Post("/devices/{deviceID}/revoke", m.handleRevokeTrustedDevice)
|
||||
})
|
||||
router.Route("/users", func(r chi.Router) {
|
||||
r.Get("/", m.handleListUsers)
|
||||
r.Post("/", m.handleCreateUser)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) handleInstallationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -78,6 +82,32 @@ func (m *Module) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (m *Module) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
actorUserID := r.URL.Query().Get("actor_user_id")
|
||||
users, err := m.service.ListUsers(r.Context(), actorUserID)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
func (m *Module) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd CreateUserCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid user payload")
|
||||
return
|
||||
}
|
||||
user, err := m.service.CreateUser(r.Context(), cmd)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (m *Module) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd RefreshCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
|
||||
@@ -70,7 +70,7 @@ type postgresInstallationRepository struct {
|
||||
|
||||
func (r *postgresUserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
SELECT id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`
|
||||
@@ -79,13 +79,53 @@ WHERE email = $1
|
||||
|
||||
func (r *postgresUserRepository) GetByID(ctx context.Context, userID string) (*User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
SELECT id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1::uuid
|
||||
`
|
||||
return scanOptionalUser(r.db.QueryRow(ctx, query, userID))
|
||||
}
|
||||
|
||||
func (r *postgresUserRepository) List(ctx context.Context) ([]User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
user, err := scanOptionalUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != nil {
|
||||
users = append(users, *user)
|
||||
}
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (r *postgresUserRepository) Create(ctx context.Context, user User) (*User, error) {
|
||||
const query = `
|
||||
INSERT INTO users (email, password_hash, mfa_enabled, platform_role, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
`
|
||||
return scanOptionalUser(r.db.QueryRow(ctx, query,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.MFAEnabled,
|
||||
user.PlatformRole,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
))
|
||||
}
|
||||
|
||||
func (r *postgresDeviceRepository) Upsert(ctx context.Context, params UpsertDeviceParams) (*Device, error) {
|
||||
const query = `
|
||||
INSERT INTO devices (
|
||||
@@ -348,7 +388,7 @@ ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
platform_role = EXCLUDED.platform_role,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, platform_role, created_at, updated_at
|
||||
`, email, params.PasswordHash, params.Role, now))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert bootstrap owner: %w", err)
|
||||
@@ -461,6 +501,7 @@ func scanOptionalUser(row scanner) (*User, error) {
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.MFAEnabled,
|
||||
&user.PlatformRole,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
List(ctx context.Context) ([]User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetByID(ctx context.Context, userID string) (*User, error)
|
||||
Create(ctx context.Context, user User) (*User, error)
|
||||
}
|
||||
|
||||
type DeviceRepository interface {
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/authority"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
||||
postgresplatform "github.com/example/remote-access-platform/backend/internal/platform/postgres"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg module.Config
|
||||
store Store
|
||||
db postgresplatform.DBTX
|
||||
transactor Transactor
|
||||
tokenManager *TokenManager
|
||||
authority *authority.Verifier
|
||||
@@ -31,7 +33,7 @@ func NewService(deps module.Dependencies, store Store, transactor Transactor, ve
|
||||
} else if verifier, err := authority.NewVerifier(deps.Config.Installation); err == nil {
|
||||
authorityVerifier = verifier
|
||||
}
|
||||
return &Service{
|
||||
service := &Service{
|
||||
cfg: deps.Config,
|
||||
store: store,
|
||||
transactor: transactor,
|
||||
@@ -45,6 +47,10 @@ func NewService(deps module.Dependencies, store Store, transactor Transactor, ve
|
||||
authority: authorityVerifier,
|
||||
now: time.Now,
|
||||
}
|
||||
if postgresStore, ok := store.(*postgresStore); ok {
|
||||
service.db = postgresStore.db
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, cmd LoginCommand) (*AuthResult, error) {
|
||||
@@ -120,6 +126,44 @@ func (s *Service) Login(ctx context.Context, cmd LoginCommand) (*AuthResult, err
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUsers(ctx context.Context, actorUserID string) ([]User, error) {
|
||||
if err := s.ensurePlatformAdmin(ctx, actorUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.Users().List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(ctx context.Context, cmd CreateUserCommand) (*User, error) {
|
||||
if err := s.ensurePlatformAdmin(ctx, cmd.ActorUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(cmd.Email))
|
||||
password := strings.TrimSpace(cmd.Password)
|
||||
role := strings.TrimSpace(cmd.PlatformRole)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if email == "" || !strings.Contains(email, "@") || len(password) < 8 {
|
||||
return nil, ErrInvalidBootstrapOwner
|
||||
}
|
||||
if role != "user" && role != authority.PlatformRoleAdmin && role != authority.PlatformRoleRecoveryAdmin {
|
||||
return nil, ErrInvalidBootstrapOwner
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash user password: %w", err)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
return s.store.Users().Create(ctx, User{
|
||||
Email: email,
|
||||
PasswordHash: string(passwordHash),
|
||||
MFAEnabled: false,
|
||||
PlatformRole: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, cmd RefreshCommand) (*AuthResult, error) {
|
||||
authSessionID, err := s.tokenManager.ParseRefreshToken(cmd.RefreshToken)
|
||||
if err != nil {
|
||||
@@ -438,3 +482,25 @@ func (s *Service) installationStatusFromRecord(record *InstallationAuthorityStat
|
||||
func (s *Service) strictAuthority() bool {
|
||||
return s.authority != nil && s.authority.Strict()
|
||||
}
|
||||
|
||||
func (s *Service) ensurePlatformAdmin(ctx context.Context, actorUserID string) error {
|
||||
if actorUserID == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
role := authority.PlatformRoleUser
|
||||
if s.db != nil {
|
||||
effectiveRole, err := authority.EffectivePlatformRole(ctx, s.db, s.authority, actorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role = effectiveRole
|
||||
} else if user, err := s.store.Users().GetByID(ctx, actorUserID); err != nil {
|
||||
return err
|
||||
} else if user != nil && user.PlatformRole != "" {
|
||||
role = user.PlatformRole
|
||||
}
|
||||
if role != authority.PlatformRoleAdmin && role != authority.PlatformRoleRecoveryAdmin {
|
||||
return ErrDeviceRevoked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVPNPacketHubPopBatchAndStatsKeys(t *testing.T) {
|
||||
hub := newVPNPacketHub()
|
||||
key := vpnPacketKey{
|
||||
ClusterID: "cluster-1",
|
||||
VPNConnectionID: "vpn-1",
|
||||
Direction: vpnDirectionClientToGateway,
|
||||
}
|
||||
|
||||
packetA := []byte{
|
||||
0x45, 0x00, 0x00, 20,
|
||||
0x00, 0x01, 0x00, 0x00,
|
||||
64, 17, 0, 0,
|
||||
192, 168, 0, 1,
|
||||
192, 168, 0, 2,
|
||||
0x00, 0x50, 0x01, 0xBB,
|
||||
}
|
||||
|
||||
packetB := make([]byte, len(packetA))
|
||||
copy(packetB, packetA)
|
||||
packetB[19] = 0xBA
|
||||
|
||||
hub.Push(key, packetA)
|
||||
hub.Push(key, packetB)
|
||||
|
||||
packets := hub.PopBatch(context.Background(), key, 0, vpnPacketBatchMaxPackets, vpnPacketBatchMaxBytes)
|
||||
if len(packets) != 2 {
|
||||
t.Fatalf("expected 2 packets in batch, got %d", len(packets))
|
||||
}
|
||||
|
||||
statsAny := hub.Snapshot("cluster-1", "vpn-1")[vpnDirectionClientToGateway]
|
||||
stats, ok := statsAny.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected stats payload type: %T", statsAny)
|
||||
}
|
||||
|
||||
for _, keyName := range []string{
|
||||
"pushed",
|
||||
"pushed_bytes",
|
||||
"popped",
|
||||
"popped_bytes",
|
||||
"window_push_rate_pps",
|
||||
"window_pop_rate_pps",
|
||||
"window_push_rate_mbps",
|
||||
"window_pop_rate_mbps",
|
||||
"window_push_packets",
|
||||
"window_pop_packets",
|
||||
"queue_depth",
|
||||
"queue_depths",
|
||||
"queue_depth_max",
|
||||
"queue_depth_high_watermark",
|
||||
"queue_depth_high_at",
|
||||
"shard_depth_high_watermark",
|
||||
"shard_depth_high_at",
|
||||
"queue_capacity",
|
||||
"queue_shard_capacity",
|
||||
"queue_full_drops",
|
||||
"requeue_drops",
|
||||
"cleared_stale_packets",
|
||||
"flow_shard_count",
|
||||
"flow_isolation",
|
||||
} {
|
||||
if _, found := stats[keyName]; !found {
|
||||
t.Fatalf("missing vpn packet stat key %s", keyName)
|
||||
}
|
||||
}
|
||||
|
||||
if got, ok := stats["popped"].(uint64); !ok || got != 2 {
|
||||
t.Fatalf("expected popped=2, got %v (ok=%v)", stats["popped"], ok)
|
||||
}
|
||||
if got, ok := stats["pushed"].(uint64); !ok || got != 2 {
|
||||
t.Fatalf("expected pushed=2, got %v (ok=%v)", stats["pushed"], ok)
|
||||
}
|
||||
if got, ok := stats["queue_depth_high_watermark"].(int); !ok || got < 1 {
|
||||
t.Fatalf("expected queue depth high watermark, got %v (ok=%v)", stats["queue_depth_high_watermark"], ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNPacketHubGatherBehavior(t *testing.T) {
|
||||
hub := newVPNPacketHub()
|
||||
key := vpnPacketKey{
|
||||
ClusterID: "cluster-1",
|
||||
VPNConnectionID: "vpn-1",
|
||||
Direction: vpnDirectionGatewayToClient,
|
||||
}
|
||||
packet := []byte{
|
||||
0x45, 0x00, 0x00, 20,
|
||||
0x00, 0x01, 0x00, 0x00,
|
||||
64, 6, 0, 0,
|
||||
10, 0, 0, 1,
|
||||
10, 0, 0, 2,
|
||||
0x12, 0x34, 0x56, 0x78,
|
||||
}
|
||||
|
||||
hub.Push(key, packet)
|
||||
hub.Push(key, packet)
|
||||
hub.Push(key, packet)
|
||||
|
||||
first, ok := hub.Pop(context.Background(), key, 0)
|
||||
if !ok {
|
||||
t.Fatal("expected packet from queue")
|
||||
}
|
||||
batch := hub.PopBatch(context.Background(), key, 0, 1, 1024)
|
||||
if len(batch) != 1 {
|
||||
t.Fatalf("expected 1 packet because batch limit 1, got %d", len(batch))
|
||||
}
|
||||
|
||||
_ = first
|
||||
}
|
||||
|
||||
func TestVPNPacketHubFlowShardsReportDepths(t *testing.T) {
|
||||
hub := newVPNPacketHub()
|
||||
key := vpnPacketKey{
|
||||
ClusterID: "cluster-1",
|
||||
VPNConnectionID: "vpn-1",
|
||||
Direction: vpnDirectionGatewayToClient,
|
||||
}
|
||||
|
||||
for i := byte(1); i <= 8; i++ {
|
||||
packet := []byte{
|
||||
0x45, 0x00, 0x00, 24,
|
||||
0x00, i, 0x00, 0x00,
|
||||
64, 6, 0, 0,
|
||||
10, 0, 0, i,
|
||||
192, 168, 200, i,
|
||||
0x12, i, 0x56, i,
|
||||
}
|
||||
if err := hub.Push(key, packet); err != nil {
|
||||
t.Fatalf("push packet %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
statsAny := hub.Snapshot("cluster-1", "vpn-1")[vpnDirectionGatewayToClient]
|
||||
stats, ok := statsAny.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected stats payload type: %T", statsAny)
|
||||
}
|
||||
if got, ok := stats["queue_depth"].(int); !ok || got != 8 {
|
||||
t.Fatalf("expected queue_depth=8, got %v (ok=%v)", stats["queue_depth"], ok)
|
||||
}
|
||||
depths, ok := stats["queue_depths"].([]int)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected queue_depths payload type: %T", stats["queue_depths"])
|
||||
}
|
||||
if len(depths) != vpnPacketFlowShardCount {
|
||||
t.Fatalf("expected %d queue shards, got %d", vpnPacketFlowShardCount, len(depths))
|
||||
}
|
||||
nonEmpty := 0
|
||||
for _, depth := range depths {
|
||||
if depth > 0 {
|
||||
nonEmpty++
|
||||
}
|
||||
}
|
||||
if nonEmpty < 2 {
|
||||
t.Fatalf("expected packets to be distributed across at least 2 shards, got depths=%v", depths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNPacketHubClearDoesNotCountAsDrop(t *testing.T) {
|
||||
hub := newVPNPacketHub()
|
||||
key := vpnPacketKey{
|
||||
ClusterID: "cluster-1",
|
||||
VPNConnectionID: "vpn-1",
|
||||
Direction: vpnDirectionClientToGateway,
|
||||
}
|
||||
packet := []byte{
|
||||
0x45, 0x00, 0x00, 20,
|
||||
0x00, 0x01, 0x00, 0x00,
|
||||
64, 6, 0, 0,
|
||||
10, 0, 0, 1,
|
||||
10, 0, 0, 2,
|
||||
0x12, 0x34, 0x56, 0x78,
|
||||
}
|
||||
if err := hub.Push(key, packet); err != nil {
|
||||
t.Fatalf("push packet: %v", err)
|
||||
}
|
||||
if cleared := hub.Clear(key); cleared != 1 {
|
||||
t.Fatalf("expected cleared=1, got %d", cleared)
|
||||
}
|
||||
statsAny := hub.Snapshot("cluster-1", "vpn-1")[vpnDirectionClientToGateway]
|
||||
stats, ok := statsAny.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected stats payload type: %T", statsAny)
|
||||
}
|
||||
if got, ok := stats["dropped"].(uint64); !ok || got != 0 {
|
||||
t.Fatalf("expected dropped=0 for stale clear, got %v (ok=%v)", stats["dropped"], ok)
|
||||
}
|
||||
if got, ok := stats["cleared_stale_packets"].(uint64); !ok || got != 1 {
|
||||
t.Fatalf("expected cleared_stale_packets=1, got %v (ok=%v)", stats["cleared_stale_packets"], ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNClientDiagnosticStopCommandDrainsPendingWork(t *testing.T) {
|
||||
hub := newVPNClientDiagnosticHub()
|
||||
hub.Enqueue("cluster-1", "device-1", map[string]any{"type": "vpn_page_probe", "url": "https://speedtest.rt.ru/"})
|
||||
hub.Enqueue("cluster-1", "device-1", map[string]any{"type": "vpn_tcp_connect", "host": "192.168.200.95"})
|
||||
hub.Enqueue("cluster-1", "device-1", map[string]any{"type": "stop_vpn"})
|
||||
|
||||
item, ok := hub.Pop(context.Background(), "cluster-1", "device-1", time.Millisecond)
|
||||
if !ok {
|
||||
t.Fatal("expected priority stop command")
|
||||
}
|
||||
if got, _ := item.Payload["type"].(string); got != "stop_vpn" {
|
||||
t.Fatalf("first command = %q, want stop_vpn", got)
|
||||
}
|
||||
if item, ok := hub.Pop(context.Background(), "cluster-1", "device-1", 0); ok {
|
||||
t.Fatalf("expected old commands to be drained, got %#v", item.Payload)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,3 +32,136 @@ func TestMeshLatestObservationKeyDefaults(t *testing.T) {
|
||||
t.Fatalf("key = %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichVPNClientFabricRoutePrefersPlacementEntryAndActiveExit(t *testing.T) {
|
||||
item := VPNClientConnection{
|
||||
AllowedNodeIDs: []string{"node-a", "node-b", "node-b"},
|
||||
EntryNodeIDs: []string{"entry-1", "entry-2"},
|
||||
ExitNodeID: "exit-policy",
|
||||
ActiveLease: &NodeVPNAssignmentLease{
|
||||
OwnerNodeID: "exit-active",
|
||||
},
|
||||
ClientConfig: json.RawMessage(`{"routes":["0.0.0.0/0"]}`),
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(enrichVPNClientFabricRoute(item, "entry-2", ""), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal enriched config: %v", err)
|
||||
}
|
||||
route, ok := cfg["vpn_fabric_route"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing vpn_fabric_route in %#v", cfg)
|
||||
}
|
||||
if route["preferred_data_plane"] != "fabric_mesh" || route["fallback_data_plane"] != "backend_relay" {
|
||||
t.Fatalf("unexpected data-plane route contract: %#v", route)
|
||||
}
|
||||
if route["selected_entry_node_id"] != "entry-2" || route["selected_exit_node_id"] != "exit-active" {
|
||||
t.Fatalf("unexpected selected route endpoints: %#v", route)
|
||||
}
|
||||
if route["route_candidate_count"].(float64) != 8 {
|
||||
t.Fatalf("route candidate count = %#v", route["route_candidate_count"])
|
||||
}
|
||||
candidates := route["route_candidates"].([]any)
|
||||
firstCandidate := candidates[0].(map[string]any)
|
||||
if firstCandidate["role"] != "preferred" || firstCandidate["entry_node_id"] != "entry-2" || firstCandidate["exit_node_id"] != "exit-active" {
|
||||
t.Fatalf("preferred route candidate = %#v", firstCandidate)
|
||||
}
|
||||
entryPool := route["entry_pool_node_ids"].([]any)
|
||||
exitPool := route["exit_pool_node_ids"].([]any)
|
||||
if len(entryPool) != 2 || entryPool[0] != "entry-1" || entryPool[1] != "entry-2" {
|
||||
t.Fatalf("entry pool = %#v", entryPool)
|
||||
}
|
||||
if len(exitPool) != 4 || exitPool[0] != "exit-policy" || exitPool[1] != "exit-active" || exitPool[2] != "node-a" || exitPool[3] != "node-b" {
|
||||
t.Fatalf("exit pool = %#v", exitPool)
|
||||
}
|
||||
contract, ok := cfg["vpn_dataplane_contract"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing vpn_dataplane_contract in %#v", cfg)
|
||||
}
|
||||
if contract["tunnel_type"] != "universal_ip_packet" || contract["application_protocol_agnostic"] != true {
|
||||
t.Fatalf("unexpected dataplane contract: %#v", contract)
|
||||
}
|
||||
failover := contract["failover"].(map[string]any)
|
||||
if failover["enabled"] != true || failover["alternate_route_count"].(float64) != 7 {
|
||||
t.Fatalf("unexpected failover contract: %#v", failover)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichVPNClientFabricRoutePrefersExplicitExit(t *testing.T) {
|
||||
item := VPNClientConnection{
|
||||
AllowedNodeIDs: []string{"node-a", "node-b", "node-c"},
|
||||
EntryNodeIDs: []string{"entry-1", "entry-2"},
|
||||
ExitNodeID: "exit-policy-a",
|
||||
ActiveLease: &NodeVPNAssignmentLease{
|
||||
OwnerNodeID: "",
|
||||
},
|
||||
ClientConfig: json.RawMessage(`{"routes":["0.0.0.0/0"]}`),
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(enrichVPNClientFabricRoute(item, "entry-1", "node-c"), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal enriched config: %v", err)
|
||||
}
|
||||
route, ok := cfg["vpn_fabric_route"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing vpn_fabric_route in %#v", cfg)
|
||||
}
|
||||
if route["selected_entry_node_id"] != "entry-1" {
|
||||
t.Fatalf("unexpected selected entry: %#v", route["selected_entry_node_id"])
|
||||
}
|
||||
if route["selected_exit_node_id"] != "node-c" {
|
||||
t.Fatalf("unexpected selected exit: %#v", route["selected_exit_node_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T) {
|
||||
item := VPNClientConnection{
|
||||
EntryNodeIDs: []string{"entry-1"},
|
||||
ClientConfig: json.RawMessage(`{
|
||||
"vpn_fabric_route": {
|
||||
"status": "planned",
|
||||
"selected_entry_node_id": "entry-1",
|
||||
"selected_exit_node_id": "exit-1"
|
||||
}
|
||||
}`),
|
||||
}
|
||||
heartbeatMetadata := json.RawMessage(`{
|
||||
"mesh_endpoint_report": {
|
||||
"transport": "direct_http",
|
||||
"connectivity_mode": "direct",
|
||||
"nat_type": "none",
|
||||
"region": "test",
|
||||
"peer_endpoint": "http://entry.example.test:19131",
|
||||
"endpoint_candidates": [{
|
||||
"endpoint_id": "public-http",
|
||||
"node_id": "entry-1",
|
||||
"transport": "direct_http",
|
||||
"address": "http://entry.example.test:19131",
|
||||
"reachability": "public",
|
||||
"priority": 0
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
endpoints := map[string][]map[string]any{
|
||||
"entry-1": vpnEntryEndpointCandidatesFromHeartbeat("entry-1", json.RawMessage(`{"vpn_local_gateway_shortcut":true}`), heartbeatMetadata),
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(enrichVPNClientEntryEndpointCandidates(item, endpoints), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal enriched config: %v", err)
|
||||
}
|
||||
if cfg["vpn_entry_endpoint_candidate_count"].(float64) != 1 {
|
||||
t.Fatalf("candidate count = %#v", cfg["vpn_entry_endpoint_candidate_count"])
|
||||
}
|
||||
candidates := cfg["vpn_entry_endpoint_candidates"].([]any)
|
||||
candidate := candidates[0].(map[string]any)
|
||||
if candidate["node_id"] != "entry-1" || candidate["api_base_url"] != "http://entry.example.test:19131/api/v1" {
|
||||
t.Fatalf("unexpected endpoint candidate: %#v", candidate)
|
||||
}
|
||||
if candidate["local_gateway_shortcut"] != true {
|
||||
t.Fatalf("local gateway shortcut missing: %#v", candidate)
|
||||
}
|
||||
if candidate["selected_entry"] != true || candidate["source"] != "node_latest_heartbeat.mesh_endpoint_report.endpoint_candidates" {
|
||||
t.Fatalf("unexpected endpoint metadata: %#v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Repository interface {
|
||||
AssignNodeToGroup(ctx context.Context, input AssignNodeGroupInput) (ClusterNode, error)
|
||||
|
||||
CreateJoinToken(ctx context.Context, input CreateJoinTokenInput, tokenHash string) (NodeJoinToken, error)
|
||||
ListJoinTokens(ctx context.Context, clusterID string) ([]NodeJoinToken, error)
|
||||
SetJoinTokenAuthority(ctx context.Context, clusterID, tokenID string, payload json.RawMessage, signature ClusterSignature) (NodeJoinToken, error)
|
||||
GetValidJoinTokenByHash(ctx context.Context, clusterID, tokenHash string) (NodeJoinToken, error)
|
||||
RevokeJoinToken(ctx context.Context, input RevokeJoinTokenInput) (NodeJoinToken, error)
|
||||
@@ -40,8 +41,16 @@ type Repository interface {
|
||||
|
||||
RecordHeartbeat(ctx context.Context, input RecordHeartbeatInput) (NodeHeartbeat, error)
|
||||
ListNodeHeartbeats(ctx context.Context, clusterID, nodeID string, limit int) ([]NodeHeartbeat, error)
|
||||
CreateReleaseVersion(ctx context.Context, input CreateReleaseVersionInput) (ReleaseVersion, error)
|
||||
ListReleaseVersions(ctx context.Context, clusterID, product, channel string) ([]ReleaseVersion, error)
|
||||
ListNodeUpdateServiceCandidates(ctx context.Context, clusterID string) ([]NodeUpdateServiceCandidate, error)
|
||||
UpsertNodeUpdatePolicy(ctx context.Context, input UpsertNodeUpdatePolicyInput) (NodeUpdatePolicy, error)
|
||||
GetNodeUpdatePolicy(ctx context.Context, clusterID, nodeID, product string) (NodeUpdatePolicy, error)
|
||||
ReportNodeUpdateStatus(ctx context.Context, input ReportNodeUpdateStatusInput) (NodeUpdateStatus, error)
|
||||
ListNodeUpdateStatuses(ctx context.Context, clusterID, nodeID string, limit int) ([]NodeUpdateStatus, error)
|
||||
RevokeNodeIdentity(ctx context.Context, input RevokeNodeIdentityInput) error
|
||||
DisableClusterMembership(ctx context.Context, input DisableMembershipInput) error
|
||||
DeleteClusterNode(ctx context.Context, input DeleteClusterNodeInput) error
|
||||
UpsertFabricTestingFlag(ctx context.Context, input UpsertFabricTestingFlagInput) (FabricTestingFlag, error)
|
||||
ListFabricTestingFlags(ctx context.Context) ([]FabricTestingFlag, error)
|
||||
GetEffectiveNodeTestingFlags(ctx context.Context, clusterID, nodeID string) (EffectiveNodeTestingFlags, error)
|
||||
@@ -55,6 +64,22 @@ type Repository interface {
|
||||
ListMeshLinks(ctx context.Context, clusterID string) ([]MeshLinkObservation, error)
|
||||
CreateRouteIntent(ctx context.Context, input CreateRouteIntentInput) (MeshRouteIntent, error)
|
||||
ListRouteIntents(ctx context.Context, clusterID string) ([]MeshRouteIntent, error)
|
||||
ExpireRouteIntent(ctx context.Context, input RouteIntentLifecycleInput, expiresAt time.Time) (MeshRouteIntent, error)
|
||||
DisableRouteIntent(ctx context.Context, input RouteIntentLifecycleInput) (MeshRouteIntent, error)
|
||||
RecordFabricServiceChannelRouteFeedback(ctx context.Context, input RecordFabricServiceChannelRouteFeedbackInput) (FabricServiceChannelRouteFeedbackObservation, error)
|
||||
ListFabricServiceChannelRouteFeedback(ctx context.Context, input ListFabricServiceChannelRouteFeedbackInput) ([]FabricServiceChannelRouteFeedbackObservation, error)
|
||||
ExpireFabricServiceChannelRouteFeedback(ctx context.Context, input ExpireFabricServiceChannelRouteFeedbackInput) (ExpireFabricServiceChannelRouteFeedbackResult, error)
|
||||
StoreFabricServiceChannelLease(ctx context.Context, input StoreFabricServiceChannelLeaseInput) (FabricServiceChannelLeaseRecord, error)
|
||||
GetFabricServiceChannelLease(ctx context.Context, clusterID, channelID string) (FabricServiceChannelLeaseRecord, error)
|
||||
ListFabricServiceChannelLeases(ctx context.Context, input ListFabricServiceChannelLeasesInput) ([]FabricServiceChannelLeaseRecord, error)
|
||||
CleanupExpiredFabricServiceChannelLeases(ctx context.Context, clusterID string, now time.Time, limit int) (int, error)
|
||||
RecordFabricServiceChannelRouteRebuildAttempt(ctx context.Context, input RecordFabricServiceChannelRouteRebuildAttemptInput) (FabricServiceChannelRouteRebuildAttempt, error)
|
||||
ListFabricServiceChannelRouteRebuildAttempts(ctx context.Context, input ListFabricServiceChannelRouteRebuildAttemptsInput) ([]FabricServiceChannelRouteRebuildAttempt, error)
|
||||
UpdateFabricServiceChannelRouteRebuildCorrelationSnapshot(ctx context.Context, input UpdateFabricServiceChannelRouteRebuildCorrelationSnapshotInput) error
|
||||
GetFabricServiceChannelSchemaStatus(ctx context.Context, input GetFabricServiceChannelSchemaStatusInput) (FabricServiceChannelSchemaStatus, error)
|
||||
UpsertFabricServiceChannelRouteRebuildAlertSilence(ctx context.Context, input SilenceFabricServiceChannelRouteRebuildAlertInput, expiresAt time.Time) (FabricServiceChannelRouteRebuildAlertSilence, error)
|
||||
ListFabricServiceChannelRouteRebuildAlertSilences(ctx context.Context, clusterID string, now time.Time) ([]FabricServiceChannelRouteRebuildAlertSilence, error)
|
||||
DeleteFabricServiceChannelRouteRebuildAlertSilence(ctx context.Context, input UnsilenceFabricServiceChannelRouteRebuildAlertInput) (FabricServiceChannelRouteRebuildAlertSilence, error)
|
||||
ListQoSPolicies(ctx context.Context, clusterID string) ([]MeshQoSPolicy, error)
|
||||
ListFabricEntryPoints(ctx context.Context, clusterID string) ([]FabricEntryPoint, error)
|
||||
CreateFabricEntryPoint(ctx context.Context, input CreateFabricEntryPointInput) (FabricEntryPoint, error)
|
||||
@@ -78,6 +103,7 @@ type Repository interface {
|
||||
ListVPNConnectionAllowedNodes(ctx context.Context, clusterID, vpnConnectionID string) ([]VPNConnectionAllowedNode, error)
|
||||
AcquireVPNConnectionLease(ctx context.Context, input AcquireVPNConnectionLeaseInput, expiresAt time.Time, fencingToken string) (VPNConnectionLease, error)
|
||||
RenewVPNConnectionLease(ctx context.Context, input RenewVPNConnectionLeaseInput, expiresAt time.Time) (VPNConnectionLease, error)
|
||||
RenewNodeVPNAssignmentLease(ctx context.Context, input RenewNodeVPNAssignmentLeaseInput, expiresAt time.Time) (VPNConnectionLease, error)
|
||||
ReleaseVPNConnectionLease(ctx context.Context, input ReleaseVPNConnectionLeaseInput) (VPNConnectionLease, error)
|
||||
FenceVPNConnectionLease(ctx context.Context, input FenceVPNConnectionLeaseInput) (VPNConnectionLease, error)
|
||||
GetActiveVPNConnectionLease(ctx context.Context, clusterID, vpnConnectionID string) (VPNConnectionLease, error)
|
||||
@@ -85,7 +111,8 @@ type Repository interface {
|
||||
ExpireStaleVPNConnectionLeases(ctx context.Context, clusterID string, now time.Time) ([]VPNConnectionLease, error)
|
||||
ListNodeVPNAssignments(ctx context.Context, clusterID, nodeID string) ([]NodeVPNAssignment, error)
|
||||
ReportNodeVPNAssignmentStatus(ctx context.Context, input ReportNodeVPNAssignmentStatusInput) (NodeVPNAssignmentStatus, error)
|
||||
GetVPNClientProfile(ctx context.Context, clusterID, organizationID, userID, preferredEntryNodeID, preferredExitNodeID string, generatedAt time.Time) (VPNClientProfile, error)
|
||||
|
||||
RecordAudit(ctx context.Context, event ClusterAuditEvent) error
|
||||
ListAuditEvents(ctx context.Context, clusterID string, limit int) ([]ClusterAuditEvent, error)
|
||||
ListAuditEvents(ctx context.Context, input ListAuditEventsInput) ([]ClusterAuditEvent, error)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,9 @@ func (m *Module) Name() string {
|
||||
|
||||
func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/node-agents", func(r chi.Router) {
|
||||
r.Post("/docker-install-profile", m.dockerInstallProfile)
|
||||
r.Post("/windows-install-profile", m.windowsInstallProfile)
|
||||
r.Post("/linux-install-profile", m.linuxInstallProfile)
|
||||
r.Post("/enroll", m.enrollAgent)
|
||||
r.Post("/enrollments/{requestID}/bootstrap", m.bootstrapEnrollment)
|
||||
r.Post("/register", m.registerAgent)
|
||||
@@ -53,6 +56,48 @@ func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) linuxInstallProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var payload clustermodule.DockerInstallProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid linux install profile payload")
|
||||
return
|
||||
}
|
||||
profile, err := m.cluster.GetLinuxInstallProfile(r.Context(), payload)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"linux_install_profile": profile})
|
||||
}
|
||||
|
||||
func (m *Module) windowsInstallProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var payload clustermodule.DockerInstallProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid windows install profile payload")
|
||||
return
|
||||
}
|
||||
profile, err := m.cluster.GetWindowsInstallProfile(r.Context(), payload)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"windows_install_profile": profile})
|
||||
}
|
||||
|
||||
func (m *Module) dockerInstallProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var payload clustermodule.DockerInstallProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid docker install profile payload")
|
||||
return
|
||||
}
|
||||
profile, err := m.cluster.GetDockerInstallProfile(r.Context(), payload)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"docker_install_profile": profile})
|
||||
}
|
||||
|
||||
func (m *Module) enrollAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
|
||||
@@ -242,6 +242,37 @@ func (m *Module) loadAdminSummary(ctx context.Context, orgID string) (AdminSumma
|
||||
return AdminSummary{}, err
|
||||
}
|
||||
|
||||
var vpnConnectionCount int64
|
||||
var vpnActiveLeaseCount int64
|
||||
var vpnForwardingCount int64
|
||||
if err := m.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM vpn_connections
|
||||
WHERE organization_id = $1::uuid
|
||||
AND desired_state = 'enabled'
|
||||
`, orgID).Scan(&vpnConnectionCount); err != nil {
|
||||
return AdminSummary{}, err
|
||||
}
|
||||
if err := m.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM vpn_connection_leases l
|
||||
INNER JOIN vpn_connections vc ON vc.id = l.vpn_connection_id
|
||||
WHERE vc.organization_id = $1::uuid
|
||||
AND l.status = 'active'
|
||||
AND l.expires_at > NOW()
|
||||
`, orgID).Scan(&vpnActiveLeaseCount); err != nil {
|
||||
return AdminSummary{}, err
|
||||
}
|
||||
if err := m.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM vpn_connection_assignment_latest_statuses s
|
||||
INNER JOIN vpn_connections vc ON vc.id = s.vpn_connection_id
|
||||
WHERE vc.organization_id = $1::uuid
|
||||
AND COALESCE((s.status_payload->>'packet_forwarding')::boolean, false)
|
||||
`, orgID).Scan(&vpnForwardingCount); err != nil {
|
||||
return AdminSummary{}, err
|
||||
}
|
||||
|
||||
auditRows, err := m.db.Query(ctx, `
|
||||
SELECT ae.id::text, ae.event_type, ae.target_type, ae.target_id, ae.payload, ae.created_at
|
||||
FROM audit_events ae
|
||||
@@ -265,6 +296,12 @@ func (m *Module) loadAdminSummary(ctx context.Context, orgID string) (AdminSumma
|
||||
if err := auditRows.Err(); err != nil {
|
||||
return AdminSummary{}, err
|
||||
}
|
||||
if services == nil {
|
||||
services = []ServiceSummary{}
|
||||
}
|
||||
if audit == nil {
|
||||
audit = []OrgAuditEvent{}
|
||||
}
|
||||
|
||||
return AdminSummary{
|
||||
OrganizationID: orgID,
|
||||
@@ -272,14 +309,34 @@ func (m *Module) loadAdminSummary(ctx context.Context, orgID string) (AdminSumma
|
||||
ActiveSessionCount: activeSessionCount,
|
||||
ServiceEndpoints: services,
|
||||
ConnectorStatus: map[string]any{
|
||||
"vpn": "not_implemented",
|
||||
"connector": "not_implemented",
|
||||
"vpn": map[string]any{
|
||||
"enabled_connections": vpnConnectionCount,
|
||||
"active_leases": vpnActiveLeaseCount,
|
||||
"packet_forwarding": vpnForwardingCount,
|
||||
"status": connectorStatus(vpnConnectionCount, vpnActiveLeaseCount, vpnForwardingCount),
|
||||
},
|
||||
"rdp": map[string]any{
|
||||
"status": "resource_catalog_ready",
|
||||
},
|
||||
},
|
||||
RecentAudit: audit,
|
||||
TopologyExposure: tenantSafeTopologyExposure(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func connectorStatus(enabledConnections, activeLeases, forwarding int64) string {
|
||||
if enabledConnections == 0 {
|
||||
return "not_configured"
|
||||
}
|
||||
if forwarding > 0 {
|
||||
return "active"
|
||||
}
|
||||
if activeLeases > 0 {
|
||||
return "gateway_blocked"
|
||||
}
|
||||
return "waiting_for_gateway"
|
||||
}
|
||||
|
||||
func tenantSafeTopologyExposure() string {
|
||||
return "tenant_safe_no_core_mesh_topology"
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type Resource struct {
|
||||
Address string `json:"address"`
|
||||
Protocol string `json:"protocol"`
|
||||
SecretRef *string `json:"secret_ref,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
CertificateVerificationMode string `json:"certificate_verification_mode"`
|
||||
RenderQualityProfile string `json:"render_quality_profile"`
|
||||
ClipboardMode string `json:"clipboard_mode"`
|
||||
@@ -116,6 +117,7 @@ func (m *Module) listResources(w http.ResponseWriter, r *http.Request) {
|
||||
query := `
|
||||
SELECT r.id, r.organization_id, r.name, r.address, r.protocol, r.secret_ref,
|
||||
r.certificate_verification_mode, r.metadata, r.created_at, r.updated_at,
|
||||
EXISTS (SELECT 1 FROM resource_secrets sec WHERE sec.resource_id = r.id) AS has_secret,
|
||||
COALESCE(rp.clipboard_mode, 'disabled') AS clipboard_mode,
|
||||
COALESCE(rp.file_transfer_mode, 'disabled') AS file_transfer_mode
|
||||
FROM resources r
|
||||
@@ -500,6 +502,7 @@ func (m *Module) getByID(ctx context.Context, resourceID string) (Resource, erro
|
||||
row := m.db.QueryRow(ctx, `
|
||||
SELECT r.id, r.organization_id, r.name, r.address, r.protocol, r.secret_ref,
|
||||
r.certificate_verification_mode, r.metadata, r.created_at, r.updated_at,
|
||||
EXISTS (SELECT 1 FROM resource_secrets sec WHERE sec.resource_id = r.id) AS has_secret,
|
||||
COALESCE(rp.clipboard_mode, 'disabled') AS clipboard_mode,
|
||||
COALESCE(rp.file_transfer_mode, 'disabled') AS file_transfer_mode
|
||||
FROM resources r
|
||||
@@ -555,6 +558,7 @@ func scanResource(row rowScanner) (Resource, error) {
|
||||
&resource.Metadata,
|
||||
&resource.CreatedAt,
|
||||
&resource.UpdatedAt,
|
||||
&resource.HasSecret,
|
||||
&resource.ClipboardMode,
|
||||
&resource.FileTransferMode,
|
||||
); err != nil {
|
||||
|
||||
@@ -214,9 +214,49 @@ END, prg.granted_at DESC
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("iterate platform role grants: %w", err)
|
||||
}
|
||||
if bestRole == PlatformRoleUser {
|
||||
if role, ok, err := strictBootstrappedOwnerFallback(ctx, db, verifier, userID, email); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return role, nil
|
||||
}
|
||||
return legacyPlatformRole(ctx, db, userID)
|
||||
}
|
||||
return bestRole, nil
|
||||
}
|
||||
|
||||
func strictBootstrappedOwnerFallback(ctx context.Context, db postgresplatform.DBTX, verifier *Verifier, userID, email string) (string, bool, error) {
|
||||
var role string
|
||||
var bootstrappedOwnerEmail *string
|
||||
var authorityState string
|
||||
var rootFingerprint string
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT u.platform_role, ia.bootstrapped_owner_email, ia.authority_state, ia.product_root_key_fingerprint
|
||||
FROM users u
|
||||
CROSS JOIN installation_authority ia
|
||||
WHERE u.id = $1::uuid
|
||||
AND ia.id = 1
|
||||
`, userID).Scan(&role, &bootstrappedOwnerEmail, &authorityState, &rootFingerprint)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PlatformRoleUser, false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("query strict bootstrapped owner fallback: %w", err)
|
||||
}
|
||||
if bootstrappedOwnerEmail == nil ||
|
||||
!strings.EqualFold(*bootstrappedOwnerEmail, email) ||
|
||||
authorityState != "active" ||
|
||||
rootFingerprint != verifier.RootFingerprint() {
|
||||
return PlatformRoleUser, false, nil
|
||||
}
|
||||
switch role {
|
||||
case PlatformRoleAdmin, PlatformRoleRecoveryAdmin:
|
||||
return role, true, nil
|
||||
default:
|
||||
return PlatformRoleUser, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func legacyPlatformRole(ctx context.Context, db postgresplatform.DBTX, userID string) (string, error) {
|
||||
var role string
|
||||
if err := db.QueryRow(ctx, `SELECT platform_role FROM users WHERE id = $1::uuid`, userID).Scan(&role); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -208,6 +209,7 @@ func buildRouter(logger *slog.Logger, modules ...module.Module) http.Handler {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ready"))
|
||||
})
|
||||
router.Post("/mesh/v1/health", controlPlaneMeshHealth)
|
||||
|
||||
router.Route("/api/v1", func(r chi.Router) {
|
||||
for _, mod := range modules {
|
||||
@@ -218,3 +220,34 @@ func buildRouter(logger *slog.Logger, modules ...module.Module) http.Handler {
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func controlPlaneMeshHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var message struct {
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
From struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
} `json:"from"`
|
||||
To struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
} `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
|
||||
http.Error(w, "invalid mesh health message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if message.ProtocolVersion != "mesh-control-v1" || message.From.ClusterID == "" || message.From.NodeID == "" {
|
||||
http.Error(w, "invalid mesh health message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"protocol_version": "mesh-control-v1",
|
||||
"accepted": true,
|
||||
"by": map[string]string{
|
||||
"cluster_id": message.From.ClusterID,
|
||||
"node_id": "control-plane-relay",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP INDEX IF EXISTS node_update_status_reports_latest_idx;
|
||||
DROP INDEX IF EXISTS release_artifacts_match_idx;
|
||||
DROP INDEX IF EXISTS release_versions_lookup_idx;
|
||||
|
||||
DROP TABLE IF EXISTS node_update_status_reports;
|
||||
DROP TABLE IF EXISTS node_update_desired_policies;
|
||||
DROP TABLE IF EXISTS release_artifacts;
|
||||
DROP TABLE IF EXISTS release_versions;
|
||||
@@ -0,0 +1,74 @@
|
||||
CREATE TABLE IF NOT EXISTS release_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'dev',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
compatibility JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
changelog TEXT,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
authority_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
authority_signature JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (cluster_id, product, version, channel)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release_artifacts (
|
||||
id UUID PRIMARY KEY,
|
||||
release_id UUID NOT NULL REFERENCES release_versions(id) ON DELETE CASCADE,
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
os TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
install_type TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
signature TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (release_id, os, arch, install_type, kind)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_update_desired_policies (
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'dev',
|
||||
target_version TEXT,
|
||||
strategy TEXT NOT NULL DEFAULT 'manual',
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
rollback_allowed BOOLEAN NOT NULL DEFAULT true,
|
||||
health_window_seconds INTEGER NOT NULL DEFAULT 180,
|
||||
updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (cluster_id, node_id, product)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_update_status_reports (
|
||||
id UUID PRIMARY KEY,
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
current_version TEXT NOT NULL DEFAULT '',
|
||||
target_version TEXT NOT NULL DEFAULT '',
|
||||
phase TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT,
|
||||
rollback_version TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS release_versions_lookup_idx
|
||||
ON release_versions (cluster_id, product, channel, status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS release_artifacts_match_idx
|
||||
ON release_artifacts (release_id, os, arch, install_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS node_update_status_reports_latest_idx
|
||||
ON node_update_status_reports (cluster_id, node_id, product, observed_at DESC);
|
||||
@@ -0,0 +1,26 @@
|
||||
DROP VIEW IF EXISTS cluster_admin_summaries;
|
||||
|
||||
CREATE VIEW cluster_admin_summaries AS
|
||||
SELECT
|
||||
c.id AS cluster_id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.status,
|
||||
c.region,
|
||||
COALESCE(cas.authority_state, 'authoritative') AS authority_state,
|
||||
COALESCE(cas.mutation_mode, 'normal') AS mutation_mode,
|
||||
ca.key_algorithm AS cluster_key_algorithm,
|
||||
ca.public_key_fingerprint AS cluster_key_fingerprint,
|
||||
COUNT(DISTINCT cm.node_id) AS node_count,
|
||||
COUNT(DISTINCT CASE WHEN n.health_status = 'healthy' THEN n.id END) AS healthy_node_count,
|
||||
COUNT(DISTINCT CASE WHEN njr.status = 'pending' THEN njr.id END) AS pending_join_count,
|
||||
COUNT(DISTINCT nra.id) AS active_role_assignment_count,
|
||||
MAX(n.last_seen_at) AS last_node_seen_at
|
||||
FROM clusters c
|
||||
LEFT JOIN cluster_authority_states cas ON cas.cluster_id = c.id
|
||||
LEFT JOIN cluster_authorities ca ON ca.cluster_id = c.id
|
||||
LEFT JOIN cluster_memberships cm ON cm.cluster_id = c.id
|
||||
LEFT JOIN nodes n ON n.id = cm.node_id
|
||||
LEFT JOIN node_join_requests njr ON njr.cluster_id = c.id
|
||||
LEFT JOIN node_role_assignments nra ON nra.cluster_id = c.id AND nra.status = 'active'
|
||||
GROUP BY c.id, c.slug, c.name, c.status, c.region, cas.authority_state, cas.mutation_mode, ca.key_algorithm, ca.public_key_fingerprint;
|
||||
@@ -0,0 +1,29 @@
|
||||
DROP VIEW IF EXISTS cluster_admin_summaries;
|
||||
|
||||
CREATE VIEW cluster_admin_summaries AS
|
||||
SELECT
|
||||
c.id AS cluster_id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.status,
|
||||
c.region,
|
||||
COALESCE(cas.authority_state, 'authoritative') AS authority_state,
|
||||
COALESCE(cas.mutation_mode, 'normal') AS mutation_mode,
|
||||
ca.key_algorithm AS cluster_key_algorithm,
|
||||
ca.public_key_fingerprint AS cluster_key_fingerprint,
|
||||
COUNT(DISTINCT cm.node_id) AS node_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN n.health_status = 'healthy'
|
||||
AND n.last_seen_at >= NOW() - '1 minute'::interval THEN n.id
|
||||
END) AS healthy_node_count,
|
||||
COUNT(DISTINCT CASE WHEN njr.status = 'pending' THEN njr.id END) AS pending_join_count,
|
||||
COUNT(DISTINCT nra.id) AS active_role_assignment_count,
|
||||
MAX(n.last_seen_at) AS last_node_seen_at
|
||||
FROM clusters c
|
||||
LEFT JOIN cluster_authority_states cas ON cas.cluster_id = c.id
|
||||
LEFT JOIN cluster_authorities ca ON ca.cluster_id = c.id
|
||||
LEFT JOIN cluster_memberships cm ON cm.cluster_id = c.id
|
||||
LEFT JOIN nodes n ON n.id = cm.node_id
|
||||
LEFT JOIN node_join_requests njr ON njr.cluster_id = c.id
|
||||
LEFT JOIN node_role_assignments nra ON nra.cluster_id = c.id AND nra.status = 'active'
|
||||
GROUP BY c.id, c.slug, c.name, c.status, c.region, cas.authority_state, cas.mutation_mode, ca.key_algorithm, ca.public_key_fingerprint;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS fabric_service_channel_route_feedback_latest;
|
||||
DROP TABLE IF EXISTS fabric_service_channel_route_feedback_observations;
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS fabric_service_channel_route_feedback_observations (
|
||||
id UUID PRIMARY KEY,
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
reporter_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
route_id TEXT NOT NULL,
|
||||
service_class TEXT NOT NULL,
|
||||
feedback_status TEXT NOT NULL,
|
||||
score_adjustment INTEGER NOT NULL DEFAULT 0,
|
||||
reasons TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
stall_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_send_duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_route_feedback_observed
|
||||
ON fabric_service_channel_route_feedback_observations (cluster_id, reporter_node_id, service_class, observed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_route_feedback_route
|
||||
ON fabric_service_channel_route_feedback_observations (cluster_id, route_id, observed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fabric_service_channel_route_feedback_latest (
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
reporter_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
route_id TEXT NOT NULL,
|
||||
observation_id UUID NOT NULL REFERENCES fabric_service_channel_route_feedback_observations(id) ON DELETE CASCADE,
|
||||
service_class TEXT NOT NULL,
|
||||
feedback_status TEXT NOT NULL,
|
||||
score_adjustment INTEGER NOT NULL DEFAULT 0,
|
||||
reasons TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
stall_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_send_duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (cluster_id, reporter_node_id, route_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_route_feedback_latest_active
|
||||
ON fabric_service_channel_route_feedback_latest (cluster_id, reporter_node_id, service_class, expires_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS fabric_service_channel_route_rebuild_attempts;
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE IF NOT EXISTS fabric_service_channel_route_rebuild_attempts (
|
||||
id UUID PRIMARY KEY,
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
reporter_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
service_class TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
replacement_route_id TEXT NOT NULL DEFAULT '',
|
||||
rebuild_request_id TEXT NOT NULL,
|
||||
rebuild_status TEXT NOT NULL,
|
||||
rebuild_reason TEXT NOT NULL DEFAULT '',
|
||||
rebuild_attempt INTEGER NOT NULL DEFAULT 0,
|
||||
decision_source TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
generation TEXT NOT NULL DEFAULT '',
|
||||
policy_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
observed_policy_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
observed_route_generation TEXT NOT NULL DEFAULT '',
|
||||
effective_route_generation TEXT NOT NULL DEFAULT '',
|
||||
feedback_status TEXT NOT NULL DEFAULT '',
|
||||
feedback_score_adjustment INTEGER NOT NULL DEFAULT 0,
|
||||
feedback_effective_score_adjustment INTEGER NOT NULL DEFAULT 0,
|
||||
feedback_reasons TEXT[] NOT NULL DEFAULT '{}',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
stall_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_send_duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
quality_window_sample_count INTEGER NOT NULL DEFAULT 0,
|
||||
quality_window_failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
quality_window_drop_count INTEGER NOT NULL DEFAULT 0,
|
||||
quality_window_slow_count INTEGER NOT NULL DEFAULT 0,
|
||||
old_hops TEXT[] NOT NULL DEFAULT '{}',
|
||||
replacement_hops TEXT[] NOT NULL DEFAULT '{}',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (cluster_id, reporter_node_id, service_class, route_id, rebuild_request_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_rebuild_attempts_cluster_reporter_updated
|
||||
ON fabric_service_channel_route_rebuild_attempts (cluster_id, reporter_node_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_rebuild_attempts_cluster_route_updated
|
||||
ON fabric_service_channel_route_rebuild_attempts (cluster_id, route_id, updated_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS fabric_service_channel_rebuild_alert_silences;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS fabric_service_channel_rebuild_alert_silences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
reporter_node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
route_id TEXT NOT NULL,
|
||||
guard_status TEXT NOT NULL,
|
||||
generation TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (cluster_id, reporter_node_id, route_id, guard_status, generation)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_rebuild_alert_silences_active
|
||||
ON fabric_service_channel_rebuild_alert_silences (cluster_id, expires_at DESC);
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
DROP INDEX IF EXISTS idx_fsc_rebuild_attempts_cluster_guard_updated;
|
||||
|
||||
ALTER TABLE fabric_service_channel_route_rebuild_attempts
|
||||
DROP COLUMN IF EXISTS correlation_snapshot_at,
|
||||
DROP COLUMN IF EXISTS correlation_timeline,
|
||||
DROP COLUMN IF EXISTS guard_traffic_deadline_seconds,
|
||||
DROP COLUMN IF EXISTS guard_transition_deadline_seconds,
|
||||
DROP COLUMN IF EXISTS guard_reason,
|
||||
DROP COLUMN IF EXISTS guard_severity,
|
||||
DROP COLUMN IF EXISTS guard_status,
|
||||
DROP COLUMN IF EXISTS post_rebuild_send_flow_dropped,
|
||||
DROP COLUMN IF EXISTS post_rebuild_send_flow_packets,
|
||||
DROP COLUMN IF EXISTS post_rebuild_send_failures,
|
||||
DROP COLUMN IF EXISTS post_rebuild_send_packets,
|
||||
DROP COLUMN IF EXISTS post_rebuild_selected_route_id,
|
||||
DROP COLUMN IF EXISTS node_route_generation_matched,
|
||||
DROP COLUMN IF EXISTS node_route_generation_withdrawn_at,
|
||||
DROP COLUMN IF EXISTS node_route_generation_applied_at,
|
||||
DROP COLUMN IF EXISTS node_route_generation_status,
|
||||
DROP COLUMN IF EXISTS node_transition_matched,
|
||||
DROP COLUMN IF EXISTS node_transition_observed_at,
|
||||
DROP COLUMN IF EXISTS node_transition_generation,
|
||||
DROP COLUMN IF EXISTS node_transition_status;
|
||||
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE fabric_service_channel_route_rebuild_attempts
|
||||
ADD COLUMN IF NOT EXISTS node_transition_status TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_transition_generation TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_transition_observed_at TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_transition_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS node_route_generation_status TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_route_generation_applied_at TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_route_generation_withdrawn_at TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS node_route_generation_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS post_rebuild_selected_route_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS post_rebuild_send_packets BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS post_rebuild_send_failures BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS post_rebuild_send_flow_packets BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS post_rebuild_send_flow_dropped BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS guard_status TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS guard_severity TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS guard_reason TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS guard_transition_deadline_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS guard_traffic_deadline_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS correlation_timeline JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS correlation_snapshot_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fsc_rebuild_attempts_cluster_guard_updated
|
||||
ON fabric_service_channel_route_rebuild_attempts (cluster_id, guard_severity, guard_status, updated_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS fabric_service_channel_leases;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS fabric_service_channel_leases (
|
||||
cluster_id UUID NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
channel_id UUID NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL DEFAULT '',
|
||||
service_class TEXT NOT NULL,
|
||||
selected_entry_node_id UUID NULL REFERENCES nodes(id) ON DELETE SET NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
lease JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (cluster_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fabric_service_channel_leases_cluster_expires_idx
|
||||
ON fabric_service_channel_leases(cluster_id, expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fabric_service_channel_leases_entry_idx
|
||||
ON fabric_service_channel_leases(cluster_id, selected_entry_node_id, expires_at);
|
||||
@@ -0,0 +1,17 @@
|
||||
DELETE FROM mesh_qos_policies
|
||||
WHERE service_class IN ('remote_workspace', 'video')
|
||||
AND metadata->>'fabric_service_channel' = 'true';
|
||||
|
||||
ALTER TABLE mesh_route_intents
|
||||
DROP CONSTRAINT IF EXISTS mesh_route_intents_service_class_check;
|
||||
|
||||
ALTER TABLE mesh_route_intents
|
||||
ADD CONSTRAINT mesh_route_intents_service_class_check
|
||||
CHECK (service_class IN ('input', 'control', 'synthetic', 'render', 'clipboard', 'file_transfer', 'vpn_packets', 'telemetry'));
|
||||
|
||||
ALTER TABLE mesh_qos_policies
|
||||
DROP CONSTRAINT IF EXISTS mesh_qos_policies_service_class_check;
|
||||
|
||||
ALTER TABLE mesh_qos_policies
|
||||
ADD CONSTRAINT mesh_qos_policies_service_class_check
|
||||
CHECK (service_class IN ('input', 'control', 'synthetic', 'render', 'clipboard', 'file_transfer', 'vpn_packets', 'telemetry'));
|
||||
@@ -0,0 +1,29 @@
|
||||
ALTER TABLE mesh_route_intents
|
||||
DROP CONSTRAINT IF EXISTS mesh_route_intents_service_class_check;
|
||||
|
||||
ALTER TABLE mesh_route_intents
|
||||
ADD CONSTRAINT mesh_route_intents_service_class_check
|
||||
CHECK (service_class IN ('input', 'control', 'synthetic', 'render', 'clipboard', 'file_transfer', 'vpn_packets', 'remote_workspace', 'video', 'telemetry'));
|
||||
|
||||
ALTER TABLE mesh_qos_policies
|
||||
DROP CONSTRAINT IF EXISTS mesh_qos_policies_service_class_check;
|
||||
|
||||
ALTER TABLE mesh_qos_policies
|
||||
ADD CONSTRAINT mesh_qos_policies_service_class_check
|
||||
CHECK (service_class IN ('input', 'control', 'synthetic', 'render', 'clipboard', 'file_transfer', 'vpn_packets', 'remote_workspace', 'video', 'telemetry'));
|
||||
|
||||
INSERT INTO mesh_qos_policies (
|
||||
cluster_id, service_class, priority, reliability_mode, drop_policy, bandwidth_policy, metadata
|
||||
)
|
||||
SELECT c.id, 'remote_workspace', 20, 'adaptive', 'adaptive', '{}'::jsonb,
|
||||
'{"default":true,"fabric_service_channel":true,"interactive":true}'::jsonb
|
||||
FROM clusters c
|
||||
ON CONFLICT (cluster_id, service_class) DO NOTHING;
|
||||
|
||||
INSERT INTO mesh_qos_policies (
|
||||
cluster_id, service_class, priority, reliability_mode, drop_policy, bandwidth_policy, metadata
|
||||
)
|
||||
SELECT c.id, 'video', 40, 'adaptive', 'adaptive', '{}'::jsonb,
|
||||
'{"default":true,"fabric_service_channel":true,"adaptive":true}'::jsonb
|
||||
FROM clusters c
|
||||
ON CONFLICT (cluster_id, service_class) DO NOTHING;
|
||||
Reference in New Issue
Block a user