138 lines
3.8 KiB
Go
138 lines
3.8 KiB
Go
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
|
||
}
|