From 16fc6cb1b6caf51db8f01023fd66ca0aa3edfca2 Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 17 Mar 2026 18:53:26 +0100 Subject: [PATCH] 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. --- backend/internal/app/app.go | 2 +- backend/internal/gateway/handler.go | 43 ++++++++++++++++++++++++-- backend/internal/gateway/repository.go | 22 +++++++++++++ backend/internal/gateway/service.go | 16 ++++++++++ backend/internal/gateway/types.go | 9 ++++++ backend/internal/httpserver/router.go | 2 ++ deploy/.env.example | 5 ++- deploy/docker-compose.yml | 7 ++++- deploy/scripts/gateway-entrypoint.sh | 41 ++++++++++++++++++++++-- 9 files changed, 138 insertions(+), 9 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 66de623..2fe41a6 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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), }) diff --git a/backend/internal/gateway/handler.go b/backend/internal/gateway/handler.go index e0a1d7e..231259e 100644 --- a/backend/internal/gateway/handler.go +++ b/backend/internal/gateway/handler.go @@ -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) +} diff --git a/backend/internal/gateway/repository.go b/backend/internal/gateway/repository.go index ddeaea6..a66a650 100644 --- a/backend/internal/gateway/repository.go +++ b/backend/internal/gateway/repository.go @@ -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 { diff --git a/backend/internal/gateway/service.go b/backend/internal/gateway/service.go index 709a1a5..c4714ec 100644 --- a/backend/internal/gateway/service.go +++ b/backend/internal/gateway/service.go @@ -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) +} diff --git a/backend/internal/gateway/types.go b/backend/internal/gateway/types.go index eac2825..3834a51 100644 --- a/backend/internal/gateway/types.go +++ b/backend/internal/gateway/types.go @@ -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"` +} diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index a7620c5..645a0c4 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -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)) diff --git a/deploy/.env.example b/deploy/.env.example index a5e6c35..2dee470 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -12,8 +12,11 @@ DEFAULT_DNS_SERVERS=10.20.0.53 DEFAULT_VPN_CIDR=100.96.0.0/24 DEFAULT_GATEWAY_ENDPOINT=vpn.example.com:51820 DEFAULT_GATEWAY_PUBLIC_KEY=replace-me +GATEWAY_BOOTSTRAP_TOKEN=nexavpn-gateway-bootstrap 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_GATEWAY_PRIVATE_KEY= NEXAVPN_GATEWAY_INTERFACE=wg0 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0e23d7d..287d11e 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -61,8 +61,13 @@ services: devices: - /dev/net/tun:/dev/net/tun environment: + GATEWAY_BOOTSTRAP_TOKEN: ${GATEWAY_BOOTSTRAP_TOKEN:-nexavpn-gateway-bootstrap} 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_GATEWAY_PRIVATE_KEY: ${NEXAVPN_GATEWAY_PRIVATE_KEY:-} NEXAVPN_GATEWAY_INTERFACE: ${NEXAVPN_GATEWAY_INTERFACE:-wg0} diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh index 6f08bae..65b9de9 100644 --- a/deploy/scripts/gateway-entrypoint.sh +++ b/deploy/scripts/gateway-entrypoint.sh @@ -7,10 +7,45 @@ mkdir -p /var/lib/nexavpn IFACE="${NEXAVPN_GATEWAY_INTERFACE:-wg0}" UPLINK_IFACE="${NEXAVPN_UPLINK_INTERFACE:-eth0}" 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 "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." tail -f /dev/null exit 0 @@ -27,7 +62,7 @@ mkdir -p /etc/wireguard apply_bundle() { echo "Fetching bundle from ${SYNC_URL}" curl -fsSL \ - -H "Authorization: Bearer ${NEXAVPN_API_TOKEN}" \ + -H "X-Gateway-Bootstrap-Token: ${GATEWAY_BOOTSTRAP_TOKEN}" \ "${SYNC_URL}" \ -o "${STATE_JSON}"