258 lines
7.7 KiB
Go
258 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"arclineit/arcline-portal/internal/auth"
|
|
"arclineit/arcline-portal/internal/db"
|
|
"arclineit/arcline-portal/internal/mail"
|
|
"arclineit/arcline-portal/internal/uptime"
|
|
"arclineit/arcline-portal/internal/web"
|
|
)
|
|
|
|
func main() {
|
|
loadEnv(".env")
|
|
|
|
// --- CLI flags ---
|
|
seedFlag := flag.Bool("seed", false, "create the initial admin account and exit")
|
|
seedUser := flag.String("username", "", "admin username (used with -seed)")
|
|
seedName := flag.String("name", "", "admin display name (used with -seed)")
|
|
seedPass := flag.String("password", "", "admin password — min 8 chars (used with -seed)")
|
|
flag.Parse()
|
|
|
|
port := envOr("PORT", "8082")
|
|
dbPath := envOr("DB_PATH", "./portal.db")
|
|
uptimeDBPath := envOr("UPTIME_DB_PATH", "../arcline-uptime/uptime.db")
|
|
|
|
// --- Database ---
|
|
database, err := db.Open(dbPath)
|
|
if err != nil {
|
|
slog.Error("open portal db", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer database.Close()
|
|
|
|
// --- Seed mode ---
|
|
if *seedFlag {
|
|
username := strings.TrimSpace(*seedUser)
|
|
name := strings.TrimSpace(*seedName)
|
|
password := *seedPass
|
|
if username == "" || name == "" {
|
|
fmt.Fprintln(os.Stderr, "error: -username and -name are required with -seed")
|
|
os.Exit(1)
|
|
}
|
|
if len(password) < 8 {
|
|
fmt.Fprintln(os.Stderr, "error: -password must be at least 8 characters")
|
|
os.Exit(1)
|
|
}
|
|
// Check if an admin already exists.
|
|
existing, err := database.GetClientByUsername(username)
|
|
if err != nil {
|
|
slog.Error("seed: lookup failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
if existing != nil {
|
|
fmt.Fprintf(os.Stderr, "error: username %q already exists\n", username)
|
|
os.Exit(1)
|
|
}
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
slog.Error("seed: hash password", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
client, err := database.CreateClient(username, name, "", hash, true)
|
|
if err != nil {
|
|
slog.Error("seed: create client", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Admin account created.\n id: %d\n username: %s\n name: %s\n",
|
|
client.ID, client.Username, client.DisplayName)
|
|
return
|
|
}
|
|
|
|
// --- Uptime reader (optional) ---
|
|
var uptimeReader *uptime.Reader
|
|
if uptime.Available(uptimeDBPath) {
|
|
uptimeReader, err = uptime.Open(uptimeDBPath)
|
|
if err != nil {
|
|
slog.Warn("uptime db unavailable — service status will not be shown", "err", err)
|
|
} else {
|
|
defer uptimeReader.Close()
|
|
slog.Info("uptime db connected", "path", uptimeDBPath)
|
|
}
|
|
} else {
|
|
slog.Warn("uptime db not found — service status disabled", "path", uptimeDBPath)
|
|
}
|
|
|
|
// --- Mail ---
|
|
var mailer *mail.Mailer
|
|
mailCfg := mail.Config{
|
|
Host: envOr("SMTP_HOST", ""),
|
|
Port: envOr("SMTP_PORT", "587"),
|
|
Username: envOr("SMTP_USER", ""),
|
|
Password: envOr("SMTP_PASS", ""),
|
|
From: envOr("SMTP_FROM", ""),
|
|
AdminEmail: envOr("ADMIN_EMAIL", ""),
|
|
BaseURL: envOr("BASE_URL", "https://portal.arclineit.com"),
|
|
}
|
|
if mailCfg.Host != "" && mailCfg.From != "" {
|
|
mailer, err = mail.New(mailCfg)
|
|
if err != nil {
|
|
slog.Warn("mail not configured", "err", err)
|
|
} else {
|
|
slog.Info("mail configured", "host", mailCfg.Host, "from", mailCfg.From)
|
|
}
|
|
} else {
|
|
slog.Warn("mail not configured — set SMTP_HOST and SMTP_FROM to enable email")
|
|
}
|
|
|
|
// --- Handlers ---
|
|
h := &web.Handler{
|
|
DB: database,
|
|
Uptime: uptimeReader,
|
|
Mail: mailer,
|
|
}
|
|
|
|
// --- Background jobs ---
|
|
go func() {
|
|
// SSL checker: run immediately, then daily.
|
|
h.RunSSLChecker()
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
h.RunSSLChecker()
|
|
}
|
|
}()
|
|
go func() {
|
|
// Prune expired sessions every hour.
|
|
ticker := time.NewTicker(time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
if err := database.PruneSessions(); err != nil {
|
|
slog.Error("prune sessions", "err", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// --- Routes ---
|
|
mux := http.NewServeMux()
|
|
|
|
// Public
|
|
mux.HandleFunc("GET /login", h.LoginGET)
|
|
mux.HandleFunc("POST /login", h.LoginPOST)
|
|
mux.HandleFunc("POST /logout", h.LogoutPOST)
|
|
mux.HandleFunc("GET /forgot", h.ForgotGET)
|
|
mux.HandleFunc("POST /forgot", h.ForgotPOST)
|
|
mux.HandleFunc("GET /reset", h.ResetGET)
|
|
mux.HandleFunc("POST /reset", h.ResetPOST)
|
|
|
|
// Static assets
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
|
|
|
// Authenticated — client
|
|
authed := auth.Middleware(database)
|
|
mux.Handle("GET /dashboard", authed(http.HandlerFunc(h.DashboardGET)))
|
|
|
|
mux.Handle("GET /ssl", authed(http.HandlerFunc(h.SSLGet)))
|
|
mux.Handle("POST /ssl/add", authed(http.HandlerFunc(h.SSLAddPOST)))
|
|
mux.Handle("POST /ssl/delete", authed(http.HandlerFunc(h.SSLDeletePOST)))
|
|
|
|
mux.Handle("GET /tickets", authed(http.HandlerFunc(h.TicketsGET)))
|
|
mux.Handle("POST /tickets/new", authed(http.HandlerFunc(h.TicketNewPOST)))
|
|
mux.Handle("GET /tickets/{id}", authed(http.HandlerFunc(h.TicketGET)))
|
|
mux.Handle("POST /tickets/{id}/reply", authed(http.HandlerFunc(h.TicketReplyPOST)))
|
|
|
|
mux.Handle("GET /settings", authed(http.HandlerFunc(h.SettingsGET)))
|
|
mux.Handle("POST /settings/email", authed(http.HandlerFunc(h.SettingsEmailPOST)))
|
|
mux.Handle("POST /settings/password", authed(http.HandlerFunc(h.SettingsPasswordPOST)))
|
|
|
|
// Authenticated — admin only
|
|
admin := func(hf http.HandlerFunc) http.Handler {
|
|
return authed(auth.AdminMiddleware(http.HandlerFunc(hf)))
|
|
}
|
|
mux.Handle("GET /admin", admin(h.AdminIndexGET))
|
|
mux.Handle("POST /admin/clients/new", admin(h.AdminClientNewPOST))
|
|
mux.Handle("GET /admin/clients/{id}", admin(h.AdminClientGET))
|
|
mux.Handle("POST /admin/clients/{id}/monitors/add", admin(h.AdminMonitorAddPOST))
|
|
mux.Handle("POST /admin/clients/{id}/monitors/delete", admin(h.AdminMonitorDeletePOST))
|
|
mux.Handle("POST /admin/clients/{id}/delete", admin(h.AdminClientDeletePOST))
|
|
|
|
// Root redirect / catch-all 404
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
return
|
|
}
|
|
h.NotFoundHandler(w, r)
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + port,
|
|
Handler: secHeaders(mux),
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
slog.Info("arcline-portal starting", "addr", srv.Addr)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, context.Canceled) {
|
|
slog.Error("server error", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func secHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// loadEnv reads a .env file and sets any key=value pairs as environment
|
|
// variables, skipping blank lines and lines beginning with #.
|
|
// Already-set variables (e.g. from the real environment) are not overwritten.
|
|
func loadEnv(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return // no .env is fine
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
k = strings.TrimSpace(k)
|
|
v = strings.TrimSpace(v)
|
|
// Strip surrounding quotes if present
|
|
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
|
|
v = v[1 : len(v)-1]
|
|
}
|
|
// Don't overwrite values already set in the environment
|
|
if os.Getenv(k) == "" {
|
|
os.Setenv(k, v)
|
|
}
|
|
}
|
|
}
|