type: user

role: user

content:
Read the following files from the dragonrelay Forgejo repository (owner: zgaetano, repo: dragonrelay) and return their full contents. Use the forgejo_get_file tool for each:
1. backend/cmd/dragonrelay/main.go
2. backend/internal/api/api.go
3. backend/internal/api/admin.go
4. backend/internal/api/auth.go
5. backend/internal/auth/middleware.go
6. backend/internal/auth/jwt.go

Return ALL file contents in full, with clear file path headers.

userType: external

entrypoint: sdk-ts

gitBranch: HEAD


type: message

role: assistant

type: text

text: I'll retrieve all those files from the dragonrelay repository for you.

service_tier: standard

attributionAgent: Explore

type: assistant


type: direct


speed: standard


result:
File: backend/cmd/dragonrelay/main.go

// dragonrelay — Dragon Flight Relay Server backend.
//
// In Docker mode the React SPA is embedded at build time via go:embed.
// Set DRAGONRELAY_NO_EMBED=1 to skip the SPA (API-only / dev mode).
//
// Environment variables:
//   DRAGONRELAY_PORT          - HTTP listen port              (default: 8080)
//   DRAGONRELAY_HOST          - HTTP listen address           (default: 0.0.0.0)
//   DRAGONRELAY_NO_TS         - Set "1" to disable Tailscale (LAN-only)
//   DRAGONRELAY_DATA_DIR      - Data directory path           (default: /data)
//   DRAGONRELAY_ADMIN_PASSWORD - Initial admin password (only used on first run)
//   DRAGONRELAY_JWT_SECRET    - JWT signing secret (auto-generated if unset)
//   TS_AUTHKEY                - Tailscale auth key (optional; prompts login if unset)
//   TS_HOSTNAME               - Tailscale device hostname     (default: dragonrelay)
//   MOONLIGHT_PATH            - Override path to moonlight binary
package main

import (
	"context"
	"crypto/rand"
	"embed"
	"encoding/hex"
	"fmt"
	"io/fs"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"

	"dragonrelay/internal/api"
	"dragonrelay/internal/config"
	"dragonrelay/internal/db"
	"dragonrelay/internal/discovery"
	"dragonrelay/internal/moonlight"
	tsnode "dragonrelay/internal/tailscale"
)

// ui holds the React production build, copied here by the Dockerfile
// before `go build`. The path must match where the build output lands.
//
//go:embed ui
var uiFiles embed.FS

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	port := env("DRAGONRELAY_PORT", "8080")
	host := env("DRAGONRELAY_HOST", "0.0.0.0")
	noTS := os.Getenv("DRAGONRELAY_NO_TS") == "1"
	dataDir := env("DRAGONRELAY_DATA_DIR", "/data")

	// --- Config ---
	cfgPath := filepath.Join(dataDir, "config.json")
	cfg, err := config.Load(cfgPath)
	if err != nil && !os.IsNotExist(err) {
		log.Fatalf("loading config: %v", err)
	}
	if cfg == nil {
		cfg = &config.Config{}
	}

	// Ensure JWT secret exists (auto-generate if not provided)
	if os.Getenv("DRAGONRELAY_JWT_SECRET") == "" && cfg.JWTSecret == "" {
		secret, err := generateSecret()
		if err != nil {
			log.Fatalf("generating JWT secret: %v", err)
		}
		cfg.JWTSecret = secret
		_ = config.Save(cfgPath, cfg)
		log.Println("Generated new JWT secret and saved to config")
	}

	// --- SQLite user store ---
	dbPath := filepath.Join(dataDir, "dragonrelay.db")
	store, err := db.Open(dbPath)
	if err != nil {
		log.Fatalf("opening database: %v", err)
	}
	defer store.Close()

	// Seed default admin if no users exist yet
	if adminPw := os.Getenv("DRAGONRELAY_ADMIN_PASSWORD"); adminPw != "" {
		seeded, err := store.SeedAdminIfEmpty(adminPw)
		if err != nil {
			log.Printf("Warning: failed to seed admin: %v", err)
		} else if seeded {
			log.Println("Seeded default admin user")
		}
	}

	// --- Moonlight launcher ---
	launcher, err := moonlight.New(os.Getenv("MOONLIGHT_PATH"))
	if err != nil {
		log.Printf("Warning: %v", err)
		launcher, _ = moonlight.New("")
	}

	// --- mDNS discovery ---
	disc := discovery.New()
	go disc.Run(ctx)

	// --- Tailscale node (optional) ---
	var ts *tsnode.Node
	if !noTS {
		log.Println("Starting embedded Tailscale node…")
		ts, err = tsnode.New(ctx, tsnode.Config{
			Hostname: env("TS_HOSTNAME", "dragonrelay"),
			AuthKey:  os.Getenv("TS_AUTHKEY"),
		})
		if err != nil {
			log.Printf("Warning: Tailscale failed to start (%v) — running in LAN-only mode", err)
			ts = nil
		} else {
			if addr := ts.LocalAddr(); addr != "" {
				log.Printf("Tailscale connected: %s", addr)
			} else {
				log.Println("Tailscale: waiting for auth (check logs for login URL)")
			}
		}
	}

	// --- Embedded SPA ---
	// Strip the "ui/" prefix so the FS root contains index.html directly.
	uiRoot, err := fs.Sub(uiFiles, "ui")
	if err != nil {
		log.Fatalf("Failed to access embedded UI: %v", err)
	}

	// --- HTTP server ---
	srv := api.New(disc, launcher, ts, store, cfg, cfgPath, uiRoot)
	addr := fmt.Sprintf("%s:%s", host, port)
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatalf("Cannot listen on %s: %v", addr, err)
	}
	log.Printf("Dragon Flight Relay listening on http://%s", addr)

	httpSrv := &http.Server{Handler: srv.Handler()}
	go func() {
		if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
			log.Printf("HTTP server error: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("Shutting down…")
	_ = httpSrv.Close()
	if ts != nil {
		_ = ts.Close()
	}
}

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func generateSecret() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}


