Files
dsh-web/cmd/server/main.go
T

138 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"errors"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/api"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
if err := api.ValidateAuthConfig(); err != nil {
return err
}
root, err := filepath.Abs(getenv("CONTAINER_DATA_ROOT", getenv("DATA_ROOT", "./data")))
if err != nil {
return err
}
if err = os.MkdirAll(root, 0700); err != nil {
return err
}
if os.Getenv("HOST_DATA_ROOT") == "" {
os.Setenv("HOST_DATA_ROOT", root)
}
s, err := store.Open(filepath.Join(root, "db.sqlite"))
if err != nil {
return err
}
defer s.Close()
var count int
if err = s.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
return err
}
if count == 0 {
password := os.Getenv("ADMIN_PASSWORD")
if len(password) < 12 || len(password) > 72 {
return fmt.Errorf("first startup requires ADMIN_PASSWORD of 12–72 bytes")
}
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if e != nil {
return e
}
if err = s.CreateUser(store.User{ID: store.ID(), Username: getenv("ADMIN_USERNAME", "admin"), Admin: true, Quota: api.DefaultQuota()}, string(hash)); err != nil {
return err
}
}
panel := api.New(s, root)
if err := panel.ConfigError(); err != nil {
return err
}
checkCtx, checkCancel := context.WithTimeout(context.Background(), 20*time.Second)
if err := panel.Reconcile(checkCtx); err != nil {
log.Printf("startup reconciliation unavailable; Docker connectivity must be checked: %v", err)
}
checkCancel()
if err := panel.StartImageBuilds(); err != nil {
return fmt.Errorf("image build recovery: %w", err)
}
backend := panel.Handler()
static := http.FileServer(http.Dir(getenv("WEB_DIR", "web/dist")))
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/dsh/") {
backend.ServeHTTP(w, r)
return
}
if r.URL.Path == "/healthz" {
w.Write([]byte("ok"))
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", 405)
return
}
// The embedded runtime visits the same-origin SPA renewal bridge.
// Keep cross-origin framing blocked without blocking that bridge.
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
static.ServeHTTP(w, r)
})
srv := &http.Server{Addr: getenv("LISTEN_ADDR", "127.0.0.1:8088"), Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := panel.StartIntents(ctx); err != nil {
return fmt.Errorf("creation intent recovery: %w", err)
}
go panel.RunQuotaMonitor(ctx)
return serve(ctx, srv)
}
func serve(ctx context.Context, srv *http.Server) error {
drained := make(chan struct{})
finished := make(chan struct{})
defer close(finished)
go func() {
defer close(drained)
select {
case <-ctx.Done():
case <-finished:
return
}
c, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(c); err != nil {
log.Printf("graceful shutdown deadline reached: %v", err)
_ = srv.Close()
}
}()
log.Printf("dsh panel listening on %s", srv.Addr)
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
<-drained
return nil
}
return err
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}