Read the following files from the dragonrelay Forgejo repository (owner: zgaetano, repo: dragonrelay) using the forgejo_get_file tool:
1. backend/internal/db/db.go
2. backend/internal/discovery/discovery.go (if it exists - try listing backend/internal/discovery first)
3. backend/internal/moonlight/moonlight.go (if it exists - try listing backend/internal/moonlight first)
4. backend/internal/tailscale/tailscale.go (if it exists - try listing backend/internal/tailscale first)
5. backend/go.mod

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


I'll help you read those files from the dragonrelay repository. Let me start by listing the directories to see what exists, then read the files.


📁 api
📁 auth
📁 config
📁 db
📁 discovery
📁 moonlight
📁 tailscale


File: backend/internal/db/db.go

// Package db manages the SQLite user store for Dragon Flight Relay.
package db

import (
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	_ "github.com/mattn/go-sqlite3"
	"golang.org/x/crypto/bcrypt"
)

// User represents a row in the users table.
type User struct {
	ID        int64
	Username  string
	Password  string // bcrypt hash
	Role      string // "admin", "member", or "viewer"
	CreatedAt time.Time
}

// Store wraps a SQLite database connection.
type Store struct {
	db *sql.DB
}

// Open opens (or creates) the SQLite database at path.
// Pass ":memory:" for tests.
func Open(path string) (*Store, error) {
	d, err := sql.Open("sqlite3", path)
	if err != nil {
		return nil, fmt.Errorf("open db: %w", err)
	}
	// SQLite disables foreign-key enforcement by default. Turn it on so the
	// FK clauses in our schema (ON DELETE CASCADE, ON DELETE SET NULL) actually
	// fire. We enable it via a query string PRAGMA at connection-open time.
	// Since *sql.DB pools connections internally and PRAGMA foreign_keys is
	// per-connection, we also set MaxOpenConns(1) to enforce a single connection,
	// ensuring all operations use an FK-enforcing connection. This is safe for
	// this homelab project (very low concurrency, single goroutine handling
	// one HTTP request at a time).
	if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil {
		d.Close()
		return nil, fmt.Errorf("enable foreign_keys: %w", err)
	}
	d.SetMaxOpenConns(1)
	s := &Store{db: d}
	if err := s.migrate(); err != nil {
		d.Close()
		return nil, err
	}
	return s, nil
}

// Close closes the underlying database.
func (s *Store) Close() error {
	return s.db.Close()
}