result:
File: backend/internal/api/api.go

// Package api exposes a REST API and serves the embedded React SPA.
package api

import (
	"io/fs"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"dragonrelay/internal/auth"
	"dragonrelay/internal/config"
	"dragonrelay/internal/db"
	"dragonrelay/internal/discovery"
	"dragonrelay/internal/moonlight"
	tsnode "dragonrelay/internal/tailscale"
)

// Server is the HTTP API + SPA server.
type Server struct {
	disc     *discovery.Discoverer
	launcher *moonlight.Launcher
	tsNode   *tsnode.Node
	router   *gin.Engine
	db       *db.Store
	cfg      *config.Config
	cfgPath  string
}

// New creates and configures the server.
func New(disc *discovery.Discoverer, launcher *moonlight.Launcher, ts *tsnode.Node, store *db.Store, cfg *config.Config, cfgPath string, staticFS fs.FS) *Server {
	gin.SetMode(gin.ReleaseMode)
	r := gin.Default()

	s := &Server{
		disc:     disc,
		launcher: launcher,
		tsNode:   ts,
		router:   r,
		db:       store,
		cfg:      cfg,
		cfgPath:  cfgPath,
	}

	// CORS for local dev
	r.Use(func(c *gin.Context) {
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
		c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}
		c.Next()
	})

	// Public routes
	r.POST("/api/auth/login", s.postLogin)
	r.POST("/api/auth/logout", s.postLogout)

	// Authenticated routes
	authed := r.Group("/api", auth.Middleware(s.jwtSecret))
	{
		authed.GET("/status", s.getStatus)
		authed.GET("/hosts", s.getHosts)
		authed.POST("/hosts/manual", auth.RequireRole("admin"), s.addManualHost)
		authed.DELETE("/hosts/:ip", auth.RequireRole("admin"), s.deleteHost)
		authed.POST("/connect", auth.RequireRole("admin", "user"), s.connect)
		authed.POST("/pair", auth.RequireRole("admin", "user"), s.pair)
		// Sessions readable by all authenticated users
		authed.GET("/admin/sessions", s.getAdminSessions)
	}

	// Admin routes
	admin := r.Group("/api/admin", auth.Middleware(s.jwtSecret), auth.RequireRole("admin"))
	{
		admin.GET("/config", s.getAdminConfig)
		admin.PUT("/config", s.putAdminConfig)
		admin.POST("/sessions/:id/disconnect", s.postAdminDisconnect)
		admin.GET("/users", s.getAdminUsers)
		admin.POST("/users", s.postAdminUsers)
		admin.PUT("/users/:id", s.putAdminUser)
		admin.DELETE("/users/:id", s.deleteAdminUser)
	}

	// Serve the embedded React SPA
	if staticFS != nil {
		r.NoRoute(func(c *gin.Context) {
			path := c.Request.URL.Path
			if path == "/" || path == "" {
				path = "/index.html"
			}
			fsPath := path[1:]
			f, err := staticFS.Open(fsPath)
			if err != nil {
				c.FileFromFS("index.html", http.FS(staticFS))
				return
			}
			f.Close()
			c.FileFromFS(fsPath, http.FS(staticFS))
		})
	}

	return s
}

