mirror of
https://github.com/tronbyt/server.git
synced 2026-08-31 06:57:10 +02:00
refactor(cmd): add cobra cli libary (#789)
This commit is contained in:
@@ -45,12 +45,12 @@ The core functionality involves serving WebP images to devices, generated by ren
|
||||
1. **Dependencies:** Ensure Go 1.26+ and C build tools (gcc) are installed.
|
||||
2. **Run with `go run` (for development):**
|
||||
```bash
|
||||
go run ./cmd/server -db data/tronbyt.db -data .
|
||||
go run ./cmd/server --db data/tronbyt.db --data .
|
||||
```
|
||||
3. **Build and Run (for production/deployment):**
|
||||
```bash
|
||||
go build -o tronbyt-server ./cmd/server
|
||||
./tronbyt-server -db data/tronbyt.db -data .
|
||||
./tronbyt-server --db data/tronbyt.db --data .
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
+12
-6
@@ -40,9 +40,17 @@ ARG BUILD_DATE=unknown
|
||||
# Build all Go binaries in a single layer
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 xx-go build -ldflags="-w -s -extldflags '-static'" -o boot ./cmd/boot && \
|
||||
CGO_ENABLED=1 xx-go build -ldflags="-w -s -extldflags '-static' -X 'tronbyt-server/internal/version.Version=${VERSION}' -X 'tronbyt-server/internal/version.Commit=${COMMIT}' -X 'tronbyt-server/internal/version.BuildDate=${BUILD_DATE}'" -tags gzip_fonts -o tronbyt-server ./cmd/server && \
|
||||
CGO_ENABLED=1 xx-go build -ldflags="-w -s -extldflags '-static'" -o migrate ./cmd/migrate
|
||||
set -x \
|
||||
&& CGO_ENABLED=1 xx-go build \
|
||||
-ldflags="-w -s -extldflags '-static' -X 'tronbyt-server/internal/version.Version=${VERSION}' -X 'tronbyt-server/internal/version.Commit=${COMMIT}' -X 'tronbyt-server/internal/version.BuildDate=${BUILD_DATE}'" \
|
||||
-tags gzip_fonts \
|
||||
-o build/app/tronbyt-server \
|
||||
./cmd/server
|
||||
|
||||
WORKDIR /app/build
|
||||
|
||||
RUN ln -s /app/tronbyt-server boot \
|
||||
&& ln -s /app/tronbyt-server app/migrate
|
||||
|
||||
# --- Runtime Stage ---
|
||||
FROM scratch
|
||||
@@ -53,9 +61,7 @@ WORKDIR /app
|
||||
COPY --from=build-production /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# Copy compiled binaries from builder
|
||||
COPY --from=build-production /app/boot /boot
|
||||
COPY --from=build-production /app/tronbyt-server /app/tronbyt-server
|
||||
COPY --from=build-production /app/migrate /app/migrate
|
||||
COPY --from=build-production /app/build /
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -46,7 +46,4 @@ if ($LASTEXITCODE -ne 0) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 5. Build 'migrate' (Database tool)
|
||||
go build -ldflags="-w -s" -o migrate.exe ./cmd/migrate
|
||||
|
||||
Write-Host "Build Complete!" -ForegroundColor Green
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func dropPrivileges(uid, gid int) {
|
||||
if err := syscall.Setgroups([]int{gid}); err != nil {
|
||||
slog.Error("Failed to set groups", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := syscall.Setgid(gid); err != nil {
|
||||
slog.Error("Failed to set gid", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := syscall.Setuid(uid); err != nil {
|
||||
slog.Error("Failed to set uid", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"tronbyt-server/internal/migration"
|
||||
)
|
||||
|
||||
func main() {
|
||||
oldDBPath := flag.String("old", "users/tronbyt.db", "Path to legacy SQLite database")
|
||||
newDBPath := flag.String("new", "data/tronbyt.db", "Path to new GORM database (or DSN)")
|
||||
dataDir := flag.String("data", "data", "Path to data directory for files")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if err := migration.MigrateLegacyDB(*oldDBPath, *newDBPath, *dataDir); err != nil {
|
||||
slog.Error("Database migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,34 @@
|
||||
package main
|
||||
package boot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
uid := 1000
|
||||
gid := 1000
|
||||
const Name = "boot"
|
||||
|
||||
func New() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: Name,
|
||||
Short: "Boot wrapper",
|
||||
RunE: run,
|
||||
|
||||
SilenceUsage: true,
|
||||
}
|
||||
cmd.Hidden = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(_ *cobra.Command, args []string) error {
|
||||
uid, gid := 1000, 1000
|
||||
|
||||
if s := os.Getenv("PUID"); s != "" {
|
||||
if i, err := strconv.Atoi(s); err != nil {
|
||||
@@ -20,6 +37,7 @@ func main() {
|
||||
uid = i
|
||||
}
|
||||
}
|
||||
|
||||
if s := os.Getenv("PGID"); s != "" {
|
||||
if i, err := strconv.Atoi(s); err != nil {
|
||||
slog.Warn("Invalid PGID value, using default", "pgid", s, "error", err)
|
||||
@@ -70,25 +88,25 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
dropPrivileges(uid, gid)
|
||||
if err := dropPrivileges(uid, gid); err != nil {
|
||||
return fmt.Errorf("failed to drop privileges: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
slog.Error("No command provided to boot wrapper")
|
||||
os.Exit(1)
|
||||
if len(args) == 0 {
|
||||
return errors.New("no command provided to boot wrapper")
|
||||
}
|
||||
|
||||
cmdName := os.Args[1]
|
||||
cmdArgs := os.Args[1:]
|
||||
cmdName := args[0]
|
||||
cmdArgs := args[0:]
|
||||
|
||||
cmdPath, err := exec.LookPath(cmdName)
|
||||
if err != nil {
|
||||
slog.Error("Command not found", "cmd", cmdName, "error", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("%s: command not found: %w", cmdName, err)
|
||||
}
|
||||
|
||||
if err := syscall.Exec(cmdPath, cmdArgs, os.Environ()); err != nil {
|
||||
slog.Error("Failed to exec command", "cmd", cmdName, "error", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("%s: exec: %w", cmdName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package boot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func dropPrivileges(uid, gid int) error {
|
||||
if err := syscall.Setgroups([]int{gid}); err != nil {
|
||||
return fmt.Errorf("failed to set groups: %w", err)
|
||||
}
|
||||
if err := syscall.Setgid(gid); err != nil {
|
||||
return fmt.Errorf("failed to set gid: %w", err)
|
||||
}
|
||||
if err := syscall.Setuid(uid); err != nil {
|
||||
return fmt.Errorf("failed to set uid: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
package boot
|
||||
|
||||
func dropPrivileges(uid, gid int) {
|
||||
func dropPrivileges(uid, gid int) error {
|
||||
// No-op on Windows - privilege management works differently
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"tronbyt-server/cmd/server/boot"
|
||||
"tronbyt-server/cmd/server/health"
|
||||
"tronbyt-server/cmd/server/migrate"
|
||||
"tronbyt-server/cmd/server/resetpassword"
|
||||
"tronbyt-server/cmd/server/serve"
|
||||
"tronbyt-server/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func New() *cobra.Command {
|
||||
cobra.MousetrapHelpText = ""
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "tronbyt-server",
|
||||
Short: "Manage your apps on your Tronbyt completely locally",
|
||||
PersistentPreRunE: preRun,
|
||||
}
|
||||
|
||||
fs := cmd.PersistentFlags()
|
||||
fs.String(config.FlagDB, "data/tronbyt.db", "Database DSN (sqlite file path or connection string)")
|
||||
fs.String(config.FlagData, "data", "Path to data directory")
|
||||
|
||||
cmd.AddCommand(
|
||||
boot.New(),
|
||||
serve.New(),
|
||||
migrate.New(),
|
||||
resetpassword.New(),
|
||||
health.New(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func preRun(cmd *cobra.Command, _ []string) error {
|
||||
// Initialize slog before anything else that might log
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), nil)))
|
||||
|
||||
// Load configuration early to get default DB path
|
||||
cfg, err := config.LoadSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
fs := cmd.Flags()
|
||||
if f := fs.Lookup(config.FlagDB); f != nil && f.Changed {
|
||||
cfg.DBDSN = f.Value.String()
|
||||
}
|
||||
if f := fs.Lookup(config.FlagData); f != nil && f.Changed {
|
||||
cfg.DataDir = f.Value.String()
|
||||
}
|
||||
|
||||
// Re-initialize logger with configured log level
|
||||
var level slog.Level
|
||||
cfg.LogLevel = strings.ToUpper(cfg.LogLevel)
|
||||
cfg.LogLevel = strings.Replace(cfg.LogLevel, "WARNING", "WARN", 1)
|
||||
if err := level.UnmarshalText([]byte(cfg.LogLevel)); err != nil {
|
||||
slog.Warn("Invalid LOG_LEVEL, defaulting to INFO", "level", cfg.LogLevel)
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
// Create handler options with the parsed level
|
||||
loggerHandlerOpts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
var logHandler slog.Handler
|
||||
if cfg.LogFormat == "json" {
|
||||
logHandler = slog.NewJSONHandler(cmd.ErrOrStderr(), loggerHandlerOpts)
|
||||
} else {
|
||||
logHandler = slog.NewTextHandler(cmd.ErrOrStderr(), loggerHandlerOpts)
|
||||
}
|
||||
slog.SetDefault(slog.New(logHandler))
|
||||
|
||||
slog.Debug("Logger initialized", "level", level)
|
||||
|
||||
cmd.SetContext(config.NewContext(cmd.Context(), cfg))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func New() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "health [url]",
|
||||
Short: "Perform a health check",
|
||||
RunE: run,
|
||||
|
||||
SilenceUsage: true,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
url := "http://localhost:8000/health"
|
||||
if len(args) > 0 {
|
||||
url = args[0]
|
||||
}
|
||||
|
||||
if err := runHealthCheck(cmd.Context(), url); err != nil {
|
||||
return fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHealthCheck(ctx context.Context, url string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+21
-477
@@ -1,496 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
"tronbyt-server/cmd/server/boot"
|
||||
"tronbyt-server/cmd/server/migrate"
|
||||
"tronbyt-server/cmd/server/serve"
|
||||
|
||||
"tronbyt-server/internal/auth"
|
||||
"tronbyt-server/internal/config"
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/gitutils"
|
||||
"tronbyt-server/internal/migration"
|
||||
"tronbyt-server/internal/server"
|
||||
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
"github.com/tronbyt/pixlet/runtime"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/plugin/prometheus"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func runHealthCheck(url string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
slog.Error("Failed to close health check response body", "error", err)
|
||||
}
|
||||
}()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func openDB(dsn, logLevel string) (*gorm.DB, error) {
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
|
||||
var gormLogLevel logger.LogLevel
|
||||
switch strings.ToUpper(logLevel) {
|
||||
case "DEBUG":
|
||||
gormLogLevel = logger.Info // GORM Info includes SQL queries
|
||||
case "INFO":
|
||||
gormLogLevel = logger.Warn
|
||||
case "WARN", "WARNING":
|
||||
gormLogLevel = logger.Error
|
||||
case "ERROR":
|
||||
gormLogLevel = logger.Error
|
||||
default:
|
||||
gormLogLevel = logger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: data.NewGORMSlogLogger(gormLogLevel, 200*time.Millisecond, true),
|
||||
PrepareStmt: false, // Disable prepared statement caching to avoid SQLite locking issues
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres") || strings.Contains(dsn, "host=") {
|
||||
slog.Info("Using Postgres DB")
|
||||
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
||||
} else if strings.Contains(dsn, "@tcp(") || strings.Contains(dsn, "@unix(") {
|
||||
slog.Info("Using MySQL DB")
|
||||
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
} else {
|
||||
slog.Info("Using SQLite DB", "path", dsn)
|
||||
db, err = gorm.Open(sqlite.Open(dsn), gormConfig)
|
||||
if err == nil {
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
|
||||
slog.Warn("Failed to set WAL mode for SQLite", "error", err)
|
||||
}
|
||||
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
|
||||
slog.Warn("Failed to set busy timeout for SQLite", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if err := db.Use(prometheus.New(prometheus.Config{
|
||||
DBName: "tronbyt",
|
||||
RefreshInterval: 15,
|
||||
StartServer: false,
|
||||
HTTPServerPort: 8080,
|
||||
})); err != nil {
|
||||
slog.Warn("Failed to register GORM prometheus plugin", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, err
|
||||
}
|
||||
|
||||
func resetPassword(dsn, username, password string) error {
|
||||
db, err := openDB(dsn, "INFO")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hashedPassword, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := gorm.G[data.User](db).Where("username = ?", username).Update(context.Background(), "password", hashedPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeDB(db *gorm.DB) {
|
||||
// Sanitize data before migration (fixes v2.0.x empty email constraint issue)
|
||||
if db.Migrator().HasTable(&data.User{}) {
|
||||
if _, err := gorm.G[data.User](db).Where("email IN ?", []string{"", "none"}).Update(context.Background(), "email", nil); err != nil {
|
||||
slog.Warn("Failed to sanitize empty emails", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix timezone issues
|
||||
if db.Migrator().HasTable(&data.Device{}) {
|
||||
devices, err := gorm.G[data.Device](db).Where("location LIKE '%\"timezone\":\"None\"'").Find(context.Background())
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get devices with illegal timestamps", "error", err)
|
||||
} else {
|
||||
for _, device := range devices {
|
||||
device.Location.Timezone = ""
|
||||
if _, err := gorm.G[data.Device](db).Where("id = ?", device.ID).Update(context.Background(), "location", device.Location); err != nil {
|
||||
slog.Warn("Failed to update device location during sanitization", "device_id", device.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initialize slog before anything else that might log
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Load configuration early to get default DB path
|
||||
cfg, err := config.LoadSettings()
|
||||
if err != nil {
|
||||
slog.Error("Failed to load settings", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Re-initialize logger with configured log level
|
||||
var level slog.Level
|
||||
switch strings.ToUpper(cfg.LogLevel) {
|
||||
case "DEBUG":
|
||||
level = slog.LevelDebug
|
||||
case "INFO":
|
||||
level = slog.LevelInfo
|
||||
case "WARN", "WARNING":
|
||||
level = slog.LevelWarn
|
||||
case "ERROR":
|
||||
level = slog.LevelError
|
||||
// Determine the root command
|
||||
var root *cobra.Command
|
||||
switch filepath.Base(os.Args[0]) {
|
||||
case boot.Name:
|
||||
root = boot.New()
|
||||
case migrate.Name:
|
||||
root = migrate.New()
|
||||
root.PreRunE = preRun
|
||||
default:
|
||||
level = slog.LevelInfo
|
||||
slog.Warn("Invalid LOG_LEVEL, defaulting to INFO", "level", cfg.LogLevel)
|
||||
}
|
||||
|
||||
// Create handler options with the parsed level
|
||||
loggerHandlerOpts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
var logHandler slog.Handler
|
||||
if cfg.LogFormat == "json" {
|
||||
logHandler = slog.NewJSONHandler(os.Stdout, loggerHandlerOpts)
|
||||
} else {
|
||||
logHandler = slog.NewTextHandler(os.Stdout, loggerHandlerOpts)
|
||||
}
|
||||
logger = slog.New(logHandler)
|
||||
slog.SetDefault(logger)
|
||||
slog.Debug("Logger initialized", "level", level)
|
||||
|
||||
dbDSN := flag.String("db", cfg.DBDSN, "Database DSN (sqlite file path or connection string)")
|
||||
dataDir := flag.String("data", cfg.DataDir, "Path to data directory")
|
||||
flag.Parse()
|
||||
|
||||
// Update config with flag values
|
||||
cfg.DataDir = *dataDir
|
||||
cfg.DBDSN = *dbDSN
|
||||
|
||||
if len(flag.Args()) > 0 {
|
||||
cmd := flag.Arg(0)
|
||||
switch cmd {
|
||||
case "health":
|
||||
url := "http://localhost:8000/health"
|
||||
if len(flag.Args()) > 1 {
|
||||
url = flag.Arg(1)
|
||||
}
|
||||
if err := runHealthCheck(url); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
case "reset-password":
|
||||
if len(flag.Args()) < 3 {
|
||||
fmt.Println("Usage: tronbyt-server reset-password <username> <new_password>")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := flag.Arg(1)
|
||||
password := flag.Arg(2)
|
||||
if err := resetPassword(*dbDSN, username, password); err != nil {
|
||||
slog.Error("Failed to reset password", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("Password reset successfully")
|
||||
os.Exit(0)
|
||||
root = New()
|
||||
subCmd, _, err := root.Find(os.Args[1:])
|
||||
// Run serve if no command is given
|
||||
if err == nil && subCmd.Use == root.Use && subCmd.Flags().Parse(os.Args[1:]) != pflag.ErrHelp {
|
||||
root.SetArgs(append([]string{serve.Name}, os.Args[1:]...))
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||
slog.Error("Failed to create data directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
root.SilenceErrors = true
|
||||
|
||||
// Check for legacy DB for automatic migration
|
||||
legacyDBPath := filepath.Join("users", "usersdb.sqlite") // Old Python DB path
|
||||
if _, err := os.Stat(legacyDBPath); err == nil && legacyDBPath != *dbDSN {
|
||||
slog.Info("Found legacy database, checking if migration is needed", "legacy_db", legacyDBPath, "new_db", *dbDSN)
|
||||
|
||||
skipMigration := false
|
||||
tempDB, err := openDB(*dbDSN, "ERROR")
|
||||
if err == nil {
|
||||
if tempDB.Migrator().HasTable(&data.User{}) {
|
||||
count, err := gorm.G[data.User](tempDB).Count(context.Background(), "*")
|
||||
if err == nil && count > 0 {
|
||||
skipMigration = true
|
||||
slog.Warn("New database already has users, skipping automatic migration.", "new_db", *dbDSN)
|
||||
}
|
||||
}
|
||||
if sqlDB, err := tempDB.DB(); err == nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
slog.Error("Failed to close temporary DB connection", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !skipMigration {
|
||||
// Perform migration
|
||||
if err := migration.MigrateLegacyDB(legacyDBPath, *dbDSN, *dataDir); err != nil {
|
||||
slog.Error("Automatic migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("Automatic migration completed successfully. Renaming legacy DB.", "legacy_db", legacyDBPath)
|
||||
if err := os.Rename(legacyDBPath, legacyDBPath+".bak"); err != nil {
|
||||
slog.Error("Failed to rename legacy DB after migration", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone/Update System Apps Repo
|
||||
systemAppsDir := filepath.Join(*dataDir, "system-apps")
|
||||
shouldUpdate := cfg.Production
|
||||
if err := gitutils.EnsureRepo(systemAppsDir, cfg.SystemAppsRepo, cfg.GitHubToken, shouldUpdate); err != nil {
|
||||
slog.Error("Failed to update system apps repo", "error", err)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Open DB
|
||||
db, err := openDB(*dbDSN, cfg.LogLevel)
|
||||
if err != nil {
|
||||
slog.Error("Failed to open database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Sanitize data
|
||||
sanitizeDB(db)
|
||||
|
||||
// AutoMigrate (ensure schema exists)
|
||||
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil {
|
||||
slog.Error("Failed to migrate schema", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize Pixlet Cache
|
||||
var cache runtime.Cache
|
||||
if cfg.RedisURL != "" {
|
||||
slog.Info("Initializing Pixlet Redis cache", "url", cfg.RedisURL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
var err error
|
||||
cache, err = runtime.NewRedisCache(ctx, cfg.RedisURL)
|
||||
if err != nil {
|
||||
slog.Error("Failed to connect to Redis", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cancel()
|
||||
} else {
|
||||
slog.Info("Initializing Pixlet in-memory cache")
|
||||
cache = runtime.NewInMemoryCache()
|
||||
}
|
||||
defer cache.Close()
|
||||
runtime.InitHTTP(cache)
|
||||
runtime.InitCache(cache)
|
||||
|
||||
srv := server.NewServer(db, cfg)
|
||||
|
||||
// Firmware Update (production only)
|
||||
if cfg.Production {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("Panic during background firmware update", "panic", r)
|
||||
}
|
||||
}()
|
||||
if err := srv.UpdateFirmwareBinaries(); err != nil {
|
||||
slog.Error("Failed to update firmware binaries in background", "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
slog.Info("Skipping firmware update (dev mode)")
|
||||
}
|
||||
|
||||
// Single User Auto-Login Warning
|
||||
userCount, err := gorm.G[data.User](db).Count(context.Background(), "*")
|
||||
if err != nil {
|
||||
slog.Error("Failed to count users for auto-login warning", "error", err)
|
||||
} else if cfg.SingleUserAutoLogin && userCount == 1 {
|
||||
slog.Warn(`
|
||||
======================================================================
|
||||
⚠️ SINGLE-USER AUTO-LOGIN MODE IS ENABLED
|
||||
======================================================================
|
||||
Authentication is DISABLED for private network connections!
|
||||
|
||||
This mode automatically logs in the single user without password.
|
||||
|
||||
SECURITY REQUIREMENTS:
|
||||
✓ Only works when exactly 1 user exists
|
||||
✓ Only works from trusted networks:
|
||||
- Localhost (127.0.0.1, ::1)
|
||||
- Private IPv4 networks (192.168.x.x, 10.x.x.x, 172.16.x.x)
|
||||
- IPv6 local ranges (Unique Local Addresses fc00::/7, commonly fd00::/8)
|
||||
- IPv6 link-local (fe80::/10)
|
||||
✓ Public IP connections still require authentication
|
||||
|
||||
To disable: Set SINGLE_USER_AUTO_LOGIN=0 in your .env file
|
||||
======================================================================`)
|
||||
}
|
||||
|
||||
// Configure Listeners
|
||||
var listeners []net.Listener
|
||||
|
||||
// TCP
|
||||
if cfg.Host != "" || cfg.Port != "" {
|
||||
addr := net.JoinHostPort(cfg.Host, cfg.Port)
|
||||
l, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
slog.Error("Failed to listen on TCP", "addr", addr, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
slog.Info("Listening on TCP", "addr", addr)
|
||||
}
|
||||
|
||||
// Unix Socket
|
||||
if cfg.UnixSocket != "" {
|
||||
if err := os.RemoveAll(cfg.UnixSocket); err != nil {
|
||||
slog.Warn("Failed to remove old socket", "error", err)
|
||||
}
|
||||
l, err := net.Listen("unix", cfg.UnixSocket)
|
||||
if err != nil {
|
||||
slog.Error("Failed to listen on Unix socket", "path", cfg.UnixSocket, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.Chmod(cfg.UnixSocket, 0666); err != nil {
|
||||
slog.Warn("Failed to set socket permissions", "error", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
slog.Info("Listening on Unix socket", "path", cfg.UnixSocket)
|
||||
}
|
||||
|
||||
if len(listeners) == 0 {
|
||||
slog.Error("No listeners configured")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var handler http.Handler = srv
|
||||
|
||||
// Determine number of servers to start and create error channel
|
||||
numServers := len(listeners)
|
||||
http3Enabled := cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" && (cfg.Host != "" || cfg.Port != "")
|
||||
if http3Enabled {
|
||||
numServers++
|
||||
}
|
||||
errCh := make(chan error, numServers)
|
||||
|
||||
// TLS Config (Shared)
|
||||
var tlsConfig *tls.Config
|
||||
if cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.SSLCertFile, cfg.SSLKeyFile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to load TLS certificates", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
slog.Info("TLS certificates loaded", "cert", cfg.SSLCertFile, "key", cfg.SSLKeyFile)
|
||||
}
|
||||
|
||||
// HTTP/3 (QUIC) Support
|
||||
if http3Enabled {
|
||||
if tlsConfig == nil {
|
||||
// This should practically not happen due to http3Enabled check above,
|
||||
// but good for safety if logic changes.
|
||||
slog.Error("HTTP/3 enabled but TLS config is nil")
|
||||
os.Exit(1)
|
||||
}
|
||||
addr := net.JoinHostPort(cfg.Host, cfg.Port)
|
||||
h3Srv := &http3.Server{
|
||||
Addr: addr,
|
||||
Handler: srv,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Serving HTTP/3 (QUIC)", "addr", addr)
|
||||
err := h3Srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("HTTP/3 server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wrap handler to set Alt-Svc header for TCP connections
|
||||
baseHandler := handler
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine the external port to advertise in Alt-Svc.
|
||||
// Use GetBaseURL to handle X-Forwarded-Host/Proto respecting logic centrally.
|
||||
baseURL := srv.GetBaseURL(r)
|
||||
u, err := url.Parse(baseURL)
|
||||
var port string
|
||||
if err == nil {
|
||||
port = u.Port()
|
||||
}
|
||||
|
||||
if port == "" {
|
||||
// No port in URL, use default based on scheme
|
||||
// HTTP/3 implies HTTPS, so 443.
|
||||
port = "443"
|
||||
}
|
||||
|
||||
w.Header().Set("Alt-Svc", fmt.Sprintf(`h3=":%s"; ma=2592000`, port))
|
||||
baseHandler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadTimeout: 25 * time.Second,
|
||||
WriteTimeout: 25 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("Tronbyt Server starting", "db", *dbDSN, "dataDir", *dataDir)
|
||||
|
||||
for _, l := range listeners {
|
||||
go func(l net.Listener) {
|
||||
var err error
|
||||
if tlsConfig != nil {
|
||||
// Wrap listener with TLS if config is present
|
||||
l = tls.NewListener(l, tlsConfig)
|
||||
}
|
||||
err = httpSrv.Serve(l)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}(l)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
slog.Error("Server failed", "error", err)
|
||||
if err := root.Execute(); err != nil {
|
||||
slog.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"tronbyt-server/internal/config"
|
||||
|
||||
"tronbyt-server/internal/migration"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "migrate"
|
||||
flagOldDBPath = "old"
|
||||
flagNewDBPath = "new"
|
||||
)
|
||||
|
||||
func New() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: Name,
|
||||
Short: "Migrate legacy SQLite database to GORM database",
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
fs := cmd.Flags()
|
||||
fs.String(flagOldDBPath, "users/tronbyt.db", "Path to legacy SQLite database")
|
||||
fs.String(flagNewDBPath, "", "Path to new GORM database (or DSN)")
|
||||
_ = fs.MarkHidden(flagNewDBPath)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := config.FromContext(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
oldDBPath := cmd.Flag(flagOldDBPath).Value.String()
|
||||
if newDBPath := cmd.Flag(flagNewDBPath).Value.String(); newDBPath != "" {
|
||||
cfg.DBDSN = newDBPath
|
||||
}
|
||||
|
||||
if err := migration.MigrateLegacyDB(oldDBPath, cfg.DBDSN, cfg.DataDir); err != nil {
|
||||
return fmt.Errorf("database migration failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package resetpassword
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"tronbyt-server/internal/auth"
|
||||
"tronbyt-server/internal/config"
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func New() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "reset-password username new_password",
|
||||
Short: "Reset a user password",
|
||||
RunE: run,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.FromContext(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
username := args[0]
|
||||
password := args[1]
|
||||
|
||||
if err := resetPassword(cmd.Context(), cfg.DBDSN, username, password); err != nil {
|
||||
slog.Error("Failed to reset password", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("Password reset successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetPassword(ctx context.Context, dsn, username, password string) error {
|
||||
db, err := data.Open(dsn, "INFO")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hashedPassword, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := gorm.G[data.User](db).Where("username = ?", username).Update(ctx, "password", hashedPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/tronbyt/pixlet/runtime"
|
||||
)
|
||||
|
||||
func initCache(redisURL string) (runtime.Cache, error) {
|
||||
// Initialize Pixlet Cache
|
||||
var cache runtime.Cache
|
||||
var err error
|
||||
|
||||
if redisURL != "" {
|
||||
slog.Info("Initializing Pixlet Redis cache", "url", redisURL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if cache, err = runtime.NewRedisCache(ctx, redisURL); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("Initializing Pixlet in-memory cache")
|
||||
cache = runtime.NewInMemoryCache()
|
||||
}
|
||||
|
||||
runtime.InitHTTP(cache)
|
||||
runtime.InitCache(cache)
|
||||
return cache, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"tronbyt-server/internal/config"
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/gitutils"
|
||||
"tronbyt-server/internal/server"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const Name = "serve"
|
||||
|
||||
func New() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: Name,
|
||||
Short: "Run Tronbyt server",
|
||||
RunE: run,
|
||||
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.FromContext(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
if err := migrateLegacyDB(cmd.Context(), cfg.DBDSN, cfg.DataDir); err != nil {
|
||||
return fmt.Errorf("failed to migrate legacy database: %w", err)
|
||||
}
|
||||
|
||||
// Clone/Update System Apps Repo
|
||||
if err := gitutils.EnsureRepo(cfg.SystemAppsDir(), cfg.SystemAppsRepo, cfg.GitHubToken, cfg.Production); err != nil {
|
||||
slog.Error("Failed to update system apps repo", "error", err)
|
||||
}
|
||||
|
||||
// Open DB
|
||||
db, err := data.Open(cfg.DBDSN, cfg.LogLevel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Sanitize data
|
||||
sanitizeDB(cmd.Context(), db)
|
||||
|
||||
// AutoMigrate (ensure schema exists)
|
||||
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil {
|
||||
return fmt.Errorf("failed to migrate schema: %w", err)
|
||||
}
|
||||
|
||||
cache, err := initCache(cfg.RedisURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize cache: %w", err)
|
||||
}
|
||||
defer cache.Close()
|
||||
|
||||
srv := server.NewServer(db, cfg)
|
||||
|
||||
// Firmware Update (production only)
|
||||
if cfg.Production {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("Panic during background firmware update", "panic", r)
|
||||
}
|
||||
}()
|
||||
if err := srv.UpdateFirmwareBinaries(); err != nil {
|
||||
slog.Error("Failed to update firmware binaries in background", "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
slog.Info("Skipping firmware update (dev mode)")
|
||||
}
|
||||
|
||||
singleUserWarning(cmd.Context(), db, cfg.SingleUserAutoLogin)
|
||||
|
||||
return serve(cfg, srv)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/migration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func migrateLegacyDB(ctx context.Context, dsn, dataDir string) error {
|
||||
// Check for legacy DB for automatic migration
|
||||
legacyDBPath := filepath.Join("users", "usersdb.sqlite") // Old Python DB path
|
||||
if _, err := os.Stat(legacyDBPath); err == nil && legacyDBPath != dsn {
|
||||
slog.Info("Found legacy database, checking if migration is needed", "legacy_db", legacyDBPath, "new_db", dsn)
|
||||
|
||||
tempDB, err := data.Open(dsn, "ERROR")
|
||||
if err == nil {
|
||||
if tempDB.Migrator().HasTable(&data.User{}) {
|
||||
count, err := gorm.G[data.User](tempDB).Count(ctx, "*")
|
||||
if err == nil && count > 0 {
|
||||
slog.Warn("New database already has users, skipping automatic migration.", "new_db", dsn)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if sqlDB, err := tempDB.DB(); err == nil {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
slog.Error("Failed to close temporary DB connection", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
if err := migration.MigrateLegacyDB(legacyDBPath, dsn, dataDir); err != nil {
|
||||
return fmt.Errorf("automatic migration failed: %w", err)
|
||||
}
|
||||
slog.Info("Automatic migration completed successfully. Renaming legacy DB.", "legacy_db", legacyDBPath)
|
||||
if err := os.Rename(legacyDBPath, legacyDBPath+".bak"); err != nil {
|
||||
slog.Error("Failed to rename legacy DB after migration", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeDB(ctx context.Context, db *gorm.DB) {
|
||||
// Sanitize data before migration (fixes v2.0.x empty email constraint issue)
|
||||
if db.Migrator().HasTable(&data.User{}) {
|
||||
if _, err := gorm.G[data.User](db).Where("email IN ?", []string{"", "none"}).Update(ctx, "email", nil); err != nil {
|
||||
slog.Warn("Failed to sanitize empty emails", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix timezone issues
|
||||
if db.Migrator().HasTable(&data.Device{}) {
|
||||
devices, err := gorm.G[data.Device](db).Where("location LIKE '%\"timezone\":\"None\"'").Find(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get devices with illegal timestamps", "error", err)
|
||||
} else {
|
||||
for _, device := range devices {
|
||||
device.Location.Timezone = ""
|
||||
if _, err := gorm.G[data.Device](db).Where("id = ?", device.ID).Update(ctx, "location", device.Location); err != nil {
|
||||
slog.Warn("Failed to update device location during sanitization", "device_id", device.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func singleUserWarning(ctx context.Context, db *gorm.DB, singleUserAutoLogin bool) {
|
||||
// Single User Auto-Login Warning
|
||||
userCount, err := gorm.G[data.User](db).Count(ctx, "*")
|
||||
if err != nil {
|
||||
slog.Error("Failed to count users for auto-login warning", "error", err)
|
||||
} else if singleUserAutoLogin && userCount == 1 {
|
||||
slog.Warn(`
|
||||
======================================================================
|
||||
⚠️ SINGLE-USER AUTO-LOGIN MODE IS ENABLED
|
||||
======================================================================
|
||||
Authentication is DISABLED for private network connections!
|
||||
|
||||
This mode automatically logs in the single user without password.
|
||||
|
||||
SECURITY REQUIREMENTS:
|
||||
✓ Only works when exactly 1 user exists
|
||||
✓ Only works from trusted networks:
|
||||
- Localhost (127.0.0.1, ::1)
|
||||
- Private IPv4 networks (192.168.x.x, 10.x.x.x, 172.16.x.x)
|
||||
- IPv6 local ranges (Unique Local Addresses fc00::/7, commonly fd00::/8)
|
||||
- IPv6 link-local (fe80::/10)
|
||||
✓ Public IP connections still require authentication
|
||||
|
||||
To disable: Set SINGLE_USER_AUTO_LOGIN=0 in your .env file
|
||||
======================================================================`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
"tronbyt-server/internal/config"
|
||||
"tronbyt-server/internal/server"
|
||||
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
)
|
||||
|
||||
func serve(cfg *config.Settings, srv *server.Server) error {
|
||||
// Configure Listeners
|
||||
var listeners []net.Listener
|
||||
|
||||
// TCP
|
||||
if cfg.Host != "" || cfg.Port != "" {
|
||||
addr := net.JoinHostPort(cfg.Host, cfg.Port)
|
||||
l, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on TCP %s: %w", addr, err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
slog.Info("Listening on TCP", "addr", addr)
|
||||
}
|
||||
|
||||
// Unix Socket
|
||||
if cfg.UnixSocket != "" {
|
||||
if err := os.RemoveAll(cfg.UnixSocket); err != nil {
|
||||
slog.Warn("Failed to remove old socket", "error", err)
|
||||
}
|
||||
l, err := net.Listen("unix", cfg.UnixSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on Unix socket %s: %w", cfg.UnixSocket, err)
|
||||
}
|
||||
if err := os.Chmod(cfg.UnixSocket, 0666); err != nil {
|
||||
slog.Warn("Failed to set socket permissions", "error", err)
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
slog.Info("Listening on Unix socket", "path", cfg.UnixSocket)
|
||||
}
|
||||
|
||||
if len(listeners) == 0 {
|
||||
return fmt.Errorf("no listeners configured")
|
||||
}
|
||||
|
||||
var handler http.Handler = srv
|
||||
|
||||
// Determine number of servers to start and create error channel
|
||||
numServers := len(listeners)
|
||||
http3Enabled := cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" && (cfg.Host != "" || cfg.Port != "")
|
||||
if http3Enabled {
|
||||
numServers++
|
||||
}
|
||||
errCh := make(chan error, numServers)
|
||||
|
||||
// TLS Config (Shared)
|
||||
var tlsConfig *tls.Config
|
||||
if cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.SSLCertFile, cfg.SSLKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load TLS certificates: %w", err)
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
slog.Info("TLS certificates loaded", "cert", cfg.SSLCertFile, "key", cfg.SSLKeyFile)
|
||||
}
|
||||
|
||||
// HTTP/3 (QUIC) Support
|
||||
if http3Enabled {
|
||||
if tlsConfig == nil {
|
||||
// This should practically not happen due to http3Enabled check above,
|
||||
// but good for safety if logic changes.
|
||||
return fmt.Errorf("HTTP/3 enabled but TLS config is nil")
|
||||
}
|
||||
addr := net.JoinHostPort(cfg.Host, cfg.Port)
|
||||
h3Srv := &http3.Server{
|
||||
Addr: addr,
|
||||
Handler: srv,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Serving HTTP/3 (QUIC)", "addr", addr)
|
||||
err := h3Srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("HTTP/3 server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wrap handler to set Alt-Svc header for TCP connections
|
||||
baseHandler := handler
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine the external port to advertise in Alt-Svc.
|
||||
// Use GetBaseURL to handle X-Forwarded-Host/Proto respecting logic centrally.
|
||||
baseURL := srv.GetBaseURL(r)
|
||||
u, err := url.Parse(baseURL)
|
||||
var port string
|
||||
if err == nil {
|
||||
port = u.Port()
|
||||
}
|
||||
|
||||
if port == "" {
|
||||
// No port in URL, use default based on scheme
|
||||
// HTTP/3 implies HTTPS, so 443.
|
||||
port = "443"
|
||||
}
|
||||
|
||||
w.Header().Set("Alt-Svc", fmt.Sprintf(`h3=":%s"; ma=2592000`, port))
|
||||
baseHandler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadTimeout: 25 * time.Second,
|
||||
WriteTimeout: 25 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("Tronbyt Server starting", "db", cfg.DBDSN, "dataDir", cfg.DataDir)
|
||||
|
||||
for _, l := range listeners {
|
||||
go func(l net.Listener) {
|
||||
var err error
|
||||
if tlsConfig != nil {
|
||||
// Wrap listener with TLS if config is present
|
||||
l = tls.NewListener(l, tlsConfig)
|
||||
}
|
||||
err = httpSrv.Serve(l)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}(l)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
return fmt.Errorf("server failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,8 @@ require (
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quic-go/quic-go v0.59.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/sumup/aaguids-go v0.0.0-20251118110537-5a71322266f2
|
||||
github.com/tronbyt/pixlet v0.52.0
|
||||
@@ -77,6 +79,7 @@ require (
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/tink/go v1.7.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.0 // indirect
|
||||
|
||||
@@ -157,6 +157,7 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
@@ -368,6 +369,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -568,6 +571,7 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
@@ -586,8 +590,13 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/joho/godotenv"
|
||||
@@ -31,6 +32,10 @@ type Settings struct {
|
||||
EnableUpdateChecks bool `env:"ENABLE_UPDATE_CHECKS" envDefault:"true"`
|
||||
}
|
||||
|
||||
func (s *Settings) SystemAppsDir() string {
|
||||
return filepath.Join(s.DataDir, "system-apps")
|
||||
}
|
||||
|
||||
// TemplateConfig holds configuration values needed in templates.
|
||||
type TemplateConfig struct {
|
||||
EnableUserRegistration bool
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ctxKey uint8
|
||||
|
||||
const configKey ctxKey = iota
|
||||
|
||||
func NewContext(ctx context.Context, conf *Settings) context.Context {
|
||||
return context.WithValue(ctx, configKey, conf)
|
||||
}
|
||||
|
||||
var ErrNoContext = errors.New("no config found in context")
|
||||
|
||||
func FromContext(ctx context.Context) (*Settings, error) {
|
||||
conf, ok := ctx.Value(configKey).(*Settings)
|
||||
if !ok {
|
||||
return nil, ErrNoContext
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
FlagDB = "db"
|
||||
FlagData = "data"
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/plugin/prometheus"
|
||||
)
|
||||
|
||||
func Open(dsn, logLevel string) (*gorm.DB, error) {
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
|
||||
var gormLogLevel logger.LogLevel
|
||||
switch strings.ToUpper(logLevel) {
|
||||
case "DEBUG":
|
||||
gormLogLevel = logger.Info // GORM Info includes SQL queries
|
||||
case "INFO":
|
||||
gormLogLevel = logger.Warn
|
||||
case "WARN", "WARNING":
|
||||
gormLogLevel = logger.Error
|
||||
case "ERROR":
|
||||
gormLogLevel = logger.Error
|
||||
default:
|
||||
gormLogLevel = logger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: NewGORMSlogLogger(gormLogLevel, 200*time.Millisecond, true),
|
||||
PrepareStmt: false, // Disable prepared statement caching to avoid SQLite locking issues
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres") || strings.Contains(dsn, "host=") {
|
||||
slog.Info("Using Postgres DB")
|
||||
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
||||
} else if strings.Contains(dsn, "@tcp(") || strings.Contains(dsn, "@unix(") {
|
||||
slog.Info("Using MySQL DB")
|
||||
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
} else {
|
||||
slog.Info("Using SQLite DB", "path", dsn)
|
||||
db, err = gorm.Open(sqlite.Open(dsn), gormConfig)
|
||||
if err == nil {
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
|
||||
slog.Warn("Failed to set WAL mode for SQLite", "error", err)
|
||||
}
|
||||
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
|
||||
slog.Warn("Failed to set busy timeout for SQLite", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if err := db.Use(prometheus.New(prometheus.Config{
|
||||
DBName: "tronbyt",
|
||||
RefreshInterval: 15,
|
||||
StartServer: false,
|
||||
HTTPServerPort: 8080,
|
||||
})); err != nil {
|
||||
slog.Warn("Failed to register GORM prometheus plugin", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, err
|
||||
}
|
||||
Reference in New Issue
Block a user