M cmd/felu/main.go => cmd/felu/main.go +14 -9
@@ 7,14 7,16 @@
 package main
 
 import (
-	"log"
+	"log/slog"
 	"net/http"
+	"os"
 	"strconv"
 	"time"
 
 	"git.src.quest/~skye/felu-ddns/internal/config"
 	"git.src.quest/~skye/felu-ddns/internal/db"
 	"git.src.quest/~skye/felu-ddns/internal/dns"
+	"git.src.quest/~skye/felu-ddns/internal/log"
 	"git.src.quest/~skye/felu-ddns/internal/routers"
 	"github.com/alexedwards/scs/v2"
 	"golang.org/x/sync/errgroup"
@@ 28,16 30,19 @@ var (
 )
 
 func main() {
-	log.Print("[felu] Starting up...")
-
 	config.InitConfig()
 
+	log.InitDefaultLogger(config.FeluConfig.LogLevel)
+	slog.Info("Starting up...")
+
 	if err := db.InitDB(); err != nil {
-		log.Fatalf("[felu] Failed to initialize database: '%s'", err)
+		slog.Error("Failed to initialize database", slog.Any("err", err))
+		os.Exit(1)
 	}
 	defer db.DBConn.Close()
 	if err := db.InitAdminUser(); err != nil {
-		log.Fatalf("[felu] Failed to initialize admin user: '%s'", err)
+		slog.Error("Failed to initialize admin user", slog.Any("err", err))
+		os.Exit(1)
 	}
 
 	sessionManager = scs.New()
@@ 57,12 62,12 @@ func main() {
 		WriteTimeout: 10 * time.Second,
 	}
 
-	log.Printf("[felu] Serving frontend at '%s'", config.FeluConfig.FrontendBindAddr)
+	slog.Info("Serving frontend", slog.String("addr", config.FeluConfig.FrontendBindAddr))
 	g.Go(func() error {
 		return frontend.ListenAndServe()
 	})
 
-	log.Printf("[felu] Serving api at '%s'", config.FeluConfig.APIBindAddr)
+	slog.Info("Serving API", slog.String("addr", config.FeluConfig.APIBindAddr))
 	g.Go(func() error {
 		return api.ListenAndServe()
 	})
@@ 70,12 75,12 @@ func main() {
 	dnsIP := config.FeluConfig.DNSBindIP
 	dnsPort := strconv.Itoa(int(config.FeluConfig.DNSBindPort))
 	dnsAddr := dnsIP + ":" + dnsPort
-	log.Printf("[felu] Serving DNS at '%s'", dnsAddr)
+	slog.Info("Serving DNS", slog.String("addr", dnsAddr))
 	g.Go(func() error {
 		return dns.Run(dnsAddr)
 	})
 
 	if err := g.Wait(); err != nil {
-		log.Fatalf("[felu] Error while running: %s", err)
+		slog.Info("Error while running", slog.Any("err", err))
 	}
 }
 
M internal/config/config.go => internal/config/config.go +4 -0
@@ 18,6 18,8 @@ type config struct {
 	// Initial password for the admin user, only used if no admin account (e.g. first boot)
 	InitialAdminPwd   string
 
+	LogLevel string
+
 	// Data directory, with trailing slash
 	DataDir string
 
@@ 38,6 40,8 @@ func InitConfig() {
 		InitialAdminEmail: util.LoadEnvStr("FELU_INITIAL_ADMIN_EMAIL", "admin@example.com"),
 		InitialAdminPwd: util.LoadEnvStr("FELU_INITIAL_ADMIN_PWD", "feluadmin"),
 
+		LogLevel: util.LoadEnvStr("FELU_LOG_LEVEL", "info"),
+
 		DataDir: util.LoadEnvStr("FELU_DB_PATH", "/var/felu/"),
 
 		FrontendBindAddr: util.LoadEnvStr("FELU_FRONTEND_BIND_ADDR", "0.0.0.0:8080"),
 
M internal/db/db.go => internal/db/db.go +3 -0
@@ 8,6 8,7 @@ package db
 
 import (
 	"database/sql"
+	"log/slog"
 
 	"git.src.quest/~skye/felu-ddns/internal/config"
 	_ "github.com/mattn/go-sqlite3"
@@ 36,10 37,12 @@ func InitAdminUser() error {
 	defer rows.Close()
 	if rows.Next() {
 		// There is at least one...
+		slog.Info("Existing admin user found")
 		return nil
 	}
 
 	// Since we're here, it's assumed no admin accounts exist
+	slog.Info("Creating initial admin user")
 	err = CreateAdmin(config.FeluConfig.InitialAdminEmail, config.FeluConfig.InitialAdminPwd)
 	if err != nil {
 		return err
 
M internal/db/migrations.go => internal/db/migrations.go +12 -9
@@ 8,13 8,14 @@ package db
 
 import (
 	"fmt"
-	"log"
+	"log/slog"
+	"os"
 )
 
 var migrationsTable string = "schema_migrations"
 
 func runMigrations() {
-	log.Print("[felu/db] Running migrations")
+	slog.Info("Running migrations")
 
 	var schemaVersion int = 0
 
@@ 26,19 27,20 @@ func runMigrations() {
 		migrationsTable)
 	err := DBConn.QueryRow(schemaVersionQuery).Scan(&schemaVersion)
 	if err != nil {
-		log.Print("[felu/db] No schema version found, running all migrations")
+		slog.Info("No schema version found, running all migrations")
 	} else {
-		log.Printf("[felu/db] Currently on schema version %d", schemaVersion)
+		slog.Info("Current schema", slog.Int("version", schemaVersion))
 	}
 
 	migrations := migrations()
 	if schemaVersion != len(migrations) {
 		for i := 0; i < len(migrations); i++ {
 			if i >= schemaVersion {
-				log.Printf("[felu/db] Running migration %d", i + 1) // + 1 is just a visual thing
+				slog.Info("Running migration", slog.Int("version", i + 1)) // + 1 is just a visual thing
 				_, err := DBConn.Exec(migrations[i])
 				if err != nil {
-					log.Fatalf("[felu/db] Migration %d failed to run!", i)
+					slog.Error("Migration failed to run!", slog.Int("version", i))
+					os.Exit(1)
 				}
 			}
 		}
@@ 51,12 53,13 @@ func runMigrations() {
 			migrationsTable, schemaVersion)
 		_, err = DBConn.Exec(schemaMigrationInsertQuery)
 		if err != nil {
-			log.Fatal("[felu/db] Migrations ran, but was not able to create migration entry")
+			slog.Error("Migrations ran, but was not able to create migration entry")
+			os.Exit(1)
 		} else {
-			log.Print("[felu/db] Migrations ran successfully")
+			slog.Info("Migrations ran successfully")
 		}
 	} else {
-		log.Printf("[felu/db] Already on schema version %d, no migrations to run", schemaVersion)
+		slog.Info("No migrations to run")
 	}
 }
 
 
M internal/dns/query.go => internal/dns/query.go +2 -2
@@ 7,7 7,7 @@
 package dns
 
 import (
-	"log"
+	"log/slog"
 	"net"
 	"strings"
 
@@ 25,7 25,7 @@ func parseQuery(m *dns.Msg, r *dns.Msg) {
 }
 
 func handleARecord(q *dns.Question, m *dns.Msg, r *dns.Msg) {
-	log.Printf("[felu/dns] Query for '%s'", q.Name)
+	slog.Info("A Record Query", slog.String("qname", q.Name))
 
 	if index := strings.IndexByte(q.Name, '.'); index >= 0 {
 		aRecord, err := db.FetchDomainARecord(q.Name[:index])
 
M internal/handlers/auth.go => internal/handlers/auth.go +1 -2
@@ 7,7 7,6 @@
 package handlers
 
 import (
-	"log"
 	"net/http"
 
 	"git.src.quest/~skye/felu-ddns/internal/db"
@@ 24,8 23,8 @@ func AuthLogin(sm *scs.SessionManager) gin.HandlerFunc {
 	return func(c *gin.Context) {
 		data := &postAuthLoginData{}
 		if err := c.Bind(data); err != nil {
-			log.Printf("[felu] ERROR: Could not bind login details: %v", err)
 			c.String(http.StatusBadRequest, "Could not bind login details")
+			c.Abort()
 			return
 		}
 
 
M internal/handlers/domains.go => internal/handlers/domains.go +1 -2
@@ 7,7 7,6 @@
 package handlers
 
 import (
-	"log"
 	"net"
 	"net/http"
 	"regexp"
@@ 26,8 25,8 @@ func PostDomain() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		data := &postDomainData{}
 		if err := c.Bind(data); err != nil {
-			log.Printf("[felu] ERROR: Could not bind domain data: %v", err)
 			c.String(http.StatusBadRequest, "Could not bind domain data")
+			c.Abort()
 			return
 		}
 
 
M internal/handlers/user.go => internal/handlers/user.go +2 -3
@@ 7,7 7,6 @@
 package handlers
 
 import (
-	"log"
 	"net/http"
 
 	"git.src.quest/~skye/felu-ddns/internal/db"
@@ 25,8 24,8 @@ func PostUserPassword() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		data := &postUserPasswordData{}
 		if err := c.Bind(data); err != nil {
-			log.Printf("[felu] ERROR: Could not bind password data: %v", err)
 			c.String(http.StatusBadRequest, "Could not bind password data")
+			c.Abort()
 			return
 		}
 
@@ 74,8 73,8 @@ func PostUserEmail() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		data := &postUserEmailData{}
 		if err := c.Bind(data); err != nil {
-			log.Printf("[felu] ERROR: Could not bind email data: %v", err)
 			c.String(http.StatusBadRequest, "Could not bind email data")
+			c.Abort()
 			return
 		}
 
 
M internal/handlers/users.go => internal/handlers/users.go +1 -2
@@ 7,7 7,6 @@
 package handlers
 
 import (
-	"log"
 	"net/http"
 
 	"git.src.quest/~skye/felu-ddns/internal/db"
@@ 23,8 22,8 @@ func PostUser() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		data := &postUserData{}
 		if err := c.Bind(data); err != nil {
-			log.Printf("[felu] ERROR: Could not bind user data: %v", err)
 			c.String(http.StatusBadRequest, "Could not bind user data")
+			c.Abort()
 			return
 		}
 
 
A internal/log/log.go => internal/log/log.go +44 -0
@@ 0,0 1,44 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package log
+
+import (
+	"log/slog"
+	"os"
+	"path/filepath"
+)
+
+func InitDefaultLogger(logLevel string) {
+	var feluLogLevel = new(slog.LevelVar)
+	switch logLevel {
+	case "debug":
+		feluLogLevel.Set(slog.LevelDebug)
+	case "info":
+		feluLogLevel.Set(slog.LevelInfo)
+	case "warning":
+		feluLogLevel.Set(slog.LevelWarn)
+	case "error":
+		feluLogLevel.Set(slog.LevelError)
+	}
+
+	replace := func(groups []string, a slog.Attr) slog.Attr {
+		if a.Key == slog.SourceKey {
+			source := a.Value.Any().(*slog.Source)
+			source.File = filepath.Base(source.File)
+		}
+
+		return a
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: feluLogLevel,
+		AddSource: true,
+		ReplaceAttr: replace,
+	}))
+
+	slog.SetDefault(logger)
+}