feat: add gateway bootstrap endpoint with token-based authentication

Add Bootstrap and AgentSyncBundle handlers to gateway package with X-Gateway-Bootstrap-Token header authentication. Implement UpsertByName repository method for idempotent gateway registration. Update gateway entrypoint script to auto-generate keys and bootstrap gateway on first run, persisting gateway ID to disk. Add GATEWAY_BOOTSTRAP_TOKEN config and update environment variables for gateway name, bootstrap URL, and sync URL.
This commit is contained in:
2026-03-17 18:53:26 +01:00
parent a197fb5bb6
commit 16fc6cb1b6
9 changed files with 138 additions and 9 deletions

View File

@@ -45,7 +45,7 @@ func New(cfg config.Config) (*App, error) {
User: user.NewHandler(userService, auditService),
Device: device.NewHandler(deviceService, auditService),
Policy: policy.NewHandler(policyService, auditService),
Gateway: gateway.NewHandler(gatewayService),
Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
Audit: audit.NewHandler(auditService),
})

View File

@@ -10,11 +10,12 @@ import (
)
type Handler struct {
service *Service
service *Service
bootstrapToken string
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
func NewHandler(service *Service, bootstrapToken string) *Handler {
return &Handler{service: service, bootstrapToken: bootstrapToken}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
@@ -52,3 +53,39 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
apiutil.JSON(w, http.StatusOK, item)
}
func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Gateway-Bootstrap-Token") != h.bootstrapToken {
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid gateway bootstrap token")
return
}
var input BootstrapRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
item, err := h.service.Bootstrap(r.Context(), input)
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "gateway_bootstrap_failed", "unable to bootstrap gateway")
return
}
apiutil.JSON(w, http.StatusOK, item)
}
func (h *Handler) AgentSyncBundle(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Gateway-Bootstrap-Token") != h.bootstrapToken {
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid gateway bootstrap token")
return
}
bundle, err := h.service.BuildSyncBundle(r.Context(), chi.URLParam(r, "id"))
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "gateway_sync_failed", "unable to build sync bundle")
return
}
apiutil.JSON(w, http.StatusOK, bundle)
}

View File

@@ -15,6 +15,7 @@ type Repository interface {
FirstActive(ctx context.Context) (Gateway, error)
BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error)
Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error)
UpsertByName(ctx context.Context, input BootstrapRequest) (Gateway, error)
}
type PGRepository struct {
@@ -136,6 +137,27 @@ func (r *PGRepository) Update(ctx context.Context, gatewayID uuid.UUID, input Up
return item, err
}
func (r *PGRepository) UpsertByName(ctx context.Context, input BootstrapRequest) (Gateway, error) {
row := r.db.QueryRow(ctx, `
insert into gateways (id, name, endpoint, public_key, listen_port, vpn_cidr, dns_servers, is_active)
values ($1, $2, $3, $4, $5, $6::cidr, $7::text[], true)
on conflict (name)
do update set
endpoint = excluded.endpoint,
public_key = excluded.public_key,
listen_port = excluded.listen_port,
vpn_cidr = excluded.vpn_cidr,
dns_servers = excluded.dns_servers,
is_active = true,
updated_at = now()
returning id, name, endpoint, public_key, listen_port, vpn_cidr::text, dns_servers, is_active
`, uuid.New(), input.Name, input.Endpoint, input.PublicKey, input.ListenPort, input.VPNCIDR, input.DNSServers)
var item Gateway
err := row.Scan(&item.ID, &item.Name, &item.Endpoint, &item.PublicKey, &item.ListenPort, &item.VPNCIDR, &item.DNSServers, &item.IsActive)
return item, err
}
func gatewayInterfaceAddress(cidr string) (string, error) {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {

View File

@@ -39,3 +39,19 @@ func (s *Service) Update(ctx context.Context, gatewayID string, input UpdateRequ
}
return s.repo.Update(ctx, id, input)
}
func (s *Service) Bootstrap(ctx context.Context, input BootstrapRequest) (Gateway, error) {
if input.Name == "" {
input.Name = "primary-gateway"
}
if input.ListenPort == 0 {
input.ListenPort = 51820
}
if input.VPNCIDR == "" {
input.VPNCIDR = "100.96.0.0/24"
}
if len(input.DNSServers) == 0 {
input.DNSServers = []string{"10.20.0.53"}
}
return s.repo.UpsertByName(ctx, input)
}

View File

@@ -21,3 +21,12 @@ type UpdateRequest struct {
DNSServers []string `json:"dns_servers"`
IsActive bool `json:"is_active"`
}
type BootstrapRequest struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
PublicKey string `json:"public_key"`
ListenPort int `json:"listen_port"`
VPNCIDR string `json:"vpn_cidr"`
DNSServers []string `json:"dns_servers"`
}

View File

@@ -36,6 +36,8 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r.Post("/auth/login", handlers.Auth.Login)
r.Post("/auth/refresh", handlers.Auth.Refresh)
r.Post("/auth/logout", handlers.Auth.Logout)
r.Post("/gateway-agent/bootstrap", handlers.Gateway.Bootstrap)
r.Get("/gateway-agent/{id}/sync", handlers.Gateway.AgentSyncBundle)
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(jwtSecret))