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:
@@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user