func (s *Store) migrate() error {
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Read current schema version
	var version int
	if err := tx.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
		return fmt.Errorf("read user_version: %w", err)
	}

	// v0 → v1: original users table.
	if version < 1 {
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS users (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				username   TEXT UNIQUE NOT NULL,
				password   TEXT NOT NULL,
				role       TEXT NOT NULL CHECK(role IN ('admin','user','viewer')),
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP
			)
		`); err != nil {
			return fmt.Errorf("create users: %w", err)
		}
		if _, err := tx.Exec(`PRAGMA user_version = 1`); err != nil {
			return err
		}
		version = 1
	}

	// v1 → v2: per-host ACL, invites, role rename.
	if version < 2 {
		// Rebuild users with the new role enum (superset for rollback safety).
		if _, err := tx.Exec(`
			CREATE TABLE users_new (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				username   TEXT UNIQUE NOT NULL,
				password   TEXT NOT NULL,
				role       TEXT NOT NULL CHECK(role IN ('admin','user','member','viewer')),
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP
			)
		`); err != nil {
			return fmt.Errorf("create users_new: %w", err)
		}
		if _, err := tx.Exec(`
			INSERT INTO users_new (id, username, password, role, created_at)
			SELECT id, username, password,
			       CASE WHEN role='user' THEN 'member' ELSE role END,
			       created_at FROM users
		`); err != nil {
			return fmt.Errorf("copy users: %w", err)
		}
		if _, err := tx.Exec(`DROP TABLE users`); err != nil {
			return fmt.Errorf("drop users: %w", err)
		}
		if _, err := tx.Exec(`ALTER TABLE users_new RENAME TO users`); err != nil {
			return fmt.Errorf("rename users: %w", err)
		}

		// New tables.
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS host_acl (
				host_ip    TEXT    NOT NULL,
				user_id    INTEGER NOT NULL,
				level      TEXT    NOT NULL CHECK(level IN ('view','connect','manage')),
				granted_by INTEGER,
				granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
				PRIMARY KEY (host_ip, user_id),
				FOREIGN KEY (user_id)    REFERENCES users(id) ON DELETE CASCADE,
				FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
			)
		`); err != nil {
			return fmt.Errorf("create host_acl: %w", err)
		}
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS invites (
				id              INTEGER PRIMARY KEY AUTOINCREMENT,
				token_hash      TEXT    NOT NULL UNIQUE,
				label           TEXT    NOT NULL DEFAULT '',
				role            TEXT    NOT NULL CHECK(role IN ('admin','member','viewer')),
				host_access     TEXT    NOT NULL DEFAULT '[]',
				created_by      INTEGER NOT NULL,
				created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
				expires_at      DATETIME NOT NULL,
				used_at         DATETIME,
				used_by_user_id INTEGER,
				FOREIGN KEY (created_by)      REFERENCES users(id),
				FOREIGN KEY (used_by_user_id) REFERENCES users(id)
			)
		`); err != nil {
			return fmt.Errorf("create invites: %w", err)
		}

		if _, err := tx.Exec(`PRAGMA user_version = 2`); err != nil {
			return err
		}
	}

	return tx.Commit()
}

// CreateUser creates a new user with a bcrypt-hashed password.
func (s *Store) CreateUser(username, plainPassword, role string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	_, err = s.db.Exec(
		`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`,
		username, string(hash), role,
	)
	return err
}

// GetUserByUsername returns a user by username, or sql.ErrNoRows if not found.
func (s *Store) GetUserByUsername(username string) (*User, error) {
	u := &User{}
	err := s.db.QueryRow(
		`SELECT id, username, password, role, created_at FROM users WHERE username = ?`,
		username,
	).Scan(&u.ID, &u.Username, &u.Password, &u.Role, &u.CreatedAt)
	if err != nil {
		return nil, err
	}
	return u, nil
}

// GetUserByID returns a user by ID, or sql.ErrNoRows if not found.
func (s *Store) GetUserByID(id int64) (*User, error) {
	u := &User{}
	err := s.db.QueryRow(
		`SELECT id, username, password, role, created_at FROM users WHERE id = ?`,
		id,
	).Scan(&u.ID, &u.Username, &u.Password, &u.Role, &u.CreatedAt)
	if err != nil {
		return nil, err
	}
	return u, nil
}

// ListUsers returns all users. Password field is empty (not returned).
func (s *Store) ListUsers() ([]*User, error) {
	rows, err := s.db.Query(`SELECT id, username, role, created_at FROM users ORDER BY id`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var users []*User
	for rows.Next() {
		u := &User{}
		if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	return users, rows.Err()
}

// CheckPassword returns true if plainPassword matches the stored bcrypt hash.
func (s *Store) CheckPassword(u *User, plainPassword string) bool {
	return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plainPassword)) == nil
}

// UpdateRole changes a user's role by ID.
func (s *Store) UpdateRole(id int64, role string) error {
	_, err := s.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id)
	return err
}

// ResetPassword sets a new bcrypt-hashed password for a user by ID.
func (s *Store) ResetPassword(id int64, plainPassword string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	_, err = s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, string(hash), id)
	return err
}

// DeleteUser removes a user by ID.
func (s *Store) DeleteUser(id int64) error {
	_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
	return err
}

// SeedAdminIfEmpty creates an admin user with the given password if the
// users table is empty. Returns true if a user was created.
func (s *Store) SeedAdminIfEmpty(adminPassword string) (bool, error) {
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return false, err
	}
	if count > 0 {
		return false, nil
	}
	if err := s.CreateUser("admin", adminPassword, "admin"); err != nil {
		return false, err
	}
	return true, nil
}

// IsEmpty returns true if no users exist yet (used by setup wizard).
func (s *Store) IsEmpty() (bool, error) {
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return false, err
	}
	return count == 0, nil
}

// ─── Host ACL ──────────────────────────────────────────────────────────────

// HostGrant pairs a host IP with the level a user has on it.
type HostGrant struct {
	HostIP string `json:"host_ip"`
	Level  string `json:"level"` // "view" | "connect" | "manage"
}

// HostACLEntry is one row of "who can do what on this host".
type HostACLEntry struct {
	UserID   int64  `json:"user_id"`
	Username string `json:"username"`
	Level    string `json:"level"`
}

// GrantHostAccess inserts or upgrades a user's grant for a host.
// Pass grantedBy = 0 for system-issued grants (migration backfill).
func (s *Store) GrantHostAccess(userID int64, hostIP, level string, grantedBy int64) error {
	if level != "view" && level != "connect" && level != "manage" {
		return fmt.Errorf("invalid level: %s", level)
	}
	var grantedByVal interface{}
	if grantedBy == 0 {
		grantedByVal = nil
	} else {
		grantedByVal = grantedBy
	}
	_, err := s.db.Exec(`
		INSERT INTO host_acl (host_ip, user_id, level, granted_by)
		VALUES (?, ?, ?, ?)
		ON CONFLICT(host_ip, user_id)
		DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by, granted_at = CURRENT_TIMESTAMP
	`, hostIP, userID, level, grantedByVal)
	return err
}

// RevokeHostAccess removes a user's grant for a host.
func (s *Store) RevokeHostAccess(userID int64, hostIP string) error {
	_, err := s.db.Exec(`DELETE FROM host_acl WHERE host_ip = ? AND user_id = ?`, hostIP, userID)
	return err
}

// GetHostAccess returns the level a user has on a host, or ok=false if none.
func (s *Store) GetHostAccess(userID int64, hostIP string) (level string, ok bool, err error) {
	err = s.db.QueryRow(
		`SELECT level FROM host_acl WHERE host_ip = ? AND user_id = ?`,
		hostIP, userID,
	).Scan(&level)
	if err == sql.ErrNoRows {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return level, true, nil
}

// ListUserHostAccess returns every host this user has been granted access to.
func (s *Store) ListUserHostAccess(userID int64) ([]HostGrant, error) {
	rows, err := s.db.Query(
		`SELECT host_ip, level FROM host_acl WHERE user_id = ? ORDER BY host_ip`,
		userID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var grants []HostGrant
	for rows.Next() {
		var g HostGrant
		if err := rows.Scan(&g.HostIP, &g.Level); err != nil {
			return nil, err
		}
		grants = append(grants, g)
	}
	return grants, rows.Err()
}

// ListHostACL returns every user grant for a given host.
func (s *Store) ListHostACL(hostIP string) ([]HostACLEntry, error) {
	rows, err := s.db.Query(`
		SELECT u.id, u.username, h.level
		FROM host_acl h JOIN users u ON u.id = h.user_id
		WHERE h.host_ip = ? ORDER BY u.username
	`, hostIP)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var entries []HostACLEntry
	for rows.Next() {
		var e HostACLEntry
		if err := rows.Scan(&e.UserID, &e.Username, &e.Level); err != nil {
			return nil, err
		}
		entries = append(entries, e)
	}
	return entries, rows.Err()
}

// ─── Invites ───────────────────────────────────────────────────────────────

// hashToken turns a raw invite token into the value we store.
func hashToken(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

// InviteRecord is the read-side view of an invites row.
type InviteRecord struct {
	ID        int64
	Label     string
	Role      string
	HostAccess []HostGrant
	CreatedBy  int64
	CreatedAt  time.Time
	ExpiresAt  time.Time
	UsedAt     *time.Time
	UsedByID   *int64
}

// ErrInviteInvalid is returned when an invite is missing, expired, or used.
var ErrInviteInvalid = errors.New("invite invalid")

// CreateInvite stores the hashed token along with metadata. Returns the new id.
func (s *Store) CreateInvite(rawToken, label, role string, hostAccess []HostGrant, createdBy int64, expires time.Time) (int64, error) {
	if role != "admin" && role != "member" && role != "viewer" {
		return 0, fmt.Errorf("invalid role: %s", role)
	}
	if hostAccess == nil {
		hostAccess = []HostGrant{}
	}
	hostAccessJSON, err := json.Marshal(hostAccess)
	if err != nil {
		return 0, err
	}
	res, err := s.db.Exec(`
		INSERT INTO invites (token_hash, label, role, host_access, created_by, expires_at)
		VALUES (?, ?, ?, ?, ?, ?)
	`, hashToken(rawToken), label, role, string(hostAccessJSON), createdBy, expires)
	if err != nil {
		return 0, err
	}
	return res.LastInsertId()
}

// GetInviteByToken returns the invite for a raw token. Does NOT check expiry/used.
func (s *Store) GetInviteByToken(rawToken string) (*InviteRecord, error) {
	r := &InviteRecord{}
	var hostAccessJSON string
	var usedAt sql.NullTime
	var usedBy sql.NullInt64
	err := s.db.QueryRow(`
		SELECT id, label, role, host_access, created_by, created_at, expires_at, used_at, used_by_user_id
		FROM invites WHERE token_hash = ?
	`, hashToken(rawToken)).Scan(
		&r.ID, &r.Label, &r.Role, &hostAccessJSON,
		&r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &usedAt, &usedBy,
	)
	if err != nil {
		return nil, err
	}
	if usedAt.Valid {
		r.UsedAt = &usedAt.Time
	}
	if usedBy.Valid {
		v := usedBy.Int64
		r.UsedByID = &v
	}
	if err := json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess); err != nil {
		return nil, err
	}
	return r, nil
}

// ListActiveInvites returns invites that are unused and not expired.
func (s *Store) ListActiveInvites() ([]InviteRecord, error) {
	rows, err := s.db.Query(`
		SELECT id, label, role, host_access, created_by, created_at, expires_at
		FROM invites
		WHERE used_at IS NULL AND expires_at > CURRENT_TIMESTAMP
		ORDER BY created_at DESC
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []InviteRecord
	for rows.Next() {
		var r InviteRecord
		var hostAccessJSON string
		if err := rows.Scan(&r.ID, &r.Label, &r.Role, &hostAccessJSON, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt); err != nil {
			return nil, err
		}
		_ = json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess)
		out = append(out, r)
	}
	return out, rows.Err()
}

// RevokeInvite deletes an unused invite. Returns error if not found.
func (s *Store) RevokeInvite(id int64) error {
	res, err := s.db.Exec(`DELETE FROM invites WHERE id = ? AND used_at IS NULL`, id)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return errors.New("invite not found or already used")
	}
	return nil
}

// RedeemInvite atomically: validates, creates the user, inserts host_acl rows,
// marks the invite used. Returns the new user id.
func (s *Store) RedeemInvite(rawToken, username, plainPassword string) (int64, error) {
	hash := hashToken(rawToken)
	tx, err := s.db.Begin()
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()

	var (
		id             int64
		role           string
		hostAccessJSON string
		expiresAt      time.Time
		usedAt         sql.NullTime
	)
	err = tx.QueryRow(`
		SELECT id, role, host_access, expires_at, used_at
		FROM invites WHERE token_hash = ?
	`, hash).Scan(&id, &role, &hostAccessJSON, &expiresAt, &usedAt)
	if err == sql.ErrNoRows {
		return 0, ErrInviteInvalid
	}
	if err != nil {
		return 0, err
	}
	if usedAt.Valid || time.Now().After(expiresAt) {
		return 0, ErrInviteInvalid
	}

	// Hash password and insert user.
	pwHash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return 0, err
	}
	res, err := tx.Exec(
		`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`,
		username, string(pwHash), role,
	)
	if err != nil {
		return 0, err
	}
	userID, err := res.LastInsertId()
	if err != nil {
		return 0, err
	}

	// Insert host_acl rows.
	var grants []HostGrant
	if err := json.Unmarshal([]byte(hostAccessJSON), &grants); err != nil {
		return 0, err
	}
	for _, g := range grants {
		// Cap viewer-role users at 'view' regardless of grant.
		level := g.Level
		if role == "viewer" && level != "view" {
			level = "view"
		}
		if _, err := tx.Exec(`
			INSERT INTO host_acl (host_ip, user_id, level)
			VALUES (?, ?, ?)
		`, g.HostIP, userID, level); err != nil {
			return 0, err
		}
	}

	// Mark invite used.
	if _, err := tx.Exec(`
		UPDATE invites SET used_at = CURRENT_TIMESTAMP, used_by_user_id = ? WHERE id = ?
	`, userID, id); err != nil {
		return 0, err
	}

	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return userID, nil
}