// Handler returns the underlying http.Handler.
func (s *Server) Handler() http.Handler { return s.router }

// jwtSecret returns the JWT signing secret, preferring the env var.
func (s *Server) jwtSecret() string {
	if v := os.Getenv("DRAGONRELAY_JWT_SECRET"); v != "" {
		return v
	}
	return s.cfg.JWTSecret
}

// saveCfg persists the current config to disk.
func (s *Server) saveCfg() error {
	return config.Save(s.cfgPath, s.cfg)
}

func (s *Server) getStatus(c *gin.Context) {
	vpnAddr := ""
	vpnConnected := false
	if s.tsNode != nil {
		vpnAddr = s.tsNode.LocalAddr()
		vpnConnected = vpnAddr != ""
	}
	c.JSON(http.StatusOK, gin.H{
		"vpn_connected":  vpnConnected,
		"vpn_address":    vpnAddr,
		"moonlight_path": s.launcher.ExecPath(),
	})
}

func (s *Server) getHosts(c *gin.Context) {
	c.JSON(http.StatusOK, s.disc.Hosts())
}

func (s *Server) addManualHost(c *gin.Context) {
	var body struct {
		Name string `json:"name" binding:"required"`
		IP   string `json:"ip"   binding:"required"`
		Port int    `json:"port"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Port == 0 {
		body.Port = 47984
	}
	s.disc.AddManual(body.Name, body.IP, body.Port)
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (s *Server) deleteHost(c *gin.Context) {
	ip := c.Param("ip")
	s.disc.Remove(ip)
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (s *Server) connect(c *gin.Context) {
	var body struct {
		IP  string `json:"ip"  binding:"required"`
		App string `json:"app"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.App == "" {
		body.App = "Desktop"
	}
	if err := s.launcher.Stream(body.IP, body.App); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (s *Server) pair(c *gin.Context) {
	var body struct {
		IP string `json:"ip" binding:"required"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := s.launcher.Pair(body.IP); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}


result:
File: backend/internal/api/admin.go

package api

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
	"dragonrelay/internal/auth"
)

// getAdminConfig handles GET /api/admin/config
func (s *Server) getAdminConfig(c *gin.Context) {
	cfg := s.cfg
	masked := "••••••••"
	tsKey := ""
	if cfg.TSAuthKey != "" {
		tsKey = masked
	}
	c.JSON(http.StatusOK, gin.H{
		"ts_authkey":   tsKey,
		"ts_hostname":  cfg.TSHostname,
		"no_tailscale": cfg.NoTS,
	})
}

// putAdminConfig handles PUT /api/admin/config
func (s *Server) putAdminConfig(c *gin.Context) {
	var body struct {
		TSAuthKey  *string `json:"ts_authkey"`
		TSHostname *string `json:"ts_hostname"`
		NoTS       *bool   `json:"no_tailscale"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.TSAuthKey != nil {
		s.cfg.TSAuthKey = *body.TSAuthKey
	}
	if body.TSHostname != nil {
		s.cfg.TSHostname = *body.TSHostname
	}
	if body.NoTS != nil {
		s.cfg.NoTS = *body.NoTS
	}
	if err := s.saveCfg(); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true, "restart_required": true})
}

// getAdminSessions handles GET /api/admin/sessions
func (s *Server) getAdminSessions(c *gin.Context) {
	c.JSON(http.StatusOK, s.launcher.Sessions())
}

// postAdminDisconnect handles POST /api/admin/sessions/:id/disconnect
func (s *Server) postAdminDisconnect(c *gin.Context) {
	id := c.Param("id")
	if err := s.launcher.Disconnect(id); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// getAdminUsers handles GET /api/admin/users
func (s *Server) getAdminUsers(c *gin.Context) {
	users, err := s.db.ListUsers()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, users)
}

// postAdminUsers handles POST /api/admin/users
func (s *Server) postAdminUsers(c *gin.Context) {
	var body struct {
		Username string `json:"username" binding:"required"`
		Password string `json:"password" binding:"required"`
		Role     string `json:"role"     binding:"required"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Role != "admin" && body.Role != "user" && body.Role != "viewer" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "role must be admin, user, or viewer"})
		return
	}
	if err := s.db.CreateUser(body.Username, body.Password, body.Role); err != nil {
		c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, gin.H{"ok": true})
}

// putAdminUser handles PUT /api/admin/users/:id
func (s *Server) putAdminUser(c *gin.Context) {
	var id int64
	if _, err := fmt.Sscan(c.Param("id"), &id); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return
	}
	var body struct {
		Role     *string `json:"role"`
		Password *string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Role != nil {
		if err := s.db.UpdateRole(id, *body.Role); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	}
	if body.Password != nil {
		if err := s.db.ResetPassword(id, *body.Password); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// deleteAdminUser handles DELETE /api/admin/users/:id
func (s *Server) deleteAdminUser(c *gin.Context) {
	var id int64
	if _, err := fmt.Sscan(c.Param("id"), &id); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return
	}
	claims := auth.GetClaims(c)
	// Prevent admin from deleting themselves
	if claims != nil {
		self, _ := s.db.GetUserByUsername(claims.Username)
		if self != nil && self.ID == id {
			c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete your own account"})
			return
		}
	}
	if err := s.db.DeleteUser(id); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}


result:
File: backend/internal/api/auth.go

package api

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"dragonrelay/internal/auth"
)

// postLogin handles POST /api/auth/login
func (s *Server) postLogin(c *gin.Context) {
	var body struct {
		Username string `json:"username" binding:"required"`
		Password string `json:"password" binding:"required"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	user, err := s.db.GetUserByUsername(body.Username)
	if err != nil || !s.db.CheckPassword(user, body.Password) {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
		return
	}

	token, err := auth.SignToken(user.Username, user.Role, s.jwtSecret())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue token"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"token":    token,
		"username": user.Username,
		"role":     user.Role,
	})
}

