diff --git a/server/admin.go b/server/admin.go new file mode 100644 index 0000000..317b2e7 --- /dev/null +++ b/server/admin.go @@ -0,0 +1,136 @@ +package server + +import ( + "net/http" + "strconv" + "strings" + + "github.com/govi218/at-mesh/internal/db" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" +) + +// adminMiddleware checks if the user is authenticated as admin. +// Returns 401 JSON for API requests, redirects to /admin/login for pages. +func (s *Server) adminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(e echo.Context) error { + sess, _ := session.Get("atmesh", e) + if auth, ok := sess.Values["admin"].(bool); !ok || !auth { + // API requests get JSON 401, page requests get redirected + if e.Path() != "/admin/login" && (e.Request().Header.Get("Accept") == "application/json" || + e.Request().Header.Get("Content-Type") == "application/json" || + strings.HasPrefix(e.Path(), "/api/")) { + return e.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + } + return e.Redirect(http.StatusSeeOther, "/admin/login") + } + return next(e) + } +} + +// handleAdminLoginGet shows the admin login form. +func (s *Server) handleAdminLoginGet(e echo.Context) error { + return e.HTML(http.StatusOK, adminLoginHTML) +} + +// handleAdminLoginPost validates the admin token and sets the session. +func (s *Server) handleAdminLoginPost(e echo.Context) error { + token := e.FormValue("token") + if token == "" || token != s.config.AdminToken { + return e.HTML(http.StatusUnauthorized, strings.ReplaceAll(adminLoginHTML, "__ERROR__", "Invalid token")) + } + + sess, _ := session.Get("atmesh", e) + sess.Values["admin"] = true + sess.Save(e.Request(), e.Response()) + + return e.Redirect(http.StatusSeeOther, "/web/") +} + +// handleAdminLogout clears the admin session. +func (s *Server) handleAdminLogout(e echo.Context) error { + sess, _ := session.Get("atmesh", e) + delete(sess.Values, "admin") + sess.Save(e.Request(), e.Response()) + return e.Redirect(http.StatusSeeOther, "/admin/login") +} + +// handleListWhitelist returns all whitelist entries as JSON. +func (s *Server) handleListWhitelist(e echo.Context) error { + var entries []db.WhitelistEntry + if err := s.db.DB.Order("created_at DESC").Find(&entries).Error; err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"error": "database error"}) + } + return e.JSON(http.StatusOK, entries) +} + +// handleAddWhitelist adds a new whitelist entry. +func (s *Server) handleAddWhitelist(e echo.Context) error { + var input struct { + DID string `json:"did"` + Handle string `json:"handle"` + MaxNodes int `json:"max_nodes"` + Notes string `json:"notes"` + } + if err := e.Bind(&input); err != nil { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if input.DID == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "did is required"}) + } + + entry := db.WhitelistEntry{ + DID: input.DID, + Handle: input.Handle, + MaxNodes: input.MaxNodes, + Notes: input.Notes, + } + if err := s.db.DB.Create(&entry).Error; err != nil { + return e.JSON(http.StatusConflict, map[string]string{"error": "DID already exists"}) + } + return e.JSON(http.StatusCreated, entry) +} + +// handleDeleteWhitelist removes a whitelist entry by ID. +func (s *Server) handleDeleteWhitelist(e echo.Context) error { + id := e.Param("id") + if id == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "id is required"}) + } + idUint, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"}) + } + if err := s.db.DB.Delete(&db.WhitelistEntry{}, idUint).Error; err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"error": "database error"}) + } + return e.JSON(http.StatusOK, map[string]string{"status": "deleted"}) +} + +const adminLoginHTML = ` + + + at-mesh Admin + + + + +
+

at-mesh Admin

+ __ERROR__ +
+ + +
+
+ +` diff --git a/server/proxy.go b/server/proxy.go new file mode 100644 index 0000000..4c8c8d8 --- /dev/null +++ b/server/proxy.go @@ -0,0 +1,37 @@ +package server + +import ( + "net/http" + "net/http/httputil" + "net/url" + + "github.com/labstack/echo/v4" +) + +// newHeadscaleProxy creates a reverse proxy to Headscale with API key injection. +func (s *Server) newHeadscaleProxy() echo.HandlerFunc { + target, err := url.Parse(s.config.HeadscaleUrl) + if err != nil { + s.logger.Error("invalid headscale URL for proxy", "url", s.config.HeadscaleUrl, "err", err) + return func(e echo.Context) error { + return e.JSON(http.StatusBadGateway, map[string]string{"error": "headscale URL not configured"}) + } + } + + proxy := httputil.NewSingleHostReverseProxy(target) + + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + req.Host = target.Host + req.Header.Set("Authorization", "Bearer "+s.config.HeadscaleKey) + req.Header.Set("Accept", "application/json") + } + + return func(e echo.Context) error { + // Strip the /api/v1 prefix since Headscale expects it + // (ReverseProxy already prepends the target path, so we keep it) + proxy.ServeHTTP(e.Response(), e.Request()) + return nil + } +} diff --git a/server/server.go b/server/server.go index 296bfdd..4351db4 100644 --- a/server/server.go +++ b/server/server.go @@ -3,7 +3,9 @@ package server import ( "context" "crypto/ecdsa" + "embed" "fmt" + "io/fs" "log/slog" "net/http" "os" @@ -24,6 +26,9 @@ import ( "gorm.io/gorm" ) +//go:embed all:web +var webFS embed.FS + type Args struct { Addr string Hostname string @@ -32,6 +37,7 @@ type Args struct { HeadscaleUrl string HeadscaleKey string AdminEmail string + AdminToken string SessionSecret string Version string LogLevel string @@ -66,6 +72,7 @@ type config struct { HeadscaleUrl string HeadscaleKey string AdminEmail string + AdminToken string Version string SessionSecret string Clients []OAuthClient @@ -133,6 +140,7 @@ func New(args *Args) (*Server, error) { HeadscaleUrl: args.HeadscaleUrl, HeadscaleKey: args.HeadscaleKey, AdminEmail: args.AdminEmail, + AdminToken: args.AdminToken, Version: args.Version, SessionSecret: args.SessionSecret, Clients: args.Clients, @@ -202,6 +210,26 @@ func (s *Server) setupEcho() { // Client metadata endpoint (for non-localhost OAuth clients) e.GET("/oauth/client-metadata.json", s.handleClientMetadata) + // Admin UI + e.GET("/admin/login", s.handleAdminLoginGet) + e.POST("/admin/login", s.handleAdminLoginPost) + e.POST("/admin/logout", s.handleAdminLogout) + + // Whitelist API (admin-only) + whitelistGroup := e.Group("/api/v1/whitelist", s.adminMiddleware) + whitelistGroup.GET("", s.handleListWhitelist) + whitelistGroup.POST("", s.handleAddWhitelist) + whitelistGroup.DELETE("/:id", s.handleDeleteWhitelist) + + // Headscale API proxy (admin-only, excludes /api/v1/whitelist) + if s.config.HeadscaleUrl != "" { + e.Any("/api/v1/*", s.newHeadscaleProxy(), s.adminMiddleware) + } + + // Static files (headscale-ui build) + staticFS, _ := fs.Sub(webFS, "web") + e.GET("/web/*", echo.StaticDirectoryHandler(staticFS, false)) + // Health e.GET("/health", s.handleHealth) diff --git a/server/web/.gitkeep b/server/web/.gitkeep new file mode 100644 index 0000000..e69de29