File: backend/go.mod

module dragonrelay

go 1.26.1

require (
	github.com/gin-gonic/gin v1.12.0
	github.com/grandcat/zeroconf v1.0.0
	tailscale.com v1.96.5
	github.com/golang-jwt/jwt/v5 v5.2.1
	github.com/mattn/go-sqlite3 v1.14.22
	github.com/google/uuid v1.6.0
	golang.org/x/crypto v0.48.0
)

require (
	filippo.io/edwards25519 v1.2.0 // indirect
	github.com/akutz/memconn v0.1.0 // indirect
	github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
	github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
	github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect
	github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect
	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
	github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect
	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
	github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
	github.com/aws/smithy-go v1.24.0 // indirect
	github.com/bytedance/gopkg v0.1.3 // indirect
	github.com/bytedance/sonic v1.15.0 // indirect
	github.com/bytedance/sonic/loader v0.5.0 // indirect
	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
	github.com/cloudwego/base64x v0.1.6 // indirect
	github.com/coder/websocket v1.8.12 // indirect
	github.com/creachadair/msync v0.7.1 // indirect
	github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
	github.com/gaissmai/bart v0.26.1 // indirect
	github.com/gin-contrib/sse v1.1.0 // indirect
	github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.30.1 // indirect
	github.com/goccy/go-json v0.10.5 // indirect
	github.com/goccy/go-yaml v1.19.2 // indirect
	github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
	github.com/google/btree v1.1.3 // indirect
	github.com/google/go-cmp v0.7.0 // indirect
	github.com/hdevalence/ed25519consensus v0.2.0 // indirect
	github.com/huin/goupnp v1.3.0 // indirect
	github.com/jsimonetti/rtnetlink v1.4.0 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/compress v1.18.2 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
	github.com/mdlayher/socket v0.5.0 // indirect
	github.com/miekg/dns v1.1.58 // indirect
	github.com/mitchellh/go-ps v1.0.0 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/pires/go-proxyproto v0.8.1 // indirect
	github.com/prometheus-community/pro-bing v0.4.0 // indirect
	github.com/quic-go/qpack v0.6.0 // indirect
	github.com/quic-go/quic-go v0.59.0 // indirect
	github.com/safchain/ethtool v0.3.0 // indirect
	github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
	github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
	github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
	github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
	github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
	github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.3.1 // indirect
	github.com/x448/float16 v0.8.4 // indirect
	go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
	go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
	golang.org/x/arch v0.22.0 // indirect
	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
	golang.org/x/mod v0.32.0 // indirect
	golang.org/x/net v0.51.0 // indirect
	golang.org/x/oauth2 v0.33.0 // indirect
	golang.org/x/sync v0.19.0 // indirect
	golang.org/x/sys v0.41.0 // indirect
	golang.org/x/term v0.40.0 // indirect
	golang.org/x/text v0.34.0 // indirect
	golang.org/x/time v0.12.0 // indirect
	golang.org/x/tools v0.41.0 // indirect
	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
	golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
	google.golang.org/protobuf v1.36.11 // indirect
	gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
)


Now let me read the remaining files:


File: backend/internal/discovery/discovery.go

// Package discovery finds Apollo/Artemis hosts on the network via mDNS.
// Apollo advertises itself as _nvstream._tcp.local — the same service
// type that Moonlight scans for.
package discovery

import (
	"context"
	"log"
	"sync"
	"time"

	"github.com/grandcat/zeroconf"
)

const nvstreamService = "_nvstream._tcp"

// Host represents a discovered Apollo/Artemis streaming host.
type Host struct {
	Name    string   `json:"name"`
	IP      string   `json:"ip"`
	Port    int      `json:"port"`
	Online  bool     `json:"online"`
	SeenAt  time.Time `json:"seen_at"`
}

// Discoverer watches the local (Tailscale) network for Apollo hosts.
type Discoverer struct {
	mu    sync.RWMutex
	hosts map[string]*Host // keyed by name
}

// New creates a new Discoverer with an empty host list.
func New() *Discoverer {
	return &Discoverer{hosts: make(map[string]*Host)}
}

// Run starts mDNS browsing and blocks until ctx is cancelled.
// Discovered hosts are stored and retrievable via Hosts().
func (d *Discoverer) Run(ctx context.Context) {
	for {
		if err := d.browse(ctx); err != nil && ctx.Err() == nil {
			log.Printf("discovery: browse error: %v — retrying in 10s", err)
			select {
			case <-ctx.Done():
				return
			case <-time.After(10 * time.Second):
			}
		}
		if ctx.Err() != nil {
			return
		}
	}
}

func (d *Discoverer) browse(ctx context.Context) error {
	resolver, err := zeroconf.NewResolver(nil)
	if err != nil {
		return err
	}

	entries := make(chan *zeroconf.ServiceEntry)
	browseCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	go func() {
		for entry := range entries {
			ip := ""
			if len(entry.AddrIPv4) > 0 {
				ip = entry.AddrIPv4[0].String()
			} else if len(entry.AddrIPv6) > 0 {
				ip = entry.AddrIPv6[0].String()
			}
			if ip == "" {
				continue
			}
			h := &Host{
				Name:   entry.ServiceInstanceName(),
				IP:     ip,
				Port:   entry.Port,
				Online: true,
				SeenAt: time.Now(),
			}
			d.upsert(h)
			log.Printf("discovery: found host %s at %s:%d", h.Name, h.IP, h.Port)
		}
	}()

	if err := resolver.Browse(browseCtx, nvstreamService, "local.", entries); err != nil {
		return err
	}
	<-browseCtx.Done()
	return nil
}

func (d *Discoverer) upsert(h *Host) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.hosts[h.Name] = h
}

// Hosts returns a snapshot of all known hosts.
func (d *Discoverer) Hosts() []*Host {
	d.mu.RLock()
	defer d.mu.RUnlock()

	// Mark hosts as offline if not seen in 30s
	cutoff := time.Now().Add(-30 * time.Second)
	out := make([]*Host, 0, len(d.hosts))
	for _, h := range d.hosts {
		copy := *h
		if h.SeenAt.Before(cutoff) {
			copy.Online = false
		}
		out = append(out, &copy)
	}
	return out
}

// AddManual adds a host by IP/port without mDNS (for hosts behind NAT or
// on subnets that don't support multicast).
func (d *Discoverer) AddManual(name, ip string, port int) {
	d.upsert(&Host{
		Name:   name,
		IP:     ip,
		Port:   port,
		Online: true,
		SeenAt: time.Now(),
	})
}

// Remove deletes a host from the registry by IP address.
func (d *Discoverer) Remove(ip string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	for name, h := range d.hosts {
		if h.IP == ip {
			delete(d.hosts, name)
			return
		}
	}
}


Error: Error executing tool forgejo_get_file: Client error '404 Not Found' for url 'https://forge.wilddragon.net/api/v1/repos/zgaetano/dragonrelay/contents/backend/internal/moonlight/moonlight.go?ref=main'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404


File: backend/internal/tailscale/tailscale.go

// Package tailscale manages an embedded Tailscale node via tsnet.
// The node connects to a tailnet using an auth key and exposes a
// net.Listener that the rest of the app can use for local-only HTTP.
package tailscale

import (
	"context"
	"fmt"
	"log"
	"net"
	"net/netip"
	"os"
	"path/filepath"

	"tailscale.com/tsnet"
)

// Node wraps a tsnet.Server and provides helpers.
type Node struct {
	srv      *tsnet.Server
	hostname string
}

// Config holds settings for the embedded Tailscale node.
type Config struct {
	// Hostname is the name this device will appear as on the tailnet.
	Hostname string
	// AuthKey is a Tailscale auth key (reusable or ephemeral).
	// If empty the node will print a login URL to stderr.
	AuthKey string
	// StateDir is where tsnet persists its state.
	// Defaults to <user config dir>/dragonrelay/tsnet.
	StateDir string
}