// postLogout is a no-op server-side (JWT is stateless); client clears the token.
func (s *Server) postLogout(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{"ok": true})
}


result:
File: backend/internal/auth/middleware.go

package auth

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
)

const claimsKey = "claims"

// Middleware returns a Gin handler that validates the JWT in the
// Authorization: Bearer <token> header and stores Claims in the context.
// secretFn is called each request to allow hot-rotatable secrets.
func Middleware(secretFn func() string) gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		if !strings.HasPrefix(header, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
			return
		}
		token := strings.TrimPrefix(header, "Bearer ")
		claims, err := VerifyToken(token, secretFn())
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}
		c.Set(claimsKey, claims)
		c.Next()
	}
}

// RequireRole returns a Gin handler that aborts with 403 if the
// authenticated user's role is not in the allowed list.
// Must be used after Middleware.
func RequireRole(roles ...string) gin.HandlerFunc {
	allowed := make(map[string]bool, len(roles))
	for _, r := range roles {
		allowed[r] = true
	}
	return func(c *gin.Context) {
		claims := GetClaims(c)
		if claims == nil || !allowed[claims.Role] {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
			return
		}
		c.Next()
	}
}

// GetClaims extracts Claims from the Gin context. Returns nil if not set.
func GetClaims(c *gin.Context) *Claims {
	v, exists := c.Get(claimsKey)
	if !exists {
		return nil
	}
	claims, _ := v.(*Claims)
	return claims
}


result:
File: backend/internal/auth/jwt.go

// Package auth provides JWT signing/verification and Gin middleware.
package auth

import (
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

const tokenTTL = 24 * time.Hour

// Claims are the custom JWT claims used by Dragon Flight Relay.
type Claims struct {
	Username string `json:"username"`
	Role     string `json:"role"`
	jwt.RegisteredClaims
}

// SignToken creates a signed HS256 JWT for the given user.
func SignToken(username, role, secret string) (string, error) {
	claims := Claims{
		Username: username,
		Role:     role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   username,
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenTTL)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return t.SignedString([]byte(secret))
}

// VerifyToken parses and validates a JWT, returning the claims on success.
func VerifyToken(tokenStr, secret string) (*Claims, error) {
	t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
		}
		return []byte(secret), nil
	})
	if err != nil {
		return nil, err
	}
	claims, ok := t.Claims.(*Claims)
	if !ok || !t.Valid {
		return nil, fmt.Errorf("invalid token claims")
	}
	return claims, nil
}


