Initial commit: DSH Web Manager
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user