// New creates and starts a new embedded Tailscale node.
func New(ctx context.Context, cfg Config) (*Node, error) {
	stateDir := cfg.StateDir
	if stateDir == "" {
		// In Docker the TS_STATE_DIR env var points to the mounted volume.
		// Fall back to the user config dir for native / dev installs.
		if envDir := os.Getenv("TS_STATE_DIR"); envDir != "" {
			stateDir = envDir
		} else {
			cfgDir, err := os.UserConfigDir()
			if err != nil {
				return nil, fmt.Errorf("finding config dir: %w", err)
			}
			stateDir = filepath.Join(cfgDir, "dragonrelay", "tsnet")
		}
	}
	if err := os.MkdirAll(stateDir, 0700); err != nil {
		return nil, fmt.Errorf("creating state dir: %w", err)
	}

	s := &tsnet.Server{
		Hostname:  cfg.Hostname,
		AuthKey:   cfg.AuthKey,
		Dir:       stateDir,
		Logf:      func(format string, args ...any) { log.Printf("[tsnet] "+format, args...) },
	}

	// Bring the node up — this blocks until connected (or auth required).
	lc, err := s.LocalClient()
	if err != nil {
		return nil, fmt.Errorf("getting local client: %w", err)
	}

	// Wait for the node to be ready.
	if _, err := lc.Status(ctx); err != nil {
		// Not fatal — node may need interactive login.
		log.Printf("tailscale status: %v", err)
	}

	return &Node{srv: s, hostname: cfg.Hostname}, nil
}

// Listen returns a net.Listener bound on the Tailscale interface.
func (n *Node) Listen(network, addr string) (net.Listener, error) {
	return n.srv.Listen(network, addr)
}

// LocalAddr returns the Tailscale IPv4 address of this node, or empty string.
func (n *Node) LocalAddr() string {
	lc, err := n.srv.LocalClient()
	if err != nil {
		return ""
	}
	st, err := lc.Status(context.Background())
	if err != nil {
		return ""
	}
	for _, pfx := range st.TailscaleIPs {
		if pfx.Is4() {
			addr := netip.AddrFrom4(pfx.As4())
			return addr.String()
		}
	}
	return ""
}

// Close shuts down the node.
func (n *Node) Close() error {
	return n.srv.Close()
}


Perfect! I've successfully retrieved most of the files. Let me check if moonlight.go exists elsewhere or under a different name:


📄 launcher.go (3915B)
📄 launcher_test.go (1238B)


The moonlight directory exists but doesn't have a moonlight.go file. Let me read the files that are in there:


File: backend/internal/moonlight/launcher.go

// Package moonlight handles launching the Moonlight client and tracking
// active streaming sessions.
package moonlight

import (
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"sync"
	"syscall"
	"time"

	"github.com/google/uuid"
)

// DefaultPaths are the well-known install locations per platform.
var DefaultPaths = map[string][]string{
	"windows": {
		`C:\Program Files\Moonlight Game Streaming\Moonlight.exe`,
		`C:\Program Files (x86)\Moonlight Game Streaming\Moonlight.exe`,
	},
	"darwin": {"/Applications/Moonlight.app/Contents/MacOS/Moonlight"},
	"linux":  {"/usr/bin/moonlight", "/usr/local/bin/moonlight"},
}

// Session represents an active Moonlight streaming session.
type Session struct {
	ID        string    `json:"id"`
	HostName  string    `json:"host_name"`
	IP        string    `json:"ip"`
	App       string    `json:"app"`
	PID       int       `json:"pid"`
	StartedAt time.Time `json:"started_at"`
}

// Launcher knows how to start Moonlight and tracks active sessions.
type Launcher struct {
	execPath string
	mu       sync.Mutex
	sessions map[string]*Session
}

// New creates a Launcher, auto-detecting the Moonlight binary.
// Pass an empty string to use auto-detection.
func New(overridePath string) (*Launcher, error) {
	l := &Launcher{sessions: make(map[string]*Session)}
	if overridePath != "" {
		if path, err := exec.LookPath(overridePath); err == nil {
			l.execPath = path
			return l, nil
		}
		return nil, fmt.Errorf("moonlight not found at %q", overridePath)
	}
	if path, err := exec.LookPath("moonlight"); err == nil {
		l.execPath = path
		return l, nil
	}
	for _, candidate := range DefaultPaths[runtime.GOOS] {
		if path, err := exec.LookPath(candidate); err == nil {
			l.execPath = path
			return l, nil
		}
	}
	return l, nil
}

// ExecPath returns the resolved path to the Moonlight binary.
func (l *Launcher) ExecPath() string { return l.execPath }

// Stream launches a Moonlight stream and registers the session.
func (l *Launcher) Stream(hostIP, app string) error {
	if l.execPath == "" {
		return fmt.Errorf("Moonlight executable not found — please install Moonlight or set MOONLIGHT_PATH")
	}
	cmd := exec.Command(l.execPath, "stream", hostIP, app)
	if err := cmd.Start(); err != nil {
		return err
	}
	id := uuid.New().String()
	s := &Session{ID: id, IP: hostIP, App: app, PID: cmd.Process.Pid, StartedAt: time.Now()}
	l.mu.Lock()
	l.sessions[id] = s
	l.mu.Unlock()
	go func() {
		_ = cmd.Wait()
		l.mu.Lock()
		delete(l.sessions, id)
		l.mu.Unlock()
	}()
	return nil
}

// Pair runs the Moonlight pairing flow for a host.
func (l *Launcher) Pair(hostIP string) error {
	if l.execPath == "" {
		return fmt.Errorf("Moonlight executable not found")
	}
	cmd := exec.Command(l.execPath, "pair", hostIP)
	return cmd.Start()
}

// Sessions returns a snapshot of all active sessions.
func (l *Launcher) Sessions() []*Session {
	l.mu.Lock()
	defer l.mu.Unlock()
	out := make([]*Session, 0, len(l.sessions))
	for _, s := range l.sessions {
		cp := *s
		out = append(out, &cp)
	}
	return out
}

// Disconnect kills a session by ID.
func (l *Launcher) Disconnect(id string) error {
	l.mu.Lock()
	s, ok := l.sessions[id]
	if !ok {
		l.mu.Unlock()
		return fmt.Errorf("session %q not found", id)
	}
	delete(l.sessions, id)
	l.mu.Unlock()
	p, err := os.FindProcess(s.PID)
	if err != nil {
		return nil
	}
	_ = p.Signal(syscall.SIGTERM)
	return nil
}

// AddTestSession registers a fake session without starting a process.
// Only for use in tests.
func (l *Launcher) AddTestSession(hostName, ip, app string) string {
	id := uuid.New().String()
	l.mu.Lock()
	l.sessions[id] = &Session{ID: id, HostName: hostName, IP: ip, App: app, PID: -1, StartedAt: time.Now()}
	l.mu.Unlock()
	return id
}

// RemoveSession removes a session by ID without killing any process.
// Only for use in tests.
func (l *Launcher) RemoveSession(id string) {
	l.mu.Lock()
	delete(l.sessions, id)
	l.mu.Unlock()
}


File: backend/internal/moonlight/launcher_test.go

package moonlight_test

import (
	"testing"
	"time"

	"dragonrelay/internal/moonlight"
)

func TestSessionRegistryEmpty(t *testing.T) {
	l, _ := moonlight.New("")
	sessions := l.Sessions()
	if len(sessions) != 0 {
		t.Errorf("expected empty sessions, got %d", len(sessions))
	}
}

func TestSessionRegistryAddAndRemove(t *testing.T) {
	l, _ := moonlight.New("")
	id := l.AddTestSession("TestHost", "192.168.1.1", "Desktop")
	sessions := l.Sessions()
	if len(sessions) != 1 {
		t.Fatalf("expected 1 session, got %d", len(sessions))
	}
	if sessions[0].ID != id {
		t.Errorf("ID mismatch: got %q want %q", sessions[0].ID, id)
	}
	if sessions[0].HostName != "TestHost" {
		t.Errorf("HostName: got %q want TestHost", sessions[0].HostName)
	}
	l.RemoveSession(id)
	if len(l.Sessions()) != 0 {
		t.Error("expected 0 sessions after remove")
	}
}

func TestSessionStartedAt(t *testing.T) {
	l, _ := moonlight.New("")
	before := time.Now()
	_ = l.AddTestSession("Host", "1.2.3.4", "App")
	after := time.Now()
	sessions := l.Sessions()
	if len(sessions) == 0 {
		t.Fatal("no sessions")
	}
	s := sessions[0]
	if s.StartedAt.Before(before) || s.StartedAt.After(after) {
		t.Errorf("StartedAt %v not in range [%v, %v]", s.StartedAt, before, after)
	}
}


Perfect! Here are all the requested files from the dragonrelay repository:

---

## File 1: backend/internal/db/db.go