text:
## File Contents

### backend/cmd/dragonrelay/main.go

```go
// dragonrelay — Dragon Flight Relay Server backend.
//
// In Docker mode the React SPA is embedded at build time via go:embed.
// Set DRAGONRELAY_NO_EMBED=1 to skip the SPA (API-only / dev mode).
//
// Environment variables:
//   DRAGONRELAY_PORT          - HTTP listen port              (default: 8080)
//   DRAGONRELAY_HOST          - HTTP listen address           (default: 0.0.0.0)
//   DRAGONRELAY_NO_TS         - Set "1" to disable Tailscale (LAN-only)
//   DRAGONRELAY_DATA_DIR      - Data directory path           (default: /data)
//   DRAGONRELAY_ADMIN_PASSWORD - Initial admin password (only used on first run)
//   DRAGONRELAY_JWT_SECRET    - JWT signing secret (auto-generated if unset)
//   TS_AUTHKEY                - Tailscale auth key (optional; prompts login if unset)
//   TS_HOSTNAME               - Tailscale device hostname     (default: dragonrelay)
//   MOONLIGHT_PATH            - Override path to moonlight binary
package main

import (
	"context"
	"crypto/rand"
	"embed"
	"encoding/hex"
	"fmt"
	"io/fs"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"

	"dragonrelay/internal/api"
	"dragonrelay/internal/config"
	"dragonrelay/internal/db"
	"dragonrelay/internal/discovery"
	"dragonrelay/internal/moonlight"
	tsnode "dragonrelay/internal/tailscale"
)

// ui holds the React production build, copied here by the Dockerfile
// before `go build`. The path must match where the build output lands.
//
//go:embed ui
var uiFiles embed.FS

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	port := env("DRAGONRELAY_PORT", "8080")
	host := env("DRAGONRELAY_HOST", "0.0.0.0")
	noTS := os.Getenv("DRAGONRELAY_NO_TS") == "1"
	dataDir := env("DRAGONRELAY_DATA_DIR", "/data")

	// --- Config ---
	cfgPath := filepath.Join(dataDir, "config.json")
	cfg, err := config.Load(cfgPath)
	if err != nil && !os.IsNotExist(err) {
		log.Fatalf("loading config: %v", err)
	}
	if cfg == nil {
		cfg = &config.Config{}
	}

	// Ensure JWT secret exists (auto-generate if not provided)
	if os.Getenv("DRAGONRELAY_JWT_SECRET") == "" && cfg.JWTSecret == "" {
		secret, err := generateSecret()
		if err != nil {
			log.Fatalf("generating JWT secret: %v", err)
		}
		cfg.JWTSecret = secret
		_ = config.Save(cfgPath, cfg)
		log.Println("Generated new JWT secret and saved to config")
	}

	// --- SQLite user store ---
	dbPath := filepath.Join(dataDir, "dragonrelay.db")
	store, err := db.Open(dbPath)
	if err != nil {
		log.Fatalf("opening database: %v", err)
	}
	defer store.Close()

	// Seed default admin if no users exist yet
	if adminPw := os.Getenv("DRAGONRELAY_ADMIN_PASSWORD"); adminPw != "" {
		seeded, err := store.SeedAdminIfEmpty(adminPw)
		if err != nil {
			log.Printf("Warning: failed to seed admin: %v", err)
		} else if seeded {
			log.Println("Seeded default admin user")
		}
	}

	// --- Moonlight launcher ---
	launcher, err := moonlight.New(os.Getenv("MOONLIGHT_PATH"))
	if err != nil {
		log.Printf("Warning: %v", err)
		launcher, _ = moonlight.New("")
	}

	// --- mDNS discovery ---
	disc := discovery.New()
	go disc.Run(ctx)

	// --- Tailscale node (optional) ---
	var ts *tsnode.Node
	if !noTS {
		log.Println("Starting embedded Tailscale node…")
		ts, err = tsnode.New(ctx, tsnode.Config{
			Hostname: env("TS_HOSTNAME", "dragonrelay"),
			AuthKey:  os.Getenv("TS_AUTHKEY"),
		})
		if err != nil {
			log.Printf("Warning: Tailscale failed to start (%v) — running in LAN-only mode", err)
			ts = nil
		} else {
			if addr := ts.LocalAddr(); addr != "" {
				log.Printf("Tailscale connected: %s", addr)
			} else {
				log.Println("Tailscale: waiting for auth (check logs for login URL)")
			}
		}
	}

	// --- Embedded SPA ---
	// Strip the "ui/" prefix so the FS root contains index.html directly.
	uiRoot, err := fs.Sub(uiFiles, "ui")
	if err != nil {
		log.Fatalf("Failed to access embedded UI: %v", err)
	}

	// --- HTTP server ---
	srv := api.New(disc, launcher, ts, store, cfg, cfgPath, uiRoot)
	addr := fmt.Sprintf("%s:%s", host, port)
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatalf("Cannot listen on %s: %v", addr, err)
	}
	log.Printf("Dragon Flight Relay listening on http://%s", addr)

	httpSrv := &http.Server{Handler: srv.Handler()}
	go func() {
		if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
			log.Printf("HTTP server error: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("Shutting down…")
	_ = httpSrv.Close()
	if ts != nil {
		_ = ts.Close()
	}
}

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func generateSecret() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}
```

