Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
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
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"context"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestInvalidAuthConfigFailsBeforeDataCreation(t *testing.T) {
for _, key := range []string{"PANEL_PUBLIC_URL", "TRUSTED_PROXY_CIDRS"} {
t.Run(key, func(t *testing.T) {
t.Setenv("PANEL_PUBLIC_URL", "")
t.Setenv("TRUSTED_PROXY_CIDRS", "")
t.Setenv(key, "invalid")
root := filepath.Join(t.TempDir(), "not-created")
t.Setenv("CONTAINER_DATA_ROOT", root)
if err := run(); err == nil || !strings.Contains(err.Error(), key) {
t.Fatalf("unexpected error: %v", err)
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("data directory created: %v", err)
}
})
}
}
func TestServeWaitsForActiveHandler(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
address := make(chan string, 1)
entered := make(chan struct{})
release := make(chan struct{})
srv := &http.Server{Addr: "127.0.0.1:0", BaseContext: func(l net.Listener) context.Context {
address <- l.Addr().String()
return context.Background()
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
<-release
_, _ = w.Write([]byte("finished"))
})}
done := make(chan error, 1)
go func() { done <- serve(ctx, srv) }()
var addr string
select {
case addr = <-address:
case <-time.After(5 * time.Second):
t.Fatal("server did not listen")
}
requestDone := make(chan error, 1)
go func() {
resp, err := http.Get("http://" + addr)
if err == nil {
_, err = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
requestDone <- err
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("handler did not start")
}
cancel()
select {
case err := <-done:
close(release)
t.Fatalf("returned before handler completed: %v", err)
case <-time.After(100 * time.Millisecond):
}
close(release)
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("shutdown stalled")
}
if err := <-requestDone; err != nil {
t.Fatal(err)
}
}