chore: initial project scaffold with admin web, backend, desktop client, and deployment setup

Add monorepo structure for NexaVPN WireGuard control plane including:
- .gitignore for node_modules, build artifacts, and environment files
- README with project overview, monorepo layout, and quick start guide
- Admin web UI with React, Vite, TypeScript, and nginx reverse proxy
- API client with type definitions for users, devices, policies, gateways, and audit logs
- Admin pages for dashboard, users, devices, policies, g
This commit is contained in:
2026-03-15 16:32:34 +01:00
commit 830491cb0d
91 changed files with 5279 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
package httpserver
import (
"context"
"net/http"
"strings"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
"github.com/nexavpn/nexavpn/backend/internal/auth"
)
type contextKey string
const claimsContextKey contextKey = "claims"
func BaseMiddleware(next http.Handler) http.Handler {
return middleware.RealIP(middleware.RequestID(middleware.Logger(next)))
}
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing bearer token")
return
}
claims, err := auth.ParseAccessToken(jwtSecret, strings.TrimPrefix(header, "Bearer "))
if err != nil {
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid access token")
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func AdminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok || claims.Role != "admin" {
apiutil.Error(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
func ClaimsFromContext(ctx context.Context) (auth.Claims, bool) {
claims, ok := ctx.Value(claimsContextKey).(auth.Claims)
return claims, ok
}
func MustUserID(ctx context.Context) (uuid.UUID, bool) {
claims, ok := ClaimsFromContext(ctx)
if !ok {
return uuid.Nil, false
}
return claims.UserID, true
}

View File

@@ -0,0 +1,68 @@
package httpserver
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/nexavpn/nexavpn/backend/internal/apiutil"
"github.com/nexavpn/nexavpn/backend/internal/auth"
"github.com/nexavpn/nexavpn/backend/internal/audit"
"github.com/nexavpn/nexavpn/backend/internal/device"
"github.com/nexavpn/nexavpn/backend/internal/gateway"
"github.com/nexavpn/nexavpn/backend/internal/policy"
"github.com/nexavpn/nexavpn/backend/internal/user"
)
type Handlers struct {
Auth *auth.Handler
User *user.Handler
Device *device.Handler
Policy *policy.Handler
Gateway *gateway.Handler
Audit *audit.Handler
}
func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r := chi.NewRouter()
r.Use(BaseMiddleware)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
apiutil.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Route("/api/v1", func(r chi.Router) {
r.Post("/auth/bootstrap", handlers.Auth.Bootstrap)
r.Post("/auth/login", handlers.Auth.Login)
r.Post("/auth/refresh", handlers.Auth.Refresh)
r.Post("/auth/logout", handlers.Auth.Logout)
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(jwtSecret))
r.Get("/auth/me", handlers.Auth.Me)
r.Post("/devices/enroll", handlers.Device.Enroll)
r.Get("/me/devices", handlers.Device.ListOwn)
r.Get("/me/profile", handlers.Device.GetOwnProfile)
r.Get("/connection/status", handlers.Device.ConnectionStatus)
r.Route("/admin", func(r chi.Router) {
r.Use(AdminOnly)
r.Get("/users", handlers.User.List)
r.Post("/users", handlers.User.Create)
r.Post("/users/{id}/disable", handlers.User.Disable)
r.Post("/users/{id}/enable", handlers.User.Enable)
r.Get("/devices", handlers.Device.ListAll)
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
r.Get("/policies", handlers.Policy.List)
r.Post("/policies", handlers.Policy.Create)
r.Get("/gateways", handlers.Gateway.List)
r.Get("/gateways/{id}/sync", handlers.Gateway.SyncBundle)
r.Get("/audit-logs", handlers.Audit.List)
})
})
})
return r
}