### backend/internal/api/api.go

```go
// Package api exposes a REST API and serves the embedded React SPA.
package api

import (
	"io/fs"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"dragonrelay/internal/auth"
	"dragonrelay/internal/config"
	"dragonrelay/internal/db"
	"dragonrelay/internal/discovery"
	"dragonrelay/internal/moonlight"
	tsnode "dragonrelay/internal/tailscale"
)

// Server is the HTTP API + SPA server.
type Server struct {
	disc     *discovery.Discoverer
	launcher *moonlight.Launcher
	tsNode   *tsnode.Node
	router   *gin.Engine
	db       *db.Store
	cfg      *config.Config
	cfgPath  string
}

// New creates and configures the server.
func New(disc *discovery.Discoverer, launcher *moonlight.Launcher, ts *tsnode.Node, store *db.Store, cfg *config.Config, cfgPath string, staticFS fs.FS) *Server {
	gin.SetMode(gin.ReleaseMode)
	r := gin.Default()

	s := &Server{
		disc:     disc,
		launcher: launcher,
		tsNode:   ts,
		router:   r,
		db:       store,
		cfg:      cfg,
		cfgPath:  cfgPath,
	}

	// CORS for local dev
	r.Use(func(c *gin.Context) {
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
		c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
		if c.Request.Method == "OPTIONS" {
			c.AbortWithStatus(204)
			return
		}
		c.Next()
	})

	// Public routes
	r.POST("/api/auth/login", s.postLogin)
	r.POST("/api/auth/logout", s.postLogout)

	// Authenticated routes
	authed := r.Group("/api", auth.Middleware(s.jwtSecret))
	{
		authed.GET("/status", s.getStatus)
		authed.GET("/hosts", s.getHosts)
		authed.POST("/hosts/manual", auth.RequireRole("admin"), s.addManualHost)
		authed.DELETE("/hosts/:ip", auth.RequireRole("admin"), s.deleteHost)
		authed.POST("/connect", auth.RequireRole("admin", "user"), s.connect)
		authed.POST("/pair", auth.RequireRole("admin", "user"), s.pair)
		// Sessions readable by all authenticated users
		authed.GET("/admin/sessions", s.getAdminSessions)
	}

	// Admin routes
	admin := r.Group("/api/admin", auth.Middleware(s.jwtSecret), auth.RequireRole("admin"))
	{
		admin.GET("/config", s.getAdminConfig)
		admin.PUT("/config", s.putAdminConfig)
		admin.POST("/sessions/:id/disconnect", s.postAdminDisconnect)
		admin.GET("/users", s.getAdminUsers)
		admin.POST("/users", s.postAdminUsers)
		admin.PUT("/users/:id", s.putAdminUser)
		admin.DELETE("/users/:id", s.deleteAdminUser)
	}

	// Serve the embedded React SPA
	if staticFS != nil {
		r.NoRoute(func(c *gin.Context) {
			path := c.Request.URL.Path
			if path == "/" || path == "" {
				path = "/index.html"
			}
			fsPath := path[1:]
			f, err := staticFS.Open(fsPath)
			if err != nil {
				c.FileFromFS("index.html", http.FS(staticFS))
				return
			}
			f.Close()
			c.FileFromFS(fsPath, http.FS(staticFS))
		})
	}

	return s
}

// Handler returns the underlying http.Handler.
func (s *Server) Handler() http.Handler { return s.router }

// jwtSecret returns the JWT signing secret, preferring the env var.
func (s *Server) jwtSecret() string {
	if v := os.Getenv("DRAGONRELAY_JWT_SECRET"); v != "" {
		return v
	}
	return s.cfg.JWTSecret
}

// saveCfg persists the current config to disk.
func (s *Server) saveCfg() error {
	return config.Save(s.cfgPath, s.cfg)
}

func (s *Server) getStatus(c *gin.Context) {
	vpnAddr := ""
	vpnConnected := false
	if s.tsNode != nil {
		vpnAddr = s.tsNode.LocalAddr()
		vpnConnected = vpnAddr != ""
	}
	c.JSON(http.StatusOK, gin.H{
		"vpn_connected":  vpnConnected,
		"vpn_address":    vpnAddr,
		"moonlight_path": s.launcher.ExecPath(),
	})
}

func (s *Server) getHosts(c *gin.Context) {
	c.JSON(http.StatusOK, s.disc.Hosts())
}

func (s *Server) addManualHost(c *gin.Context) {
	var body struct {
		Name string `json:"name" binding:"required"`
		IP   string `json:"ip"   binding:"required"`
		Port int    `json:"port"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Port == 0 {
		body.Port = 47984
	}
	s.disc.AddManual(body.Name, body.IP, body.Port)
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (s *Server) deleteHost(c *gin.Context) {
	ip := c.Param("ip")
	s.disc.Remove(ip)
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (s *Server) connect(c *gin.Context) {
	var body struct {
		IP  string `json:"ip"  binding:"required"`
		App string `json:"app"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.App == "" {
		body.App = "Desktop"
	}
	if err := s.launcher.Stream(body.IP, body.App); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (s *Server) pair(c *gin.Context) {
	var body struct {
		IP string `json:"ip" binding:"required"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := s.launcher.Pair(body.IP); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}
```

### backend/internal/api/admin.go

```go
package api

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
	"dragonrelay/internal/auth"
)

// getAdminConfig handles GET /api/admin/config
func (s *Server) getAdminConfig(c *gin.Context) {
	cfg := s.cfg
	masked := "••••••••"
	tsKey := ""
	if cfg.TSAuthKey != "" {
		tsKey = masked
	}
	c.JSON(http.StatusOK, gin.H{
		"ts_authkey":   tsKey,
		"ts_hostname":  cfg.TSHostname,
		"no_tailscale": cfg.NoTS,
	})
}

// putAdminConfig handles PUT /api/admin/config
func (s *Server) putAdminConfig(c *gin.Context) {
	var body struct {
		TSAuthKey  *string `json:"ts_authkey"`
		TSHostname *string `json:"ts_hostname"`
		NoTS       *bool   `json:"no_tailscale"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.TSAuthKey != nil {
		s.cfg.TSAuthKey = *body.TSAuthKey
	}
	if body.TSHostname != nil {
		s.cfg.TSHostname = *body.TSHostname
	}
	if body.NoTS != nil {
		s.cfg.NoTS = *body.NoTS
	}
	if err := s.saveCfg(); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true, "restart_required": true})
}

// getAdminSessions handles GET /api/admin/sessions
func (s *Server) getAdminSessions(c *gin.Context) {
	c.JSON(http.StatusOK, s.launcher.Sessions())
}

// postAdminDisconnect handles POST /api/admin/sessions/:id/disconnect
func (s *Server) postAdminDisconnect(c *gin.Context) {
	id := c.Param("id")
	if err := s.launcher.Disconnect(id); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// getAdminUsers handles GET /api/admin/users
func (s *Server) getAdminUsers(c *gin.Context) {
	users, err := s.db.ListUsers()
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, users)
}

// postAdminUsers handles POST /api/admin/users
func (s *Server) postAdminUsers(c *gin.Context) {
	var body struct {
		Username string `json:"username" binding:"required"`
		Password string `json:"password" binding:"required"`
		Role     string `json:"role"     binding:"required"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Role != "admin" && body.Role != "user" && body.Role != "viewer" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "role must be admin, user, or viewer"})
		return
	}
	if err := s.db.CreateUser(body.Username, body.Password, body.Role); err != nil {
		c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, gin.H{"ok": true})
}