```go
// Package db manages the SQLite user store for Dragon Flight Relay.
package db

import (
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	_ "github.com/mattn/go-sqlite3"
	"golang.org/x/crypto/bcrypt"
)

// User represents a row in the users table.
type User struct {
	ID        int64
	Username  string
	Password  string // bcrypt hash
	Role      string // "admin", "member", or "viewer"
	CreatedAt time.Time
}

// Store wraps a SQLite database connection.
type Store struct {
	db *sql.DB
}

// Open opens (or creates) the SQLite database at path.
// Pass ":memory:" for tests.
func Open(path string) (*Store, error) {
	d, err := sql.Open("sqlite3", path)
	if err != nil {
		return nil, fmt.Errorf("open db: %w", err)
	}
	// SQLite disables foreign-key enforcement by default. Turn it on so the
	// FK clauses in our schema (ON DELETE CASCADE, ON DELETE SET NULL) actually
	// fire. We enable it via a query string PRAGMA at connection-open time.
	// Since *sql.DB pools connections internally and PRAGMA foreign_keys is
	// per-connection, we also set MaxOpenConns(1) to enforce a single connection,
	// ensuring all operations use an FK-enforcing connection. This is safe for
	// this homelab project (very low concurrency, single goroutine handling
	// one HTTP request at a time).
	if _, err := d.Exec("PRAGMA foreign_keys = ON"); err != nil {
		d.Close()
		return nil, fmt.Errorf("enable foreign_keys: %w", err)
	}
	d.SetMaxOpenConns(1)
	s := &Store{db: d}
	if err := s.migrate(); err != nil {
		d.Close()
		return nil, err
	}
	return s, nil
}

// Close closes the underlying database.
func (s *Store) Close() error {
	return s.db.Close()
}

func (s *Store) migrate() error {
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Read current schema version
	var version int
	if err := tx.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
		return fmt.Errorf("read user_version: %w", err)
	}

	// v0 → v1: original users table.
	if version < 1 {
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS users (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				username   TEXT UNIQUE NOT NULL,
				password   TEXT NOT NULL,
				role       TEXT NOT NULL CHECK(role IN ('admin','user','viewer')),
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP
			)
		`); err != nil {
			return fmt.Errorf("create users: %w", err)
		}
		if _, err := tx.Exec(`PRAGMA user_version = 1`); err != nil {
			return err
		}
		version = 1
	}

	// v1 → v2: per-host ACL, invites, role rename.
	if version < 2 {
		// Rebuild users with the new role enum (superset for rollback safety).
		if _, err := tx.Exec(`
			CREATE TABLE users_new (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				username   TEXT UNIQUE NOT NULL,
				password   TEXT NOT NULL,
				role       TEXT NOT NULL CHECK(role IN ('admin','user','member','viewer')),
				created_at DATETIME DEFAULT CURRENT_TIMESTAMP
			)
		`); err != nil {
			return fmt.Errorf("create users_new: %w", err)
		}
		if _, err := tx.Exec(`
			INSERT INTO users_new (id, username, password, role, created_at)
			SELECT id, username, password,
			       CASE WHEN role='user' THEN 'member' ELSE role END,
			       created_at FROM users
		`); err != nil {
			return fmt.Errorf("copy users: %w", err)
		}
		if _, err := tx.Exec(`DROP TABLE users`); err != nil {
			return fmt.Errorf("drop users: %w", err)
		}
		if _, err := tx.Exec(`ALTER TABLE users_new RENAME TO users`); err != nil {
			return fmt.Errorf("rename users: %w", err)
		}

		// New tables.
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS host_acl (
				host_ip    TEXT    NOT NULL,
				user_id    INTEGER NOT NULL,
				level      TEXT    NOT NULL CHECK(level IN ('view','connect','manage')),
				granted_by INTEGER,
				granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
				PRIMARY KEY (host_ip, user_id),
				FOREIGN KEY (user_id)    REFERENCES users(id) ON DELETE CASCADE,
				FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
			)
		`); err != nil {
			return fmt.Errorf("create host_acl: %w", err)
		}
		if _, err := tx.Exec(`
			CREATE TABLE IF NOT EXISTS invites (
				id              INTEGER PRIMARY KEY AUTOINCREMENT,
				token_hash      TEXT    NOT NULL UNIQUE,
				label           TEXT    NOT NULL DEFAULT '',
				role            TEXT    NOT NULL CHECK(role IN ('admin','member','viewer')),
				host_access     TEXT    NOT NULL DEFAULT '[]',
				created_by      INTEGER NOT NULL,
				created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
				expires_at      DATETIME NOT NULL,
				used_at         DATETIME,
				used_by_user_id INTEGER,
				FOREIGN KEY (created_by)      REFERENCES users(id),
				FOREIGN KEY (used_by_user_id) REFERENCES users(id)
			)
		`); err != nil {
			return fmt.Errorf("create invites: %w", err)
		}

		if _, err := tx.Exec(`PRAGMA user_version = 2`); err != nil {
			return err
		}
	}

	return tx.Commit()
}

// CreateUser creates a new user with a bcrypt-hashed password.
func (s *Store) CreateUser(username, plainPassword, role string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	_, err = s.db.Exec(
		`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`,
		username, string(hash), role,
	)
	return err
}

// GetUserByUsername returns a user by username, or sql.ErrNoRows if not found.
func (s *Store) GetUserByUsername(username string) (*User, error) {
	u := &User{}
	err := s.db.QueryRow(
		`SELECT id, username, password, role, created_at FROM users WHERE username = ?`,
		username,
	).Scan(&u.ID, &u.Username, &u.Password, &u.Role, &u.CreatedAt)
	if err != nil {
		return nil, err
	}
	return u, nil
}

// GetUserByID returns a user by ID, or sql.ErrNoRows if not found.
func (s *Store) GetUserByID(id int64) (*User, error) {
	u := &User{}
	err := s.db.QueryRow(
		`SELECT id, username, password, role, created_at FROM users WHERE id = ?`,
		id,
	).Scan(&u.ID, &u.Username, &u.Password, &u.Role, &u.CreatedAt)
	if err != nil {
		return nil, err
	}
	return u, nil
}

// ListUsers returns all users. Password field is empty (not returned).
func (s *Store) ListUsers() ([]*User, error) {
	rows, err := s.db.Query(`SELECT id, username, role, created_at FROM users ORDER BY id`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var users []*User
	for rows.Next() {
		u := &User{}
		if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	return users, rows.Err()
}

// CheckPassword returns true if plainPassword matches the stored bcrypt hash.
func (s *Store) CheckPassword(u *User, plainPassword string) bool {
	return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plainPassword)) == nil
}

// UpdateRole changes a user's role by ID.
func (s *Store) UpdateRole(id int64, role string) error {
	_, err := s.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id)
	return err
}

// ResetPassword sets a new bcrypt-hashed password for a user by ID.
func (s *Store) ResetPassword(id int64, plainPassword string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	_, err = s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, string(hash), id)
	return err
}

// DeleteUser removes a user by ID.
func (s *Store) DeleteUser(id int64) error {
	_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
	return err
}

// SeedAdminIfEmpty creates an admin user with the given password if the
// users table is empty. Returns true if a user was created.
func (s *Store) SeedAdminIfEmpty(adminPassword string) (bool, error) {
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return false, err
	}
	if count > 0 {
		return false, nil
	}
	if err := s.CreateUser("admin", adminPassword, "admin"); err != nil {
		return false, err
	}
	return true, nil
}

// IsEmpty returns true if no users exist yet (used by setup wizard).
func (s *Store) IsEmpty() (bool, error) {
	var count int
	if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
		return false, err
	}
	return count == 0, nil
}

// ─── Host ACL ──────────────────────────────────────────────────────────────

// HostGrant pairs a host IP with the level a user has on it.
type HostGrant struct {
	HostIP string `json:"host_ip"`
	Level  string `json:"level"` // "view" | "connect" | "manage"
}

// HostACLEntry is one row of "who can do what on this host".
type HostACLEntry struct {
	UserID   int64  `json:"user_id"`
	Username string `json:"username"`
	Level    string `json:"level"`
}

// GrantHostAccess inserts or upgrades a user's grant for a host.
// Pass grantedBy = 0 for system-issued grants (migration backfill).
func (s *Store) GrantHostAccess(userID int64, hostIP, level string, grantedBy int64) error {
	if level != "view" && level != "connect" && level != "manage" {
		return fmt.Errorf("invalid level: %s", level)
	}
	var grantedByVal interface{}
	if grantedBy == 0 {
		grantedByVal = nil
	} else {
		grantedByVal = grantedBy
	}
	_, err := s.db.Exec(`
		INSERT INTO host_acl (host_ip, user_id, level, granted_by)
		VALUES (?, ?, ?, ?)
		ON CONFLICT(host_ip, user_id)
		DO UPDATE SET level = excluded.level, granted_by = excluded.granted_by, granted_at = CURRENT_TIMESTAMP
	`, hostIP, userID, level, grantedByVal)
	return err
}

// RevokeHostAccess removes a user's grant for a host.
func (s *Store) RevokeHostAccess(userID int64, hostIP string) error {
	_, err := s.db.Exec(`DELETE FROM host_acl WHERE host_ip = ? AND user_id = ?`, hostIP, userID)
	return err
}

// GetHostAccess returns the level a user has on a host, or ok=false if none.
func (s *Store) GetHostAccess(userID int64, hostIP string) (level string, ok bool, err error) {
	err = s.db.QueryRow(
		`SELECT level FROM host_acl WHERE host_ip = ? AND user_id = ?`,
		hostIP, userID,
	).Scan(&level)
	if err == sql.ErrNoRows {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return level, true, nil
}

// ListUserHostAccess returns every host this user has been granted access to.
func (s *Store) ListUserHostAccess(userID int64) ([]HostGrant, error) {
	rows, err := s.db.Query(
		`SELECT host_ip, level FROM host_acl WHERE user_id = ? ORDER BY host_ip`,
		userID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var grants []HostGrant
	for rows.Next() {
		var g HostGrant
		if err := rows.Scan(&g.HostIP, &g.Level); err != nil {
			return nil, err
		}
		grants = append(grants, g)
	}
	return grants, rows.Err()
}

// ListHostACL returns every user grant for a given host.
func (s *Store) ListHostACL(hostIP string) ([]HostACLEntry, error) {
	rows, err := s.db.Query(`
		SELECT u.id, u.username, h.level
		FROM host_acl h JOIN users u ON u.id = h.user_id
		WHERE h.host_ip = ? ORDER BY u.username
	`, hostIP)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var entries []HostACLEntry
	for rows.Next() {
		var e HostACLEntry
		if err := rows.Scan(&e.UserID, &e.Username, &e.Level); err != nil {
			return nil, err
		}
		entries = append(entries, e)
	}
	return entries, rows.Err()
}

// ─── Invites ───────────────────────────────────────────────────────────────

// hashToken turns a raw invite token into the value we store.
func hashToken(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

// InviteRecord is the read-side view of an invites row.
type InviteRecord struct {
	ID        int64
	Label     string
	Role      string
	HostAccess []HostGrant
	CreatedBy  int64
	CreatedAt  time.Time
	ExpiresAt  time.Time
	UsedAt     *time.Time
	UsedByID   *int64
}

// ErrInviteInvalid is returned when an invite is missing, expired, or used.
var ErrInviteInvalid = errors.New("invite invalid")

// CreateInvite stores the hashed token along with metadata. Returns the new id.
func (s *Store) CreateInvite(rawToken, label, role string, hostAccess []HostGrant, createdBy int64, expires time.Time) (int64, error) {
	if role != "admin" && role != "member" && role != "viewer" {
		return 0, fmt.Errorf("invalid role: %s", role)
	}
	if hostAccess == nil {
		hostAccess = []HostGrant{}
	}
	hostAccessJSON, err := json.Marshal(hostAccess)
	if err != nil {
		return 0, err
	}
	res, err := s.db.Exec(`
		INSERT INTO invites (token_hash, label, role, host_access, created_by, expires_at)
		VALUES (?, ?, ?, ?, ?, ?)
	`, hashToken(rawToken), label, role, string(hostAccessJSON), createdBy, expires)
	if err != nil {
		return 0, err
	}
	return res.LastInsertId()
}

// GetInviteByToken returns the invite for a raw token. Does NOT check expiry/used.
func (s *Store) GetInviteByToken(rawToken string) (*InviteRecord, error) {
	r := &InviteRecord{}
	var hostAccessJSON string
	var usedAt sql.NullTime
	var usedBy sql.NullInt64
	err := s.db.QueryRow(`
		SELECT id, label, role, host_access, created_by, created_at, expires_at, used_at, used_by_user_id
		FROM invites WHERE token_hash = ?
	`, hashToken(rawToken)).Scan(
		&r.ID, &r.Label, &r.Role, &hostAccessJSON,
		&r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &usedAt, &usedBy,
	)
	if err != nil {
		return nil, err
	}
	if usedAt.Valid {
		r.UsedAt = &usedAt.Time
	}
	if usedBy.Valid {
		v := usedBy.Int64
		r.UsedByID = &v
	}
	if err := json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess); err != nil {
		return nil, err
	}
	return r, nil
}

// ListActiveInvites returns invites that are unused and not expired.
func (s *Store) ListActiveInvites() ([]InviteRecord, error) {
	rows, err := s.db.Query(`
		SELECT id, label, role, host_access, created_by, created_at, expires_at
		FROM invites
		WHERE used_at IS NULL AND expires_at > CURRENT_TIMESTAMP
		ORDER BY created_at DESC
	`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []InviteRecord
	for rows.Next() {
		var r InviteRecord
		var hostAccessJSON string
		if err := rows.Scan(&r.ID, &r.Label, &r.Role, &hostAccessJSON, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt); err != nil {
			return nil, err
		}
		_ = json.Unmarshal([]byte(hostAccessJSON), &r.HostAccess)
		out = append(out, r)
	}
	return out, rows.Err()
}

// RevokeInvite deletes an unused invite. Returns error if not found.
func (s *Store) RevokeInvite(id int64) error {
	res, err := s.db.Exec(`DELETE FROM invites WHERE id = ? AND used_at IS NULL`, id)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return errors.New("invite not found or already used")
	}
	return nil
}

// RedeemInvite atomically: validates, creates the user, inserts host_acl rows,
// marks the invite used. Returns the new user id.
func (s *Store) RedeemInvite(rawToken, username, plainPassword string) (int64, error) {
	hash := hashToken(rawToken)
	tx, err := s.db.Begin()
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()

	var (
		id             int64
		role           string
		hostAccessJSON string
		expiresAt      time.Time
		usedAt         sql.NullTime
	)
	err = tx.QueryRow(`
		SELECT id, role, host_access, expires_at, used_at
		FROM invites WHERE token_hash = ?
	`, hash).Scan(&id, &role, &hostAccessJSON, &expiresAt, &usedAt)
	if err == sql.ErrNoRows {
		return 0, ErrInviteInvalid
	}
	if err != nil {
		return 0, err
	}
	if usedAt.Valid || time.Now().After(expiresAt) {
		return 0, ErrInviteInvalid
	}

	// Hash password and insert user.
	pwHash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
	if err != nil {
		return 0, err
	}
	res, err := tx.Exec(
		`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`,
		username, string(pwHash), role,
	)
	if err != nil {
		return 0, err
	}
	userID, err := res.LastInsertId()
	if err != nil {
		return 0, err
	}

	// Insert host_acl rows.
	var grants []HostGrant
	if err := json.Unmarshal([]byte(hostAccessJSON), &grants); err != nil {
		return 0, err
	}
	for _, g := range grants {
		// Cap viewer-role users at 'view' regardless of grant.
		level := g.Level
		if role == "viewer" && level != "view" {
			level = "view"
		}
		if _, err := tx.Exec(`
			INSERT INTO host_acl (host_ip, user_id, level)
			VALUES (?, ?, ?)
		`, g.HostIP, userID, level); err != nil {
			return 0, err
		}
	}

	// Mark invite used.
	if _, err := tx.Exec(`
		UPDATE invites SET used_at = CURRENT_TIMESTAMP, used_by_user_id = ? WHERE id = ?
	`, userID, id); err != nil {
		return 0, err
	}

	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return userID, nil
}
```

---

## File 2: backend/internal/discovery/discovery.go

```go
// Package discovery finds Apollo/Artemis hosts on the network via mDNS.
// Apollo advertises itself as _nvstream._tcp.local — the same service
// type that Moonlight scans for.
package discovery

import (
	"context"
	"log"
	"sync"
	"time"

	"github.com/grandcat/zeroconf"
)

const nvstreamService = "_nvstream._tcp"

// Host represents a discovered Apollo/Artemis streaming host.
type Host struct {
	Name    string     `json:"name"`
	IP      string     `json:"ip"`
	Port    int        `json:"port"`
	Online  bool       `json:"online"`
	SeenAt  time.Time  `json:"seen_at"`
}

// Discoverer watches the local (Tailscale) network for Apollo hosts.
type Discoverer struct {
	mu    sync.RWMutex
	hosts map[string]*Host // keyed by name
}

// New creates a new Discoverer with an empty host list.
func New() *Discoverer {
	return &Discoverer{hosts: make(map[string]*Host)}
}

// Run starts mDNS browsing and blocks until ctx is cancelled.
// Discovered hosts are stored and retrievable via Hosts().
func (d *Discoverer) Run(ctx context.Context) {
	for {
		if err := d.browse(ctx); err != nil && ctx.Err() == nil {
			log.Printf("discovery: browse error: %v — retrying in 10s", err)
			select {
			case <-ctx.Done():
				return
			case <-time.After(10 * time.Second):
			}
		}
		if ctx.Err() != nil {
			return
		}
	}
}

func (d *Discoverer) browse(ctx context.Context) error {
	resolver, err := zeroconf.NewResolver(nil)
	if err != nil {
		return err
	}

	entries := make(chan *zeroconf.ServiceEntry)
	browseCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	go func() {
		for entry := range entries {
			ip := ""
			if len(entry.AddrIPv4) > 0 {
				ip = entry.AddrIPv4[0].String()
			} else if len(entry.AddrIPv6) > 0 {
				ip = entry.AddrIPv6[0].String()
			}
			if ip == "" {
				continue
			}
			h := &Host{
				Name:   entry.ServiceInstanceName(),
				IP:     ip,
				Port:   entry.Port,
				Online: true,
				SeenAt: time.Now(),
			}
			d.upsert(h)
			log.Printf("discovery: found host %s at %s:%d", h.Name, h.IP, h.Port)
		}
	}()

	if err := resolver.Browse(browseCtx, nvstreamService, "local.", entries); err != nil {
		return err
	}
	<-browseCtx.Done()
	return nil
}

func (d *Discoverer) upsert(h *Host) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.hosts[h.Name] = h
}

// Hosts returns a snapshot of all known hosts.
func (d *Discoverer) Hosts() []*Host {
	d.mu.RLock()
	defer d.mu.RUnlock()

	// Mark hosts as offline if not seen in 30s
	cutoff := time.Now().Add(-30 * time.Second)
	out := make([]*Host, 0, len(d.hosts))
	for _, h := range d.hosts {
		copy := *h
		if h.SeenAt.Before(cutoff) {
			copy.Online = false
		}
		out = append(out, &copy)
	}
	return out
}

// AddManual adds a host by IP/port without mDNS (for hosts behind NAT or
// on subnets that don't support multicast).
func (d *Discoverer) AddManual(name, ip string, port int) {
	d.upsert(&Host{
		Name:   name,
		IP:     ip,
		Port:   port,
		Online: true,
		SeenAt: time.Now(),
	})
}

// Remove deletes a host from the registry by IP address.
func (d *Discoverer) Remove(ip string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	for name, h := range d.hosts {
		if h.IP == ip {
			delete(d.hosts, name)
			return
		}
	}
}
```

---

## File 3: backend/internal/moonlight/launcher.go

```go
// Package moonlight handles launching the Moonlight client and tracking
// active streaming sessions.
package moonlight

import (
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"sync"
	"syscall"
	"time"

	"github.com/google/uuid"
)

// DefaultPaths are the well-known install locations per platform.
var DefaultPaths = map[string][]string{
	"windows": {
		`C:\Program Files\Moonlight Game Streaming\Moonlight.exe`,
		`C:\Program Files (x86)\Moonlight Game Streaming\Moonlight.exe`,
	},
	"darwin": {"/Applications/Moonlight.app/Contents/MacOS/Moonlight"},
	"linux":  {"/usr/bin/moonlight", "/usr/local/bin/moonlight"},
}

// Session represents an active Moonlight streaming session.
type Session struct {
	ID        string    `json:"id"`
	HostName  string    `json:"host_name"`
	IP        string    `json:"ip"`
	App       string    `json:"app"`
	PID       int       `json:"pid"`
	StartedAt time.Time `json:"started_at"`
}

// Launcher knows how to start Moonlight and tracks active sessions.
type Launcher struct {
	execPath string
	mu       sync.Mutex
	sessions map[string]*Session
}

// New creates a Launcher, auto-detecting the Moonlight binary.
// Pass an empty string to use auto-detection.
func New(overridePath string) (*Launcher, error) {
	l := &Launcher{sessions: make(map[string]*Session)}
	if overridePath != "" {
		if path, err := exec.LookPath(overridePath); err == nil {
			l.execPath = path
			return l, nil
		}
		return nil, fmt.Errorf("moonlight not found at %q", overridePath)
	}
	if path, err := exec.LookPath("moonlight"); err == nil {
		l.execPath = path
		return l, nil
	}
	for _, candidate := range DefaultPaths[runtime.GOOS] {
		if path, err := exec.LookPath(candidate); err == nil {
			l.execPath = path
			return l, nil
		}
	}
	return l, nil
}

// ExecPath returns the resolved path to the Moonlight binary.
func (l *Launcher) ExecPath() string { return l.execPath }

// Stream launches a Moonlight stream and registers the session.
func (l *Launcher) Stream(hostIP, app string) error {
	if l.execPath == "" {
		return fmt.Errorf("Moonlight executable not found — please install Moonlight or set MOONLIGHT_PATH")
	}
	cmd := exec.Command(l.execPath, "stream", hostIP, app)
	if err := cmd.Start(); err != nil {
		return err
	}
	id := uuid.New().String()
	s := &Session{ID: id, IP: hostIP, App: app, PID: cmd.Process.Pid, StartedAt: time.Now()}
	l.mu.Lock()
	l.sessions[id] = s
	l.mu.Unlock()
	go func() {
		_ = cmd.Wait()
		l.mu.Lock()
		delete(l.sessions, id)
		l.mu.Unlock()
	}()
	return nil
}

// Pair runs the Moonlight pairing flow for a host.
func (l *Launcher) Pair(hostIP string) error {
	if l.execPath == "" {
		return fmt.Errorf("Moonlight executable not found")
	}
	cmd := exec.Command(l.execPath, "pair", hostIP)
	return cmd.Start()
}

// Sessions returns a snapshot of all active sessions.
func (l *Launcher) Sessions() []*Session {
	l.mu.Lock()
	defer l.mu.Unlock()
	out := make([]*Session, 0, len(l.sessions))
	for _, s := range l.sessions {
		cp := *s
		out = append(out, &cp)
	}
	return out
}

// Disconnect kills a session by ID.
func (l *Launcher) Disconnect(id string) error {
	l.mu.Lock()
	s, ok := l.sessions[id]
	if !ok {
		l.mu.Unlock()
		return fmt.Errorf("session %q not found", id)
	}
	delete(l.sessions, id)
	l.mu.Unlock()
	p, err := os.FindProcess(s.PID)
	if err != nil {
		return nil
	}
	_ = p.Signal(syscall.SIGTERM)
	return nil
}

// AddTestSession registers a fake session without starting a process.
// Only for use in tests.
func (l *Launcher) AddTestSession(hostName, ip, app string) string {
	id := uuid.New().String()
	l.mu.Lock()
	l.sessions[id] = &Session{ID: id, HostName: hostName, IP: ip, App: app, PID: -1, StartedAt: time.Now()}
	l.mu.Unlock()
	return id
}

// RemoveSession removes a session by ID without killing any process.
// Only for use in tests.
func (l *Launcher) RemoveSession(id string) {
	l.mu.Lock()
	delete(l.sessions, id)
	l.mu.Unlock()
}
```

---

## File 4: backend/internal/moonlight/launcher_test.go

```go
package moonlight_test

import (
	"testing"
	"time"

	"dragonrelay/internal/moonlight"
)

func TestSessionRegistryEmpty(t *testing.T) {
	l, _ := moonlight.New("")
	sessions := l.Sessions()
	if len(sessions) != 0 {
		t.Errorf("expected empty sessions, got %d", len(sessions))
	}
}

func TestSessionRegistryAddAndRemove(t *testing.T) {
	l, _ := moonlight.New("")
	id := l.AddTestSession("TestHost", "192.168.1.1", "Desktop")
	sessions := l.Sessions()
	if len(sessions) != 1 {
		t.Fatalf("expected 1 session, got %d", len(sessions))
	}
	if sessions[0].ID != id {
		t.Errorf("ID mismatch: got %q want %q", sessions[0].ID, id)
	}
	if sessions[0].HostName != "TestHost" {
		t.Errorf("HostName: got %q want TestHost", sessions[0].HostName)
	}
	l.RemoveSession(id)
	if len(l.Sessions()) != 0 {
		t.Error("expected 0 sessions after remove")
	}
}

func TestSessionStartedAt(t *testing.T) {
	l, _ := moonlight.New("")
	before := time.Now()
	_ = l.AddTestSession("Host", "1.2.3.4", "App")
	after := time.Now()
	sessions := l.Sessions()
	if len(sessions) == 0 {
		t.Fatal("no sessions")
	}
	s := sessions[0]
	if s.StartedAt.Before(before) || s.StartedAt.After(after) {
		t.Errorf("StartedAt %v not in range [%v, %v]", s.StartedAt, before, after)
	}
}
```

---

## File 5: backend/internal/tailscale/tailscale.go

```go
// Package tailscale manages an embedded Tailscale node via tsnet.
// The node connects to a tailnet using an auth key and exposes a
// net.Listener that the rest of the app can use for local-only HTTP.
package tailscale

import (
	"context"
	"fmt"
	"log"
	"net"
	"net/netip"
	"os"
	"path/filepath"

	"tailscale.com/tsnet"
)

// Node wraps a tsnet.Server and provides helpers.
type Node struct {
	srv      *tsnet.Server
	hostname string
}

// Config holds settings for the embedded Tailscale node.
type Config struct {
	// Hostname is the name this device will appear as on the tailnet.
	Hostname string
	// AuthKey is a Tailscale auth key (reusable or ephemeral).
	// If empty the node will print a login URL to stderr.
	AuthKey string
	// StateDir is where tsnet persists its state.
	// Defaults to <user config dir>/dragonrelay/tsnet.
	StateDir string
}

// New creates and starts a new embedded Tailscale node.
func New(ctx context.Context, cfg Config) (*Node, error) {
	stateDir := cfg.StateDir
	if stateDir == "" {
		// In Docker the TS_STATE_DIR env var points to the mounted volume.
		// Fall back to the user config dir for native / dev installs.
		if envDir := os.Getenv("TS_STATE_DIR"); envDir != "" {
			stateDir = envDir
		} else {
			cfgDir, err := os.UserConfigDir()
			if err != nil {
				return nil, fmt.Errorf("finding config dir: %w", err)
			}
			stateDir = filepath.Join(cfgDir, "dragonrelay", "tsnet")
		}
	}
	if err := os.MkdirAll(stateDir, 0700); err != nil {
		return nil, fmt.Errorf("creating state dir: %w", err)
	}

	s := &tsnet.Server{
		Hostname:  cfg.Hostname,
		AuthKey:   cfg.AuthKey,
		Dir:       stateDir,
		Logf:      func(format string, args ...any) { log.Printf("[tsnet] "+format, args...) },
	}

	// Bring the node up — this blocks until connected (or auth required).
	lc, err := s.LocalClient()
	if err != nil {
		return nil, fmt.Errorf("getting local client: %w", err)
	}

	// Wait for the node to be ready.
	if _, err := lc.Status(ctx); err != nil {
		// Not fatal — node may need interactive login.
		log.Printf("tailscale status: %v", err)
	}

	return &Node{srv: s, hostname: cfg.Hostname}, nil
}

// Listen returns a net.Listener bound on the Tailscale interface.
func (n *Node) Listen(network, addr string) (net.Listener, error) {
	return n.srv.Listen(network, addr)
}

// LocalAddr returns the Tailscale IPv4 address of this node, or empty string.
func (n *Node) LocalAddr() string {
	lc, err := n.srv.LocalClient()
	if err != nil {
		return ""
	}
	st, err := lc.Status(context.Background())
	if err != nil {
		return ""
	}
	for _, pfx := range st.TailscaleIPs {
		if pfx.Is4() {
			addr := netip.AddrFrom4(pfx.As4())
			return addr.String()
		}
	}
	return ""
}

// Close shuts down the node.
func (n *Node) Close() error {
	return n.srv.Close()
}
```

---

## File 6: backend/go.mod

```
module dragonrelay

go 1.26.1

require (
	github.com/gin-gonic/gin v1.12.0
	github.com/grandcat/zeroconf v1.0.0
	tailscale.com v1.96.5
	github.com/golang-jwt/jwt/v5 v5.2.1
	github.com/mattn/go-sqlite3 v1.14.22
	github.com/google/uuid v1.6.0
	golang.org/x/crypto v0.48.0
)

require (
	filippo.io/edwards25519 v1.2.0 // indirect
	github.com/akutz/memconn v0.1.0 // indirect
	github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
	github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
	github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect
	github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect
	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
	github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect
	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
	github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
	github.com/aws/smithy-go v1.24.0 // indirect
	github.com/bytedance/gopkg v0.1.3 // indirect
	github.com/bytedance/sonic v1.15.0 // indirect
	github.com/bytedance/sonic/loader v0.5.0 // indirect
	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
	github.com/cloudwego/base64x v0.1.6 // indirect
	github.com/coder/websocket v1.8.12 // indirect
	github.com/creachadair/msync v0.7.1 // indirect
	github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
	github.com/gaissmai/bart v0.26.1 // indirect
	github.com/gin-contrib/sse v1.1.0 // indirect
	github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.30.1 // indirect
	github.com/goccy/go-json v0.10.5 // indirect
	github.com/goccy/go-yaml v1.19.2 // indirect
	github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
	github.com/google/btree v1.1.3 // indirect
	github.com/google/go-cmp v0.7.0 // indirect
	github.com/hdevalence/ed25519consensus v0.2.0 // indirect
	github.com/huin/goupnp v1.3.0 // indirect
	github.com/jsimonetti/rtnetlink v1.4.0 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/compress v1.18.2 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
	github.com/mdlayher/socket v0.5.0 // indirect
	github.com/miekg/dns v1.1.58 // indirect
	github.com/mitchellh/go-ps v1.0.0 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/pires/go-proxyproto v0.8.1 // indirect
	github.com/prometheus-community/pro-bing v0.4.0 // indirect
	github.com/quic-go/qpack v0.6.0 // indirect
	github.com/quic-go/quic-go v0.59.0 // indirect
	github.com/safchain/ethtool v0.3.0 // indirect
	github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
	github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
	github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
	github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
	github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
	github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.3.1 // indirect
	github.com/x448/float16 v0.8.4 // indirect
	go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
	go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
	golang.org/x/arch v0.22.0 // indirect
	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
	golang.org/x/mod v0.32.0 // indirect
	golang.org/x/net v0.51.0 // indirect
	golang.org/x/oauth2 v0.33.0 // indirect
	golang.org/x/sync v0.19.0 // indirect
	golang.org/x/sys v0.41.0 // indirect
	golang.org/x/term v0.40.0 // indirect
	golang.org/x/text v0.34.0 // indirect
	golang.org/x/time v0.12.0 // indirect
	golang.org/x/tools v0.41.0 // indirect
	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
	golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
	google.golang.org/protobuf v1.36.11 // indirect
	gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
)
```

---

## Summary

All requested files have been retrieved:
- **backend/internal/db/db.go** - Complete SQLite user store implementation with user management, host ACLs, and invite system
- **backend/internal/discovery/discovery.go** - mDNS-based host discovery for Apollo/Artemis streaming hosts
- **backend/internal/moonlight/launcher.go** - Moonlight client launcher for streaming sessions
- **backend/internal/moonlight/launcher_test.go** - Tests for the Moonlight launcher
- **backend/internal/tailscale/tailscale.go** - Embedded Tailscale node management via tsnet
- **backend/go.mod** - Go module dependencies (go 1.26.1, main deps: Gin, Tailscale, zeroconf, JWT, SQLite, UUID, crypto)

Note: There is no separate `backend/internal/moonlight/moonlight.go` file; the moonlight package is implemented via `launcher.go`.