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), User: user.NewHandler(userService, auditService),
Device: device.NewHandler(deviceService, auditService), Device: device.NewHandler(deviceService, auditService),
Policy: policy.NewHandler(policyService, auditService), Policy: policy.NewHandler(policyService, auditService),
Gateway: gateway.NewHandler(gatewayService), Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
Audit: audit.NewHandler(auditService), Audit: audit.NewHandler(auditService),
}) })

View File

@@ -11,10 +11,11 @@ import (
type Handler struct { type Handler struct {
service *Service service *Service
bootstrapToken string
} }
func NewHandler(service *Service) *Handler { func NewHandler(service *Service, bootstrapToken string) *Handler {
return &Handler{service: service} return &Handler{service: service, bootstrapToken: bootstrapToken}
} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { 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) 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) FirstActive(ctx context.Context) (Gateway, error)
BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error)
Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error)
UpsertByName(ctx context.Context, input BootstrapRequest) (Gateway, error)
} }
type PGRepository struct { type PGRepository struct {
@@ -136,6 +137,27 @@ func (r *PGRepository) Update(ctx context.Context, gatewayID uuid.UUID, input Up
return item, err 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) { func gatewayInterfaceAddress(cidr string) (string, error) {
prefix, err := netip.ParsePrefix(cidr) prefix, err := netip.ParsePrefix(cidr)
if err != nil { 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) 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"` DNSServers []string `json:"dns_servers"`
IsActive bool `json:"is_active"` 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/login", handlers.Auth.Login)
r.Post("/auth/refresh", handlers.Auth.Refresh) r.Post("/auth/refresh", handlers.Auth.Refresh)
r.Post("/auth/logout", handlers.Auth.Logout) 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.Group(func(r chi.Router) {
r.Use(AuthMiddleware(jwtSecret)) r.Use(AuthMiddleware(jwtSecret))

View File

@@ -12,8 +12,11 @@ DEFAULT_DNS_SERVERS=10.20.0.53
DEFAULT_VPN_CIDR=100.96.0.0/24 DEFAULT_VPN_CIDR=100.96.0.0/24
DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820 DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820
DEFAULT_GATEWAY_PUBLIC_KEY=replace-me DEFAULT_GATEWAY_PUBLIC_KEY=replace-me
GATEWAY_BOOTSTRAP_TOKEN=nexavpn-gateway-bootstrap
NEXAVPN_GATEWAY_ID= NEXAVPN_GATEWAY_ID=
NEXAVPN_GATEWAY_SYNC_URL=http://backend:8080/api/v1/admin/gateways NEXAVPN_GATEWAY_NAME=primary-gateway
NEXAVPN_GATEWAY_SYNC_URL=http://backend:8080/api/v1/gateway-agent
NEXAVPN_GATEWAY_BOOTSTRAP_URL=http://backend:8080/api/v1/gateway-agent/bootstrap
NEXAVPN_API_TOKEN= NEXAVPN_API_TOKEN=
NEXAVPN_GATEWAY_PRIVATE_KEY= NEXAVPN_GATEWAY_PRIVATE_KEY=
NEXAVPN_GATEWAY_INTERFACE=wg0 NEXAVPN_GATEWAY_INTERFACE=wg0

View File

@@ -61,8 +61,13 @@ services:
devices: devices:
- /dev/net/tun:/dev/net/tun - /dev/net/tun:/dev/net/tun
environment: environment:
GATEWAY_BOOTSTRAP_TOKEN: ${GATEWAY_BOOTSTRAP_TOKEN:-nexavpn-gateway-bootstrap}
NEXAVPN_GATEWAY_ID: ${NEXAVPN_GATEWAY_ID:-} NEXAVPN_GATEWAY_ID: ${NEXAVPN_GATEWAY_ID:-}
NEXAVPN_GATEWAY_SYNC_URL: ${NEXAVPN_GATEWAY_SYNC_URL:-http://backend:8080/api/v1/admin/gateways} NEXAVPN_GATEWAY_NAME: ${NEXAVPN_GATEWAY_NAME:-primary-gateway}
NEXAVPN_GATEWAY_SYNC_URL: ${NEXAVPN_GATEWAY_SYNC_URL:-http://backend:8080/api/v1/gateway-agent}
NEXAVPN_GATEWAY_BOOTSTRAP_URL: ${NEXAVPN_GATEWAY_BOOTSTRAP_URL:-http://backend:8080/api/v1/gateway-agent/bootstrap}
DEFAULT_GATEWAY_ENDPOINT: ${DEFAULT_GATEWAY_ENDPOINT:-localhost:51820}
DEFAULT_VPN_CIDR: ${DEFAULT_VPN_CIDR:-100.96.0.0/24}
NEXAVPN_API_TOKEN: ${NEXAVPN_API_TOKEN:-} NEXAVPN_API_TOKEN: ${NEXAVPN_API_TOKEN:-}
NEXAVPN_GATEWAY_PRIVATE_KEY: ${NEXAVPN_GATEWAY_PRIVATE_KEY:-} NEXAVPN_GATEWAY_PRIVATE_KEY: ${NEXAVPN_GATEWAY_PRIVATE_KEY:-}
NEXAVPN_GATEWAY_INTERFACE: ${NEXAVPN_GATEWAY_INTERFACE:-wg0} NEXAVPN_GATEWAY_INTERFACE: ${NEXAVPN_GATEWAY_INTERFACE:-wg0}

View File

@@ -7,10 +7,45 @@ mkdir -p /var/lib/nexavpn
IFACE="${NEXAVPN_GATEWAY_INTERFACE:-wg0}" IFACE="${NEXAVPN_GATEWAY_INTERFACE:-wg0}"
UPLINK_IFACE="${NEXAVPN_UPLINK_INTERFACE:-eth0}" UPLINK_IFACE="${NEXAVPN_UPLINK_INTERFACE:-eth0}"
ENABLE_MASQUERADE="${NEXAVPN_ENABLE_MASQUERADE:-true}" ENABLE_MASQUERADE="${NEXAVPN_ENABLE_MASQUERADE:-true}"
GATEWAY_NAME="${NEXAVPN_GATEWAY_NAME:-primary-gateway}"
BOOTSTRAP_URL="${NEXAVPN_GATEWAY_BOOTSTRAP_URL:-http://backend:8080/api/v1/gateway-agent/bootstrap}"
GATEWAY_ID_FILE="/var/lib/nexavpn/gateway-id"
if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] || [ -z "${NEXAVPN_API_TOKEN:-}" ] || [ -z "${NEXAVPN_GATEWAY_PRIVATE_KEY:-}" ]; then if [ -z "${GATEWAY_BOOTSTRAP_TOKEN:-}" ]; then
echo "GATEWAY_BOOTSTRAP_TOKEN is required."
tail -f /dev/null
exit 0
fi
if [ -z "${NEXAVPN_GATEWAY_PRIVATE_KEY:-}" ]; then
if [ -f /var/lib/nexavpn/gateway-private.key ]; then
NEXAVPN_GATEWAY_PRIVATE_KEY="$(cat /var/lib/nexavpn/gateway-private.key)"
else
wg genkey | tee /var/lib/nexavpn/gateway-private.key >/tmp/nexavpn-gateway-private.key
NEXAVPN_GATEWAY_PRIVATE_KEY="$(cat /tmp/nexavpn-gateway-private.key)"
rm -f /tmp/nexavpn-gateway-private.key
fi
fi
if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] && [ -f "${GATEWAY_ID_FILE}" ]; then
NEXAVPN_GATEWAY_ID="$(cat "${GATEWAY_ID_FILE}")"
fi
if [ -z "${NEXAVPN_GATEWAY_ID:-}" ]; then
GATEWAY_PUBLIC_KEY="$(printf '%s' "${NEXAVPN_GATEWAY_PRIVATE_KEY}" | wg pubkey)"
echo "Bootstrapping gateway ${GATEWAY_NAME}"
BOOTSTRAP_RESPONSE="$(curl -fsSL \
-H "Content-Type: application/json" \
-H "X-Gateway-Bootstrap-Token: ${GATEWAY_BOOTSTRAP_TOKEN}" \
-d "{\"name\":\"${GATEWAY_NAME}\",\"endpoint\":\"${DEFAULT_GATEWAY_ENDPOINT:-localhost:51820}\",\"public_key\":\"${GATEWAY_PUBLIC_KEY}\",\"listen_port\":51820,\"vpn_cidr\":\"${DEFAULT_VPN_CIDR:-100.96.0.0/24}\",\"dns_servers\":[\"10.20.0.53\"]}" \
"${BOOTSTRAP_URL}")"
NEXAVPN_GATEWAY_ID="$(printf '%s' "${BOOTSTRAP_RESPONSE}" | jq -r '.id')"
printf '%s' "${NEXAVPN_GATEWAY_ID}" > "${GATEWAY_ID_FILE}"
fi
if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] || [ -z "${NEXAVPN_GATEWAY_PRIVATE_KEY:-}" ]; then
echo "Gateway sync is not configured yet." echo "Gateway sync is not configured yet."
echo "Set NEXAVPN_GATEWAY_ID, NEXAVPN_API_TOKEN and NEXAVPN_GATEWAY_PRIVATE_KEY." echo "Gateway bootstrap or key generation failed."
echo "Gateway apply state will be written to /var/lib/nexavpn when configured." echo "Gateway apply state will be written to /var/lib/nexavpn when configured."
tail -f /dev/null tail -f /dev/null
exit 0 exit 0
@@ -27,7 +62,7 @@ mkdir -p /etc/wireguard
apply_bundle() { apply_bundle() {
echo "Fetching bundle from ${SYNC_URL}" echo "Fetching bundle from ${SYNC_URL}"
curl -fsSL \ curl -fsSL \
-H "Authorization: Bearer ${NEXAVPN_API_TOKEN}" \ -H "X-Gateway-Bootstrap-Token: ${GATEWAY_BOOTSTRAP_TOKEN}" \
"${SYNC_URL}" \ "${SYNC_URL}" \
-o "${STATE_JSON}" -o "${STATE_JSON}"