// putAdminUser handles PUT /api/admin/users/:id
func (s *Server) putAdminUser(c *gin.Context) {
	var id int64
	if _, err := fmt.Sscan(c.Param("id"), &id); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return
	}
	var body struct {
		Role     *string `json:"role"`
		Password *string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Role != nil {
		if err := s.db.UpdateRole(id, *body.Role); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	}
	if body.Password != nil {
		if err := s.db.ResetPassword(id, *body.Password); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// deleteAdminUser handles DELETE /api/admin/users/:id
func (s *Server) deleteAdminUser(c *gin.Context) {
	var id int64
	if _, err := fmt.Sscan(c.Param("id"), &id); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return
	}
	claims := auth.GetClaims(c)
	// Prevent admin from deleting themselves
	if claims != nil {
		self, _ := s.db.GetUserByUsername(claims.Username)
		if self != nil && self.ID == id {
			c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete your own account"})
			return
		}
	}
	if err := s.db.DeleteUser(id); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}
```

### backend/internal/api/auth.go

```go
package api

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"dragonrelay/internal/auth"
)

// postLogin handles POST /api/auth/login
func (s *Server) postLogin(c *gin.Context) {
	var body struct {
		Username string `json:"username" binding:"required"`
		Password string `json:"password" binding:"required"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	user, err := s.db.GetUserByUsername(body.Username)
	if err != nil || !s.db.CheckPassword(user, body.Password) {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
		return
	}

	token, err := auth.SignToken(user.Username, user.Role, s.jwtSecret())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue token"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"token":    token,
		"username": user.Username,
		"role":     user.Role,
	})
}

// postLogout is a no-op server-side (JWT is stateless); client clears the token.
func (s *Server) postLogout(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{"ok": true})
}
```

### backend/internal/auth/middleware.go

```go
package auth

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
)

const claimsKey = "claims"

// Middleware returns a Gin handler that validates the JWT in the
// Authorization: Bearer <token> header and stores Claims in the context.
// secretFn is called each request to allow hot-rotatable secrets.
func Middleware(secretFn func() string) gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		if !strings.HasPrefix(header, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
			return
		}
		token := strings.TrimPrefix(header, "Bearer ")
		claims, err := VerifyToken(token, secretFn())
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}
		c.Set(claimsKey, claims)
		c.Next()
	}
}

// RequireRole returns a Gin handler that aborts with 403 if the
// authenticated user's role is not in the allowed list.
// Must be used after Middleware.
func RequireRole(roles ...string) gin.HandlerFunc {
	allowed := make(map[string]bool, len(roles))
	for _, r := range roles {
		allowed[r] = true
	}
	return func(c *gin.Context) {
		claims := GetClaims(c)
		if claims == nil || !allowed[claims.Role] {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
			return
		}
		c.Next()
	}
}

// GetClaims extracts Claims from the Gin context. Returns nil if not set.
func GetClaims(c *gin.Context) *Claims {
	v, exists := c.Get(claimsKey)
	if !exists {
		return nil
	}
	claims, _ := v.(*Claims)
	return claims
}
```

### backend/internal/auth/jwt.go

```go
// Package auth provides JWT signing/verification and Gin middleware.
package auth

import (
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

const tokenTTL = 24 * time.Hour

// Claims are the custom JWT claims used by Dragon Flight Relay.
type Claims struct {
	Username string `json:"username"`
	Role     string `json:"role"`
	jwt.RegisteredClaims
}

// SignToken creates a signed HS256 JWT for the given user.
func SignToken(username, role, secret string) (string, error) {
	claims := Claims{
		Username: username,
		Role:     role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   username,
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenTTL)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return t.SignedString([]byte(secret))
}

// VerifyToken parses and validates a JWT, returning the claims on success.
func VerifyToken(tokenStr, secret string) (*Claims, error) {
	t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
		}
		return []byte(secret), nil
	})
	if err != nil {
		return nil, err
	}
	claims, ok := t.Claims.(*Claims)
	if !ok || !t.Valid {
		return nil, fmt.Errorf("invalid token claims")
	}
	return claims, nil
}
```

All files have been successfully retrieved from the dragonrelay repository. These files show the core backend structure with:

- **main.go**: Server initialization with config loading, JWT secret management, database setup, and Tailscale/mDNS discovery
- **api.go**: HTTP server setup with Gin router, route definitions, and SPA serving
- **admin.go**: Admin endpoints for configuration, user management, and session control
- **auth.go**: Login/logout endpoints
- **middleware.go**: JWT validation middleware and role-based access control
- **jwt.go**: JWT signing and verification using HS256