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
+22
View File
@@ -0,0 +1,22 @@
# Authentication boundary (§13)
- Real logins retain `dsh_session` as a database session binding and require a
five-minute HS256 access JWT in an additional HttpOnly cookie. JWT verification
fixes the algorithm and binds subject/session/issuer/audience/expiry. Bare
session IDs never authenticate. Internal callers and test fixtures must also
attach a signed access cookie.
- Refresh is a random 256-bit HttpOnly cookie, scoped to `/api`, with only its
SHA-256 hash stored in SQLite. The session has a 24-hour absolute lifetime.
Conditional SQL update makes rotation single-use even across concurrent requests.
Logout deletes the database session and its refresh binding. Both refresh and
logout retain mandatory exact Origin and session-bound CSRF checks, even when
access has expired. Other operations require valid access.
- Signing key is generated once under the configured data root, private mode
0600, and reused across restarts. Initialization errors fail authentication closed.
Tokens never appear in JSON, URLs, audit detail, or application logs.
- Known accounts lock for 15 minutes after five failures; the counter/lock survive
restart. Successful login resets the counter; expired locks reset the counter.
Existing global throttling remains additional protection.
- Tests cover missing/tampered/expired access, database binding, refresh rotation
and replay, CSRF/Origin rejection, logout revocation, key reuse/permissions,
account isolation, restart persistence, lock expiry, and runtime compatibility.
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"database/sql"
"encoding/json"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"time"
)
var usernamePattern = regexp.MustCompile("^[a-zA-Z0-9_-]{3,40}$")
func DefaultQuota() int64 {
if n, err := strconv.ParseInt(os.Getenv("DEFAULT_QUOTA_BYTES"), 10, 64); err == nil && n > 0 {
return n
}
return 10 << 30
}
func (a *API) users(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
switch r.Method {
case "GET":
rows, err := a.s.DB.Query("SELECT id,username,admin,quota,created FROM users ORDER BY created")
if err != nil {
fail(w, 500, "database error")
return
}
defer rows.Close()
out := []store.User{}
for rows.Next() {
var v store.User
var created int64
if err := rows.Scan(&v.ID, &v.Username, &v.Admin, &v.Quota, &created); err != nil {
fail(w, 500, "database error")
return
}
v.Created = time.Unix(created, 0)
out = append(out, v)
}
writeJSON(w, out)
case "POST":
var in struct {
Username string
Password string
Admin bool
Quota int64 `json:"quota_bytes"`
}
if json.NewDecoder(r.Body).Decode(&in) != nil || !usernamePattern.MatchString(in.Username) || len(in.Password) < 12 || len(in.Password) > 72 || in.Quota < 0 {
fail(w, 400, "username must be 3–40 letters/digits/_/-, password 12–72 bytes, quota nonnegative")
return
}
if in.Quota == 0 {
in.Quota = DefaultQuota()
}
h, e := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if e != nil {
fail(w, 500, "password error")
return
}
v := store.User{ID: store.ID(), Username: in.Username, Admin: in.Admin, Quota: in.Quota, Created: time.Now()}
if e = a.s.CreateUser(v, string(h)); e != nil {
fail(w, 409, "username already exists or database unavailable")
return
}
a.s.AddAudit(u.ID, "user.create", v.ID, "")
writeJSON(w, v)
case "PATCH":
var in struct {
ID string
Quota int64 `json:"quota_bytes"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || in.ID == "" || in.Quota <= 0 {
fail(w, 400, "positive quota and user ID required")
return
}
result, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id=?", in.Quota, in.ID)
if err != nil {
fail(w, 500, "quota update failed")
return
}
count, _ := result.RowsAffected()
if count != 1 {
fail(w, 404, "user not found")
return
}
a.s.AddAudit(u.ID, "user.quota", in.ID, strconv.FormatInt(in.Quota, 10))
writeJSON(w, map[string]bool{"ok": true})
default:
fail(w, 405, "method not allowed")
}
}
func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
var in struct{ ID, Password string }
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || in.ID == "" || len(in.Password) < 12 || len(in.Password) > 72 {
fail(w, 400, "user ID and password 12–72 bytes required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(w, 500, "password error")
return
}
// Login must not issue a session using a hash read before this reset.
a.authLoginMu.Lock()
defer a.authLoginMu.Unlock()
u, _, err = a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
tx, err := a.s.DB.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, "password reset failed")
return
}
defer tx.Rollback()
var username string
err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id=?", in.ID).Scan(&username)
if err == sql.ErrNoRows {
fail(w, 404, "user not found")
return
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "UPDATE users SET pass_hash=? WHERE id=?", string(hash), in.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)", in.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE uid=?", in.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM auth_failures WHERE username=?", username)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "user.reset_password", in.ID, "", time.Now().Unix())
}
if err == nil {
err = tx.Commit()
}
if err != nil {
fail(w, 500, "password reset failed")
return
}
writeJSON(w, map[string]bool{"ok": true, "reauthenticate": u.ID == in.ID})
}
func (a *API) audit(w http.ResponseWriter, r *http.Request) {
u, _, e := a.user(r)
if e != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
if r.Method != "GET" {
fail(w, 405, "method not allowed")
return
}
rows, e := a.s.DB.Query("SELECT id,uid,action,target,detail,at FROM audit ORDER BY id DESC LIMIT 500")
if e != nil {
fail(w, 500, "database error")
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, at int64
var uid, action, target, detail string
if rows.Scan(&id, &uid, &action, &target, &detail, &at) != nil {
fail(w, 500, "database error")
return
}
out = append(out, map[string]any{"id": id, "user_id": uid, "action": action, "target": target, "detail": detail, "created_at": time.Unix(at, 0)})
}
writeJSON(w, out)
}
+303
View File
@@ -0,0 +1,303 @@
package api
import (
"encoding/json"
"fmt"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
const resetPath = "/api/users/reset-password"
func TestResetPasswordConcurrentLogin(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
start := make(chan struct{})
results := make(chan *httptest.ResponseRecorder, 8)
for i := 0; i < 8; i++ {
go func() { <-start; results <- req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil) }()
}
close(start)
w := req("POST", resetPath, resetBody("bob", resetSecret), cookieValue(admin, "dsh_csrf"), admin)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
for i := 0; i < 8; i++ {
w := <-results
if w.Code != 200 && w.Code != 401 && w.Code != 429 {
t.Fatal(w.Code, w.Body.String())
}
if w.Code == 200 {
if check := req("GET", "/api/me", "", "", w.Result().Cookies()); check.Code != 401 {
t.Fatal("old-password login retained session", check.Code)
}
}
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM sessions WHERE uid='bob'").Scan(&n); err != nil || n != 0 {
t.Fatal("old login sessions", n, err)
}
}
type resetSignalReader struct {
reader *strings.Reader
started chan struct{}
once sync.Once
}
func (r *resetSignalReader) Read(p []byte) (int, error) {
r.once.Do(func() { close(r.started) })
return r.reader.Read(p)
}
func TestResetPasswordRechecksWaitingAdmin(t *testing.T) {
for _, revoke := range []string{"session", "admin"} {
t.Run(revoke, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
reader := &resetSignalReader{reader: strings.NewReader(resetBody("bob", resetSecret)), started: make(chan struct{})}
r := httptest.NewRequest("POST", resetPath, reader)
r.Header.Set("Origin", a.origin)
r.Header.Set("X-CSRF-Token", cookieValue(admin, "dsh_csrf"))
for _, c := range admin {
r.AddCookie(c)
}
a.authLoginMu.Lock()
locked := true
defer func() {
if locked {
a.authLoginMu.Unlock()
}
}()
done := make(chan *httptest.ResponseRecorder, 1)
go func() { w := httptest.NewRecorder(); a.Handler().ServeHTTP(w, r); done <- w }()
select {
case <-reader.started:
case <-time.After(5 * time.Second):
t.Fatal("request did not reach decoder")
}
select {
case w := <-done:
t.Fatal("reset bypassed login lock", w.Code)
case <-time.After(150 * time.Millisecond):
}
query := "UPDATE users SET admin=0 WHERE id='alice'"
want := 403
if revoke == "session" {
query = "DELETE FROM sessions WHERE uid='alice'"
want = 401
}
if _, err := a.s.DB.Exec(query); err != nil {
t.Fatal(err)
}
a.authLoginMu.Unlock()
locked = false
select {
case w := <-done:
if w.Code != want {
t.Fatal(w.Code, w.Body.String())
}
case <-time.After(5 * time.Second):
t.Fatal("reset did not finish")
}
hash, err := a.s.Password("bob")
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte("password-123")) != nil {
t.Fatal("unauthorized reset", err)
}
})
}
}
const resetSecret = "new-password-123"
func resetBody(id, password string) string {
b, _ := json.Marshal(map[string]string{"id": id, "password": password})
return string(b)
}
func TestResetPasswordValidation(t *testing.T) {
a, req := authFixture(t)
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
regular := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
csrf := cookieValue(admin, "dsh_csrf")
for _, tc := range []struct {
name, method, body, csrf string
cookies []*http.Cookie
code int
}{
{"anonymous", "POST", resetBody("bob", resetSecret), "", nil, 401},
{"regular", "POST", resetBody("alice", resetSecret), cookieValue(regular, "dsh_csrf"), regular, 403},
{"csrf", "POST", resetBody("bob", resetSecret), "bad", admin, 403},
{"method", "GET", "", csrf, admin, 405},
{"missing id", "POST", resetBody("", resetSecret), csrf, admin, 400},
{"short", "POST", resetBody("bob", strings.Repeat("a", 11)), csrf, admin, 400},
{"long", "POST", resetBody("bob", strings.Repeat("a", 73)), csrf, admin, 400},
{"unicode long", "POST", resetBody("bob", strings.Repeat("密", 25)), csrf, admin, 400},
{"malformed", "POST", "{", csrf, admin, 400},
{"trailing", "POST", resetBody("bob", resetSecret) + "{}", csrf, admin, 400},
{"oversized", "POST", resetBody("bob", strings.Repeat("a", 4096)), csrf, admin, 400},
{"missing target", "POST", resetBody("missing", resetSecret), csrf, admin, 404},
} {
t.Run(tc.name, func(t *testing.T) {
w := req(tc.method, resetPath, tc.body, tc.csrf, tc.cookies)
if w.Code != tc.code {
t.Fatal(w.Code, w.Body.String())
}
})
}
for _, origin := range []string{"", "https://evil.test"} {
r := httptest.NewRequest("POST", resetPath, strings.NewReader(resetBody("bob", resetSecret)))
r.Header.Set("Origin", origin)
r.Header.Set("X-CSRF-Token", csrf)
for _, c := range admin {
r.AddCookie(c)
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("origin accepted", w.Code)
}
}
for _, password := range []string{strings.Repeat("a", 12), strings.Repeat("密", 24)} {
if w := req("POST", resetPath, resetBody("bob", password), csrf, admin); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
hash, err := a.s.Password("bob")
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
t.Fatal("boundary hash", err)
}
}
}
func resetLogin(username, password string) string {
b, _ := json.Marshal(map[string]string{"username": username, "password": password})
return string(b)
}
func TestResetPasswordLifecycle(t *testing.T) {
for _, target := range []string{"bob", "alice"} {
t.Run(target, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
login := func(name string) []*http.Cookie {
w := req("POST", "/api/login", resetLogin(name, "password-123"), "", nil)
if w.Code != 200 {
t.Fatal(w.Code)
}
return w.Result().Cookies()
}
admin := login("alice")
targets := [][]*http.Cookie{login(target), login(target)}
other := "alice"
if target == "alice" {
other = "bob"
}
unaffected := login(other)
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES(?,5,?)", target, 9999999999); err != nil {
t.Fatal(err)
}
w := req("POST", resetPath, resetBody(target, resetSecret), cookieValue(admin, "dsh_csrf"), admin)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var result map[string]bool
if json.Unmarshal(w.Body.Bytes(), &result) != nil || !result["ok"] || result["reauthenticate"] != (target == "alice") {
t.Fatal(w.Body.String())
}
for _, cs := range targets {
var remaining int
if err := a.s.DB.QueryRow("SELECT count(*) FROM auth_refresh WHERE sid=?", cookieValue(cs, "dsh_session")).Scan(&remaining); err != nil || remaining != 0 {
t.Fatal("old refresh row", remaining, err)
}
if w := req("GET", "/api/me", "", "", cs); w.Code != 401 {
t.Fatal("old access", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(cs, "dsh_csrf"), cs); w.Code != 403 {
t.Fatal("old refresh", w.Code)
}
}
for _, query := range []string{"SELECT count(*) FROM sessions WHERE uid=?", "SELECT count(*) FROM auth_failures WHERE username=?", "SELECT count(*) FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)"} {
var n int
if err := a.s.DB.QueryRow(query, target).Scan(&n); err != nil || n != 0 {
t.Fatal(query, n, err)
}
}
if w := req("GET", "/api/me", "", "", unaffected); w.Code != 200 {
t.Fatal("other access", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(unaffected, "dsh_csrf"), unaffected); w.Code != 200 {
t.Fatal("other refresh", w.Code)
}
if w := req("POST", "/api/login", resetLogin(target, "password-123"), "", nil); w.Code != 401 {
t.Fatal("old password", w.Code)
}
if w := req("POST", "/api/login", resetLogin(target, resetSecret), "", nil); w.Code != 200 {
t.Fatal("new password", w.Code)
}
var actor, detail string
var n int
if err := a.s.DB.QueryRow("SELECT uid,detail,count(*) FROM audit WHERE action='user.reset_password' AND target=?", target).Scan(&actor, &detail, &n); err != nil || actor != "alice" || detail != "" || n != 1 {
t.Fatal("audit", actor, detail, n, err)
}
})
}
}
func TestResetPasswordRollback(t *testing.T) {
for _, step := range []struct{ table, event string }{{"users", "UPDATE"}, {"auth_refresh", "DELETE"}, {"sessions", "DELETE"}, {"auth_failures", "DELETE"}, {"audit", "INSERT"}} {
t.Run(step.table, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
target := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
before, err := a.s.Password("bob")
if err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('bob',5,9999999999)"); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec(fmt.Sprintf("CREATE TRIGGER reject_reset BEFORE %s ON %s BEGIN SELECT RAISE(ABORT,'forced failure'); END", step.event, step.table)); err != nil {
t.Fatal(err)
}
w := req("POST", resetPath, resetBody("bob", resetSecret), cookieValue(admin, "dsh_csrf"), admin)
if w.Code != 500 || strings.Contains(w.Body.String(), resetSecret) {
t.Fatal(w.Code, w.Body.String())
}
after, err := a.s.Password("bob")
if err != nil || before != after {
t.Fatal("hash changed", err)
}
for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='bob'": 1, "SELECT count(*) FROM auth_refresh": 2, "SELECT count(*) FROM auth_failures WHERE username='bob' AND failures=5 AND locked_until=9999999999": 1, "SELECT count(*) FROM audit WHERE action='user.reset_password'": 0} {
var n int
if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want {
t.Fatal(query, n, err)
}
}
if w := req("GET", "/api/me", "", "", target); w.Code != 200 {
t.Fatal("session changed", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(target, "dsh_csrf"), target); w.Code != 200 {
t.Fatal("refresh changed", w.Code)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAdminQuotaUpdate(t *testing.T) {
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) {})
request := func() *httptest.ResponseRecorder {
w := httptest.NewRecorder()
a.users(w, runtimeRequest("PATCH", "/api/users", `{"id":"owner","quota_bytes":2048}`, sid))
return w
}
if w := request(); w.Code != 403 {
t.Fatal(w.Code)
}
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='owner'"); err != nil {
t.Fatal(err)
}
if w := request(); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
u, err := a.s.UserByID("owner")
if err != nil || u.Quota != 2048 {
t.Fatalf("%+v %v", u, err)
}
t.Setenv("DEFAULT_QUOTA_BYTES", "12345")
if DefaultQuota() != 12345 {
t.Fatal("default quota ignored")
}
}
+269
View File
@@ -0,0 +1,269 @@
package api
import (
"encoding/json"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
type API struct {
s *store.Store
origin string
root string
docker *docker.Client
mu sync.Mutex
workspaceReaders map[string]int
loginMu sync.Mutex
loginSources map[string]loginSource
trustedProxies []*net.IPNet
loginSlots chan struct{}
loginConfigErr error
authKey []byte
authErr error
authLoginMu sync.Mutex
}
func New(s *store.Store, root string) *API {
a := &API{s: s, root: root, docker: docker.New(os.Getenv("DOCKER_HOST")), workspaceReaders: make(map[string]int), loginSources: make(map[string]loginSource), loginSlots: make(chan struct{}, 8)}
a.origin, a.loginConfigErr = configuredOrigin(os.Getenv("PANEL_PUBLIC_URL"))
var proxyErr error
a.trustedProxies, proxyErr = configuredProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS"), a.loginConfigErr)
if a.loginConfigErr == nil {
a.loginConfigErr = proxyErr
}
if a.loginConfigErr != nil {
a.authErr = a.loginConfigErr
} else {
a.authErr = a.initAuth()
}
return a
}
func (a *API) Handler() http.Handler {
m := http.NewServeMux()
m.HandleFunc("/api/login", a.login)
m.HandleFunc("/api/refresh", a.refresh)
m.HandleFunc("/api/runtime-session.js", runtimeSessionScript)
m.HandleFunc("/api/logout", a.logout)
m.HandleFunc("/api/me", a.me)
m.HandleFunc("/api/me/password", a.changePassword)
m.HandleFunc("/api/instances", a.instances)
m.HandleFunc("/api/instances/", a.instance)
m.HandleFunc("/api/images", a.images)
m.HandleFunc("/api/latest", a.latest)
m.HandleFunc("/api/builds", a.builds)
m.HandleFunc("/api/intents", a.intents)
m.HandleFunc("/api/intents/", a.intent)
m.HandleFunc("/api/ws/", a.workspace)
m.HandleFunc("/api/users", a.users)
m.HandleFunc("/api/users/reset-password", a.resetPassword)
m.HandleFunc("/api/audit", a.audit)
m.HandleFunc("/api/orphans", a.orphans)
m.HandleFunc("/dsh/", a.proxy)
return a.guard(m)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, n int, s string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(n)
writeJSON(w, map[string]string{"error": s})
}
func (a *API) guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.loginConfigErr != nil {
fail(w, 503, "authentication configuration invalid")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if strings.HasPrefix(r.URL.Path, "/dsh/") {
next.ServeHTTP(w, r)
return
}
// API bodies and downloads are bounded; runtime WS/SSE above remain streaming.
controller := http.NewResponseController(w)
_ = controller.SetReadDeadline(time.Now().Add(60 * time.Second))
_ = controller.SetWriteDeadline(time.Now().Add(5 * time.Minute))
defer controller.SetReadDeadline(time.Time{})
defer controller.SetWriteDeadline(time.Time{})
if r.Method != "GET" && r.URL.Path != "/api/login" {
if !a.panelOriginAllowed(r) {
fail(w, 403, "origin denied")
return
}
c, e := r.Cookie("dsh_session")
if e != nil {
fail(w, 401, "authentication required")
return
}
_, csrf, e := a.s.SessionUser(c.Value)
if e != nil || r.Header.Get("X-CSRF-Token") != csrf {
fail(w, 403, "csrf denied")
return
}
if r.URL.Path != "/api/refresh" && r.URL.Path != "/api/logout" {
if _, _, e := a.user(r); e != nil {
fail(w, 401, "authentication required")
return
}
}
}
next.ServeHTTP(w, r)
})
}
func (a *API) user(r *http.Request) (store.User, string, error) {
c, e := r.Cookie("dsh_session")
if e != nil {
return store.User{}, "", e
}
u, csrf, e := a.s.SessionUser(c.Value)
if e != nil {
return store.User{}, "", e
}
if e = a.verifyAccess(r, c.Value, u.ID); e != nil {
return store.User{}, "", e
}
return u, csrf, nil
}
func (a *API) login(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
if !a.panelOriginAllowed(r) {
fail(w, 403, "origin denied")
return
}
var in struct{ Username, Password string }
r.Body = http.MaxBytesReader(w, r.Body, 4096)
decoder := json.NewDecoder(r.Body)
if decoder.Decode(&in) != nil {
fail(w, 400, "invalid json")
return
}
if decoder.Decode(new(any)) != io.EOF {
fail(w, 400, "invalid json")
return
}
if !a.allowLoginSource(a.loginSource(r)) {
fail(w, 429, "too many login attempts; retry in a minute")
return
}
if a.authErr != nil {
fail(w, 503, "authentication unavailable")
return
}
if !a.acquireLoginSlot() {
fail(w, 503, "authentication overloaded")
return
}
defer func() { <-a.loginSlots }()
a.authLoginMu.Lock()
defer a.authLoginMu.Unlock()
locked, e := a.accountLocked(in.Username)
if e != nil {
fail(w, 503, "authentication unavailable")
return
}
if locked {
fail(w, 429, "account temporarily locked; retry later")
return
}
h, e := a.s.Password(in.Username)
if e != nil {
fail(w, 401, "invalid credentials")
return
}
if bcrypt.CompareHashAndPassword([]byte(h), []byte(in.Password)) != nil {
if e = a.failedLogin(in.Username); e != nil {
fail(w, 503, "authentication unavailable")
return
}
fail(w, 401, "invalid credentials")
return
}
u, e := a.s.UserByName(in.Username)
if e != nil {
fail(w, 401, "invalid credentials")
return
}
if _, e = a.s.DB.Exec("DELETE FROM auth_failures WHERE username=?", in.Username); e != nil {
fail(w, 503, "authentication unavailable")
return
}
sid, csrf, e := a.s.Session(u.ID)
if e != nil {
fail(w, 500, "session error")
return
}
token, e := refreshToken()
if e == nil {
_, e = a.s.DB.Exec("INSERT INTO auth_refresh(sid,token_hash) VALUES(?,?)", sid, refreshHash(token))
}
if e != nil {
a.s.DeleteSession(sid)
fail(w, 500, "session error")
return
}
a.authCookies(w, r, sid, u.ID, csrf, token)
a.s.AddAudit(u.ID, "login", u.ID, "")
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
}
func (a *API) logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
if c, e := r.Cookie("dsh_session"); e == nil {
if _, e = a.s.DB.Exec("DELETE FROM sessions WHERE id=?", c.Value); e != nil {
fail(w, 500, "session error")
return
}
}
for _, name := range []string{"dsh_session", "dsh_access", "dsh_refresh", "dsh_csrf"} {
path := "/"
if name == "dsh_refresh" {
path = "/api"
}
http.SetCookie(w, &http.Cookie{Name: name, MaxAge: -1, Path: path, Secure: a.secureCookies(r), HttpOnly: name != "dsh_csrf", SameSite: http.SameSiteLaxMode})
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) me(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
u, c, e := a.user(r)
if e != nil {
fail(w, 401, "authentication required")
return
}
writeJSON(w, map[string]any{"user": u, "csrf_token": c})
}
func (a *API) panelOriginAllowed(r *http.Request) bool {
if a.loginConfigErr != nil {
return false
}
expected := a.origin
if expected == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
expected = scheme + "://" + r.Host
}
return r.Header.Get("Origin") == expected
}
+28
View File
@@ -0,0 +1,28 @@
package api
import (
"net/http"
"sync"
"testing"
"time"
)
var testAccessTokens sync.Map
func registerTestAccess(t *testing.T, a *API, sid string) {
t.Helper()
if a.authErr != nil {
t.Fatal(a.authErr)
}
u, _, err := a.s.SessionUser(sid)
if err != nil {
t.Fatal(err)
}
testAccessTokens.Store(sid, a.accessJWT(sid, u.ID, time.Now().Add(accessLifetime)))
t.Cleanup(func() { testAccessTokens.Delete(sid) })
}
func addTestAccess(r *http.Request, sid string) {
if token, ok := testAccessTokens.Load(sid); ok {
r.AddCookie(&http.Cookie{Name: "dsh_access", Value: token.(string)})
}
}
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"errors"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type loginSource struct {
started time.Time
count int
}
func configuredOrigin(raw string) (string, error) {
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Hostname() == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return "", errors.New("invalid PANEL_PUBLIC_URL")
}
if strings.Contains(raw, "#") || strings.HasSuffix(u.Host, ":") {
return "", errors.New("invalid PANEL_PUBLIC_URL")
}
port := u.Port()
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "", errors.New("invalid PANEL_PUBLIC_URL")
}
port = strconv.Itoa(n)
}
host := strings.ToLower(u.Hostname())
if net.ParseIP(host) == nil {
if len(host) > 253 {
return "", errors.New("invalid PANEL_PUBLIC_URL")
}
for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", errors.New("invalid PANEL_PUBLIC_URL")
}
for _, c := range label {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
return "", errors.New("invalid PANEL_PUBLIC_URL")
}
}
}
} else if strings.Contains(host, ":") {
host = "[" + host + "]"
}
if port != "" && !(u.Scheme == "https" && port == "443" || u.Scheme == "http" && port == "80") {
host += ":" + port
}
return u.Scheme + "://" + host, nil
}
func configuredProxyCIDRs(raw string, prior error) ([]*net.IPNet, error) {
if prior != nil || strings.TrimSpace(raw) == "" {
return nil, prior
}
var out []*net.IPNet
for _, value := range strings.Split(raw, ",") {
value = strings.TrimSpace(value)
if value == "" {
return nil, errors.New("invalid TRUSTED_PROXY_CIDRS")
}
_, network, err := net.ParseCIDR(value)
if err != nil {
return nil, errors.New("invalid TRUSTED_PROXY_CIDRS")
}
out = append(out, network)
}
return out, nil
}
func ValidateAuthConfig() error {
_, err := configuredOrigin(os.Getenv("PANEL_PUBLIC_URL"))
_, err = configuredProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS"), err)
return err
}
func (a *API) ConfigError() error { return a.loginConfigErr }
func (a *API) acquireLoginSlot() bool {
a.loginMu.Lock()
defer a.loginMu.Unlock()
if a.loginSlots == nil {
a.loginSlots = make(chan struct{}, 8)
}
select {
case a.loginSlots <- struct{}{}:
return true
default:
return false
}
}
func (a *API) loginSource(r *http.Request) string {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
ip := net.ParseIP(peer)
if ip == nil {
return peer
}
trusted := false
for _, network := range a.trustedProxies {
if network.Contains(ip) {
trusted = true
break
}
}
if trusted {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0; i-- {
candidate := net.ParseIP(strings.TrimSpace(parts[i]))
if candidate == nil {
return ip.String()
}
isProxy := false
for _, network := range a.trustedProxies {
if network.Contains(candidate) {
isProxy = true
break
}
}
if !isProxy {
return candidate.String()
}
}
}
return ip.String()
}
func (a *API) allowLoginSource(source string) bool {
now := time.Now()
a.loginMu.Lock()
defer a.loginMu.Unlock()
if a.loginSources == nil {
a.loginSources = make(map[string]loginSource)
}
for key, entry := range a.loginSources {
if now.Sub(entry.started) >= time.Minute {
delete(a.loginSources, key)
}
}
entry, ok := a.loginSources[source]
if !ok {
if len(a.loginSources) >= 1024 {
return false
}
entry = loginSource{started: now}
}
if entry.count >= 30 {
return false
}
entry.count++
a.loginSources[source] = entry
return true
}
+271
View File
@@ -0,0 +1,271 @@
package api
import (
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
func TestConfiguredOriginRejectsInvalidOrigins(t *testing.T) {
for _, raw := range []string{"https://", "https://:8443", "https://panel.test?", "https://panel.test:bad", "ftp://panel.test", "https://panel.test/path"} {
if _, err := configuredOrigin(raw); err == nil {
t.Errorf("accepted invalid origin %q", raw)
}
}
for _, raw := range []string{"https://panel.test", "https://panel.test:8443", "http://127.0.0.1:8088"} {
if got, err := configuredOrigin(raw); err != nil || got != raw {
t.Errorf("origin %q => %q, %v", raw, got, err)
}
}
}
func TestLoginSourceNilMapAndInvalidForwardedChain(t *testing.T) {
a := &API{}
if !a.allowLoginSource("127.0.0.1") {
t.Fatal("nil source map rejected")
}
a.trustedProxies = mustCIDRs(t, "10.0.0.0/8")
r := httptest.NewRequest("POST", "http://panel.test/api/login", nil)
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Forwarded-For", "198.51.100.4,not-an-ip,10.0.0.2")
if got := a.loginSource(r); got != "10.0.0.1" {
t.Fatalf("invalid chain crossed trust boundary: %s", got)
}
}
func mustCIDRs(t *testing.T, raw string) []*net.IPNet {
t.Helper()
proxies, err := configuredProxyCIDRs(raw, nil)
if err != nil {
t.Fatal(err)
}
return proxies
}
func TestSecurityConfigValidation(t *testing.T) {
for _, raw := range []string{"https://panel.test:0", "https://panel.test:65536", "https://panel.test:", "https://panel.test#", "https://bad_host", "https://[bad]", "https://[email protected]"} {
if _, err := configuredOrigin(raw); err == nil {
t.Errorf("accepted %q", raw)
}
}
for raw, want := range map[string]string{"https://PANEL.EXAMPLE:443/": "https://panel.example", "http://PANEL.EXAMPLE:80": "http://panel.example", "https://[::1]:443": "https://[::1]"} {
if got, err := configuredOrigin(raw); err != nil || got != want {
t.Errorf("%q => %q, %v", raw, got, err)
}
}
for _, raw := range []string{"bad", "10.0.0.1", "10.0.0.0/8,", ",", "10.0.0.0/33"} {
if _, err := configuredProxyCIDRs(raw, nil); err == nil {
t.Errorf("accepted CIDRs %q", raw)
}
}
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")
if ValidateAuthConfig() == nil {
t.Fatal("configuration accepted")
}
a := New(nil, t.TempDir())
if a.ConfigError() == nil {
t.Fatal("missing config error")
}
for _, path := range []string{"/api/login", "/api/me", "/api/refresh", "/dsh/example/"} {
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, httptest.NewRequest("POST", path, strings.NewReader("{}")))
if w.Code != 503 {
t.Fatalf("%s: %d", path, w.Code)
}
}
})
}
}
func TestSecurityTLSCookieJarLifecycle(t *testing.T) {
a, _ := authFixture(t)
a.origin = ""
a.loginSlots = nil
a.loginSources = nil
srv := httptest.NewTLSServer(a.Handler())
defer srv.Close()
client := srv.Client()
client.Jar, _ = cookiejar.New(nil)
u, _ := url.Parse(srv.URL + "/api/me")
request := func(method, path, body string, status int) []*http.Cookie {
t.Helper()
r, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
r.Header.Set("Origin", srv.URL)
r.Header.Set("X-CSRF-Token", cookieValue(client.Jar.Cookies(u), "dsh_csrf"))
resp, err := client.Do(r)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != status {
t.Fatalf("%s: %d %s", path, resp.StatusCode, data)
}
return resp.Cookies()
}
check := func(cookies []*http.Cookie, deleting bool) {
t.Helper()
if len(cookies) != 4 {
t.Fatalf("cookies: %v", cookies)
}
for _, c := range cookies {
path := "/"
if c.Name == "dsh_refresh" {
path = "/api"
}
if !c.Secure || c.HttpOnly != (c.Name != "dsh_csrf") || c.Path != path || c.SameSite != http.SameSiteLaxMode || (deleting && c.MaxAge != -1) {
t.Fatalf("cookie: %+v", c)
}
}
}
check(request("POST", "/api/login", `{"username":"alice","password":"password-123"}`, 200), false)
request("GET", "/api/me", "", 200)
old := cookieValue(client.Jar.Cookies(u), "dsh_refresh")
check(request("POST", "/api/refresh", "", 200), false)
if old == cookieValue(client.Jar.Cookies(u), "dsh_refresh") {
t.Fatal("refresh not rotated")
}
request("GET", "/api/me", "", 200)
check(request("POST", "/api/logout", "", 200), true)
if len(client.Jar.Cookies(u)) != 0 {
t.Fatal("logout left cookies")
}
request("GET", "/api/me", "", 401)
}
func TestSecurityLoginBudgetsAndUntrustedHeaders(t *testing.T) {
a, _ := authFixture(t)
a.origin = ""
request := func(peer, origin, body string) int {
r := httptest.NewRequest("POST", "http://panel.test/api/login", strings.NewReader(body))
r.RemoteAddr = peer + ":1234"
r.Header.Set("Origin", origin)
r.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", len(a.loginSources)))
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", "evil.test")
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
return w.Code
}
for i := 0; i < 35; i++ {
if got := request("192.0.2.1", "https://evil.test", "{}"); got != 403 {
t.Fatal(got)
}
for _, body := range []string{"{", "{} garbage", "{} {}", strings.Repeat(" ", 4097)} {
if got := request("192.0.2.1", "http://panel.test", body); got != 400 {
t.Fatal(got)
}
}
}
if len(a.loginSources) != 0 {
t.Fatal("invalid requests consumed budget")
}
for i := 0; i < 30; i++ {
if got := request("192.0.2.1", "http://panel.test", "{}"); got != 401 {
t.Fatal(i, got)
}
}
if got := request("192.0.2.1", "http://panel.test", "{}"); got != 429 {
t.Fatal(got)
}
if got := request("192.0.2.2", "http://panel.test", "{}"); got != 401 {
t.Fatal("other source blocked", got)
}
if len(a.loginSources) != 2 {
t.Fatal("forwarded headers changed source")
}
r := httptest.NewRequest("POST", "http://panel.test", nil)
r.Header.Set("X-Forwarded-Proto", "https")
if a.secureCookies(r) {
t.Fatal("untrusted proto changed cookie policy")
}
a.origin = "https://panel.test"
if !a.secureCookies(r) {
t.Fatal("configured HTTPS not secure")
}
}
func TestSecurityLoginSlotOverloadRecovery(t *testing.T) {
a, req := authFixture(t)
for i := 0; i < 8; i++ {
if !a.acquireLoginSlot() {
t.Fatal("slot unavailable")
}
}
if w := req("POST", "/api/login", "{}", "", nil); w.Code != 503 {
t.Fatal("overload not rejected", w.Code)
}
<-a.loginSlots
if w := req("POST", "/api/login", "{}", "", nil); w.Code != 401 {
t.Fatal("released slot not usable", w.Code)
}
if len(a.loginSlots) != 7 {
t.Fatal("request leaked slot")
}
}
func TestSecurityConcurrentSlotsAndBoundedBuckets(t *testing.T) {
a := &API{}
var wg sync.WaitGroup
results := make(chan bool, 64)
for i := 0; i < 64; i++ {
wg.Add(1)
go func() { defer wg.Done(); results <- a.acquireLoginSlot() }()
}
wg.Wait()
close(results)
count := 0
for ok := range results {
if ok {
count++
}
}
if count != 8 {
t.Fatalf("slots: %d", count)
}
<-a.loginSlots
if !a.acquireLoginSlot() {
t.Fatal("released slot unavailable")
}
for i := 0; i < 1024; i++ {
if !a.allowLoginSource(fmt.Sprint(i)) {
t.Fatal(i)
}
}
if a.allowLoginSource("overflow") || len(a.loginSources) != 1024 {
t.Fatal("unbounded buckets")
}
a.loginSources["0"] = loginSource{started: time.Now().Add(-time.Minute), count: 30}
if !a.allowLoginSource("overflow") || len(a.loginSources) != 1024 {
t.Fatal("expired bucket not reclaimed")
}
b := &API{}
accepted := make(chan bool, 64)
for i := 0; i < 64; i++ {
wg.Add(1)
go func() { defer wg.Done(); accepted <- b.allowLoginSource("same") }()
}
wg.Wait()
close(accepted)
count = 0
for ok := range accepted {
if ok {
count++
}
}
if count != 30 {
t.Fatalf("concurrent budget: %d", count)
}
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"encoding/json"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func TestAuthenticationAndAdmin(t *testing.T) {
s, e := store.Open(filepath.Join(t.TempDir(), "test.sqlite"))
if e != nil {
t.Fatal(e)
}
defer s.Close()
h, _ := bcrypt.GenerateFromPassword([]byte("test-password-123"), bcrypt.MinCost)
if e = s.CreateUser(store.User{ID: "admin", Username: "admin", Admin: true, Quota: 1024}, string(h)); e != nil {
t.Fatal(e)
}
a := New(s, t.TempDir())
a.origin = "http://panel.test"
var access *http.Cookie
request := func(method, path, body, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Origin", a.origin)
r.Header.Set("X-CSRF-Token", csrf)
if cookie != nil {
r.AddCookie(cookie)
if access != nil {
r.AddCookie(access)
}
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
return w
}
if w := request("GET", "/api/me", "", "", nil); w.Code != 401 {
t.Fatal(w.Code)
}
w := request("POST", "/api/login", `{"username":"admin","password":"test-password-123"}`, "", nil)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var login struct {
CSRF string `json:"csrf_token"`
}
json.Unmarshal(w.Body.Bytes(), &login)
var cookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == "dsh_access" {
access = c
}
if c.Name == "dsh_session" {
cookie = c
}
}
if cookie == nil || !cookie.HttpOnly || cookie.Secure {
t.Fatal("wrong local cookie settings")
}
if w = request("GET", "/api/users", "", "", cookie); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if w = request("POST", "/api/users", `{"username":"member","password":"member-password-123"}`, "", cookie); w.Code != 403 {
t.Fatal("CSRF accepted", w.Code)
}
if w = request("POST", "/api/users", `{"username":"member","password":"member-password-123"}`, login.CSRF, cookie); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if w = request("POST", "/api/logout", "", login.CSRF, cookie); w.Code != 200 {
t.Fatal(w.Code)
}
if w = request("GET", "/api/me", "", "", cookie); w.Code != 401 {
t.Fatal("session not revoked")
}
}
func TestOriginRequiredWithoutPublicURL(t *testing.T) {
a := &API{}
for _, tc := range []struct {
origin string
want bool
}{{"", false}, {"http://evil.test", false}, {"http://panel.test", true}} {
r := httptest.NewRequest("POST", "http://panel.test/api/login", nil)
r.Header.Set("Origin", tc.origin)
if a.panelOriginAllowed(r) != tc.want {
t.Fatalf("origin %q", tc.origin)
}
}
}
+200
View File
@@ -0,0 +1,200 @@
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const accessLifetime = 5 * time.Minute
var errAuthentication = errors.New("authentication required")
func (a *API) initAuth() error {
if err := os.MkdirAll(a.root, 0700); err != nil {
return err
}
path := filepath.Join(a.root, "auth-signing.key")
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err == nil {
key := make([]byte, 32)
if _, err = rand.Read(key); err == nil {
_, err = f.Write(key)
}
if err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
} else if !os.IsExist(err) {
return err
}
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 {
return errors.New("unsafe signing key permissions")
}
a.authKey, err = os.ReadFile(path)
if err != nil {
return err
}
if len(a.authKey) != 32 {
return errors.New("invalid signing key")
}
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`)
return err
}
type accessClaims struct {
Subject string `json:"sub"`
Session string `json:"sid"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
Expires int64 `json:"exp"`
}
const jwtHeader = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
func (a *API) accessJWT(sid, uid string, expires time.Time) string {
payload, _ := json.Marshal(accessClaims{uid, sid, "dsh-panel", "dsh-panel", expires.Unix()})
body := jwtHeader + "." + base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, a.authKey)
mac.Write([]byte(body))
return body + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *API) verifyAccess(r *http.Request, sid, uid string) error {
if a.authErr != nil || len(a.authKey) != 32 {
return errAuthentication
}
c, err := r.Cookie("dsh_access")
if err != nil {
return errAuthentication
}
p := strings.Split(c.Value, ".")
if len(p) != 3 || p[0] != jwtHeader {
return errAuthentication
}
sig, err := base64.RawURLEncoding.DecodeString(p[2])
if err != nil {
return errAuthentication
}
mac := hmac.New(sha256.New, a.authKey)
mac.Write([]byte(p[0] + "." + p[1]))
if !hmac.Equal(sig, mac.Sum(nil)) {
return errAuthentication
}
data, err := base64.RawURLEncoding.DecodeString(p[1])
if err != nil {
return errAuthentication
}
var claims accessClaims
if json.Unmarshal(data, &claims) != nil || claims.Subject != uid || claims.Session != sid || claims.Issuer != "dsh-panel" || claims.Audience != "dsh-panel" || claims.Expires <= time.Now().Unix() || claims.Expires > time.Now().Add(accessLifetime).Unix() {
return errAuthentication
}
return nil
}
func refreshToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b), err
}
func refreshHash(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func (a *API) secureCookies(r *http.Request) bool {
secure := r.TLS != nil
if u, err := url.Parse(a.origin); err == nil && u.Scheme == "https" {
secure = true
}
return secure
}
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) {
for _, c := range []*http.Cookie{
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true},
{Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())},
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true},
{Name: "dsh_csrf", Value: csrf, Path: "/"},
} {
c.Secure = a.secureCookies(r)
c.SameSite = http.SameSiteLaxMode
http.SetCookie(w, c)
}
w.Header().Set("Cache-Control", "no-store")
}
func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
if a.authErr != nil {
fail(w, 503, "authentication unavailable")
return
}
sid, err := r.Cookie("dsh_session")
if err != nil {
fail(w, 401, "authentication required")
return
}
u, csrf, err := a.s.SessionUser(sid.Value)
if err != nil {
fail(w, 401, "authentication required")
return
}
old, err := r.Cookie("dsh_refresh")
if err != nil {
fail(w, 401, "authentication required")
return
}
token, err := refreshToken()
if err != nil {
fail(w, 500, "session error")
return
}
result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix())
if err != nil {
fail(w, 500, "session error")
return
}
n, err := result.RowsAffected()
if err != nil || n != 1 {
fail(w, 401, "authentication required")
return
}
a.authCookies(w, r, sid.Value, u.ID, csrf, token)
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
}
func (a *API) accountLocked(username string) (bool, error) {
var until int64
err := a.s.DB.QueryRow("SELECT locked_until FROM auth_failures WHERE username=?", username).Scan(&until)
if err == sql.ErrNoRows {
return false, nil
}
return until > time.Now().Unix(), err
}
func (a *API) failedLogin(username string) error {
_, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0)
ON CONFLICT(username) DO UPDATE SET
failures=CASE WHEN locked_until>0 AND locked_until<=? THEN 1 ELSE failures+1 END,
locked_until=CASE WHEN locked_until>0 AND locked_until<=? THEN 0 WHEN failures+1>=5 THEN ? ELSE 0 END`, username, time.Now().Unix(), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix())
return err
}
+215
View File
@@ -0,0 +1,215 @@
package api
import (
"bytes"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func authFixture(t *testing.T) (*API, func(string, string, string, string, []*http.Cookie) *httptest.ResponseRecorder) {
t.Helper()
s, e := store.Open(filepath.Join(t.TempDir(), "db"))
if e != nil {
t.Fatal(e)
}
t.Cleanup(s.Close)
h, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.MinCost)
for _, id := range []string{"alice", "bob"} {
if e = s.CreateUser(store.User{ID: id, Username: id}, string(h)); e != nil {
t.Fatal(e)
}
}
a := New(s, t.TempDir())
a.origin = "https://panel.test"
return a, func(method, path, body, csrf string, cookies []*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Origin", a.origin)
r.Header.Set("X-CSRF-Token", csrf)
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
return w
}
}
func cookieValue(cs []*http.Cookie, name string) string {
for _, c := range cs {
if c.Name == name {
return c.Value
}
}
return ""
}
func TestAccessRefreshLifecycle(t *testing.T) {
a, req := authFixture(t)
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
cs := w.Result().Cookies()
sid := cookieValue(cs, "dsh_session")
csrf := cookieValue(cs, "dsh_csrf")
for _, name := range []string{"dsh_access", "dsh_refresh"} {
v := cookieValue(cs, name)
if v == "" || strings.Contains(w.Body.String(), v) {
t.Fatal("missing/leaked", name)
}
}
for _, c := range cs {
if !c.Secure || (c.Name != "dsh_csrf" && !c.HttpOnly) {
t.Fatal("unsafe cookie", c.Name)
}
}
if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}}); w.Code != 401 {
t.Fatal("missing access accepted", w.Code)
}
expired := append([]*http.Cookie{}, cs...)
for i, c := range expired {
if c.Name == "dsh_access" {
expired[i] = &http.Cookie{Name: c.Name, Value: a.accessJWT(sid, "alice", time.Now().Add(-time.Minute))}
}
}
if w = req("GET", "/api/me", "", "", expired); w.Code != 401 {
t.Fatal("expired access accepted")
}
if w = req("POST", "/api/refresh", "", "", expired); w.Code != 403 {
t.Fatal("refresh bypassed csrf", w.Code)
}
w = req("POST", "/api/refresh", "", csrf, expired)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
fresh := w.Result().Cookies()
if cookieValue(fresh, "dsh_refresh") == cookieValue(cs, "dsh_refresh") {
t.Fatal("not rotated")
}
if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 401 {
t.Fatal("replay accepted", w.Code)
}
if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 {
t.Fatal(w.Code)
}
if w = req("POST", "/api/logout", "", csrf, fresh); w.Code != 200 {
t.Fatal(w.Code)
}
if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 {
t.Fatal("logout access valid")
}
if w = req("POST", "/api/refresh", "", csrf, fresh); w.Code == 200 {
t.Fatal("logout refresh valid")
}
}
func TestAccountLockAndPersistentKey(t *testing.T) {
a, req := authFixture(t)
for i := 0; i < 5; i++ {
if w := req("POST", "/api/login", `{"username":"alice","password":"wrong"}`, "", nil); w.Code != 401 {
t.Fatal(w.Code)
}
}
if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 429 {
t.Fatal("not locked", w.Code)
}
if w := req("POST", "/api/login", `{"username":"bob","password":"password-123"}`, "", nil); w.Code != 200 {
t.Fatal("other account locked", w.Code)
}
b := New(a.s, a.root)
if !bytes.Equal(a.authKey, b.authKey) || b.authErr != nil {
t.Fatal("key not persistent", b.authErr)
}
fi, e := os.Stat(filepath.Join(a.root, "auth-signing.key"))
if e != nil || fi.Mode().Perm() != 0600 {
t.Fatal("unsafe key")
}
if locked, e := b.accountLocked("alice"); e != nil || !locked {
t.Fatal("lock not persistent", e)
}
if _, e = a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); e != nil {
t.Fatal(e)
}
if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 200 {
t.Fatal("lock did not expire", w.Code)
}
}
func TestRefreshOriginConcurrentRotationAndJWTBinding(t *testing.T) {
a, req := authFixture(t)
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
cs := w.Result().Cookies()
sid := cookieValue(cs, "dsh_session")
csrf := cookieValue(cs, "dsh_csrf")
var stored string
if err := a.s.DB.QueryRow("SELECT token_hash FROM auth_refresh WHERE sid=?", sid).Scan(&stored); err != nil || stored != refreshHash(cookieValue(cs, "dsh_refresh")) {
t.Fatal("refresh not hashed", err)
}
for _, token := range []string{cookieValue(cs, "dsh_access") + "x", a.accessJWT("other", "alice", time.Now().Add(accessLifetime)), a.accessJWT(sid, "bob", time.Now().Add(accessLifetime)), "eyJhbGciOiJub25lIn0.e30."} {
if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}, {Name: "dsh_access", Value: token}}); w.Code != 401 {
t.Fatal("invalid JWT accepted")
}
}
for _, origin := range []string{"", "https://evil.test"} {
r := httptest.NewRequest("POST", "/api/refresh", nil)
r.Header.Set("Origin", origin)
r.Header.Set("X-CSRF-Token", csrf)
for _, c := range cs {
r.AddCookie(c)
}
w = httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("origin accepted", w.Code)
}
}
var wg sync.WaitGroup
codes := make(chan int, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() { defer wg.Done(); codes <- req("POST", "/api/refresh", "", csrf, cs).Code }()
}
wg.Wait()
close(codes)
counts := map[int]int{}
for code := range codes {
counts[code]++
}
if counts[200] != 1 || counts[401] != 1 {
t.Fatal(counts)
}
b := New(a.s, a.root)
r := httptest.NewRequest("GET", "/api/me", nil)
for _, c := range cs {
r.AddCookie(c)
}
w = httptest.NewRecorder()
b.Handler().ServeHTTP(w, r)
if w.Code != 200 {
t.Fatal("restart rejected access", w.Code)
}
a.s.DeleteSession(sid)
if w = req("GET", "/api/me", "", "", cs); w.Code != 401 {
t.Fatal("deleted binding accepted")
}
}
func TestUnsafeAuthKeyFailsClosed(t *testing.T) {
a, req := authFixture(t)
if err := os.Chmod(filepath.Join(a.root, "auth-signing.key"), 0644); err != nil {
t.Fatal(err)
}
b := New(a.s, a.root)
if b.authErr == nil {
t.Fatal("unsafe key accepted")
}
a.authErr = b.authErr
if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 503 {
t.Fatal(w.Code)
}
}
+656
View File
@@ -0,0 +1,656 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/backup"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
)
var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
var configBodySlots = make(chan struct{}, 4)
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
controller := http.NewResponseController(w)
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
var flush func()
w, flush = deferJSONResponse(w)
defer flush()
}
if r.Method != http.MethodGet {
select {
case configBodySlots <- struct{}{}:
defer func() {
r.Body = http.NoBody
<-configBodySlots
}()
default:
fail(w, 503, "configuration uploads busy; retry later")
return
}
limit := int64(4096)
multipartUpload := action == "backups" && strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data")
if action == "profile" {
limit = 2 << 20
} else if multipartUpload {
limit = min(64<<20, backup.MaxEncryptedBytes+16384)
}
if !configReadBody(w, r, controller, limit) {
return
}
if multipartUpload {
defer func() {
if r.MultipartForm != nil {
r.MultipartForm.RemoveAll()
}
}()
if r.ParseMultipartForm(1<<20) != nil {
fail(w, 400, "invalid or oversized backup upload")
return
}
}
}
a.mu.Lock()
locked := true
defer func() {
if locked {
a.mu.Unlock()
}
}()
if r.Method != http.MethodGet && a.workspaceReaders[i.ID] > 0 {
fail(w, 409, "instance files busy; retry later")
return
}
if r.Method != http.MethodGet {
workspaceMu.Lock()
defer workspaceMu.Unlock()
}
current, e := a.s.GetInstance(i.ID)
if e != nil || current.UID != i.UID {
fail(w, 404, "not found")
return
}
i = current
if i.Status == "error" || a.upgradeRecoveryRequired(i) {
fail(w, 409, "instance recovery required")
return
}
home := filepath.Join(a.root, "workspaces", i.UID, "instances", i.ID, "home")
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
storage, e := os.OpenRoot(a.root)
if e != nil {
fail(w, 500, "storage unavailable")
return
}
defer storage.Close()
if configCheckPath(storage, filepath.ToSlash(filepath.Join("backups", i.UID, i.ID, "archive"))) != nil || configCheckPath(storage, filepath.ToSlash(filepath.Join("workspaces", i.UID, "instances", i.ID, "home", "profile"))) != nil {
fail(w, 400, "unsafe storage path")
return
}
if e = os.MkdirAll(dir, 0700); e != nil {
fail(w, 500, "backup storage unavailable")
return
}
wasRunning := false
if r.Method != "GET" {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
c, e := a.docker.Inspect(ctx, i.ContainerID)
if e != nil {
fail(w, 502, "container inspection failed")
return
}
wasRunning = c.State.Running
if c.State.Paused || c.State.Restarting {
fail(w, 409, "unsupported container state")
return
}
if c.State.Running && action == "profile" {
fail(w, 409, "stop instance before changing configuration or backing up")
return
}
}
switch action {
case "profile":
parent, e := upgradeRoot(a.root, i)
if e != nil {
fail(w, 400, "unsafe home")
return
}
defer parent.Close()
if e = configCheckPath(parent, "home/profiles/web/cordis.patch.yml"); e != nil {
fail(w, 400, "unsafe profile")
return
}
root, e := configProfileRoot(parent, r.Method == "PUT")
if e != nil {
if r.Method == "GET" && errors.Is(e, os.ErrNotExist) {
writeJSON(w, map[string]string{"content": ""})
return
}
fail(w, 404, "home unavailable")
return
}
defer root.Close()
const path = "cordis.patch.yml"
if r.Method == "GET" {
f, e := root.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if errors.Is(e, os.ErrNotExist) {
writeJSON(w, map[string]string{"content": ""})
return
}
if e != nil {
fail(w, 400, "profile unavailable")
return
}
defer f.Close()
st, e := f.Stat()
if e != nil || !st.Mode().IsRegular() || st.Size() > 1<<20 {
fail(w, 400, "invalid profile")
return
}
b, e := io.ReadAll(io.LimitReader(f, 1<<20))
if e != nil {
fail(w, 500, "read failed")
return
}
writeJSON(w, map[string]string{"content": string(b)})
return
}
if r.Method != "PUT" {
fail(w, 405, "method not allowed")
return
}
var in struct {
Content string `json:"content"`
Restart bool `json:"restart"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&in) != nil || len(in.Content) > 1<<20 {
fail(w, 400, "invalid profile")
return
}
var document any
if yaml.Unmarshal([]byte(in.Content), &document) != nil {
fail(w, 400, "invalid YAML")
return
}
var previous int64
if st, err := root.Lstat(path); err == nil {
if !st.Mode().IsRegular() {
fail(w, 400, "invalid profile")
return
}
previous = st.Size()
} else if !errors.Is(err, os.ErrNotExist) {
fail(w, 400, "profile unavailable")
return
}
if e = a.quotaGrowth(i.UID, int64(len(in.Content))-previous); e != nil {
fail(w, 413, "quota exceeded or usage unavailable")
return
}
tmp := ".panel-" + store.ID()
f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
fail(w, 500, "profile write failed")
return
}
defer root.Remove(tmp)
_, e = f.WriteString(in.Content)
if e == nil {
e = f.Chown(1000, 1000)
}
if e == nil {
e = f.Sync()
}
f.Close()
if e == nil {
e = root.Rename(tmp, path)
}
if e != nil {
fail(w, 500, "profile write failed")
return
}
a.s.AddAudit(i.UID, "profile.save", i.ID, "")
if in.Restart {
if a.configRestart(i) != nil {
fail(w, 502, "profile saved but restart failed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
if a.upgradeReady(ctx, i.ContainerID, i.ID, a.runtimeHost(r)) != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "profile saved but runtime readiness failed")
return
}
a.s.SetInstance(i.ID, "running")
}
writeJSON(w, map[string]bool{"ok": true})
case "backups":
if r.Method == "GET" {
if id := r.URL.Query().Get("download"); id != "" {
if !backupIDPattern.MatchString(id) {
fail(w, 400, "invalid backup ID")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename="+id+".dshbak")
root, e := os.OpenRoot(dir)
if e != nil {
fail(w, 404, "backup unavailable")
return
}
defer root.Close()
f, e := root.OpenFile(id+".dshbak", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if e != nil {
fail(w, 404, "backup unavailable")
return
}
defer f.Close()
st, e := f.Stat()
if e != nil || !st.Mode().IsRegular() {
fail(w, 400, "invalid backup")
return
}
a.mu.Unlock()
locked = false
http.ServeContent(w, r, id+".dshbak", st.ModTime(), f)
return
}
entries, e := os.ReadDir(dir)
if e != nil {
fail(w, 500, "backup listing failed")
return
}
out := []map[string]any{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".dshbak" {
continue
}
st, e := entry.Info()
if e != nil || !st.Mode().IsRegular() {
continue
}
id := entry.Name()[:len(entry.Name())-7]
kind, schema := "legacy", 0
_ = a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID).Scan(&kind, &schema)
out = append(out, map[string]any{"id": id, "size": st.Size(), "created_at": st.ModTime(), "kind": kind, "schema": schema})
}
writeJSON(w, out)
return
}
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
entries, _ := os.ReadDir(dir)
if len(entries) >= 20 {
fail(w, 409, "backup retention limit reached (20); administrator cleanup required")
return
}
id := store.ID()
path := filepath.Join(dir, id+".dshbak")
kind, schema := "manual", 2
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
kind, schema = "imported", 0
source, _, err := r.FormFile("file")
if err != nil {
fail(w, 400, "backup file required")
return
}
defer source.Close()
dest, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
fail(w, 500, "backup storage unavailable")
return
}
n, err := io.Copy(dest, io.LimitReader(source, backup.MaxEncryptedBytes+1))
closeErr := dest.Close()
if err != nil || closeErr != nil || n > backup.MaxEncryptedBytes {
os.Remove(path)
fail(w, 400, "invalid or oversized backup upload")
return
}
key, _ := a.configKey()
_, cleanup, err := backup.Prepare(path, r.FormValue("password"), key)
clear(key)
if err != nil {
os.Remove(path)
fail(w, 400, "invalid backup or original backup password")
return
}
cleanup()
} else {
var in struct {
Password string `json:"password"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || len(in.Password) == 0 || len(in.Password) > 72 {
fail(w, 400, "login password required")
return
}
var hash string
if a.s.DB.QueryRow("SELECT pass_hash FROM users WHERE id=?", i.UID).Scan(&hash) != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
fail(w, 403, "incorrect login password")
return
}
if wasRunning {
if e = a.configStop(r.Context(), i.ContainerID); e != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "runtime stop failed")
return
}
}
if e = backup.CreateWithPassword(home, path, in.Password, backup.Metadata{DSHVersion: i.Version, CreatedAt: time.Now().UTC()}); e != nil {
if wasRunning {
a.configRestart(i)
}
fail(w, 400, "backup failed: unsafe or oversized configuration")
return
}
}
st, _ := os.Stat(path)
if e = a.indexBackup(i, id, kind, schema, st.Size()); e != nil {
os.Remove(path)
if wasRunning && kind == "manual" {
a.configRestart(i)
}
fail(w, 500, "backup index unavailable")
return
}
if wasRunning && kind == "manual" && a.configRestart(i) != nil {
fail(w, 502, "backup saved; runtime restart requires recovery")
return
}
a.s.AddAudit(i.UID, "backup.create", i.ID, id)
writeJSON(w, map[string]any{"id": id, "size": st.Size(), "created_at": st.ModTime(), "kind": kind, "schema": schema})
case "restore":
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
var in struct {
ID string `json:"backup_id"`
Password string `json:"password"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || !backupIDPattern.MatchString(in.ID) {
fail(w, 400, "invalid backup ID")
return
}
key, _ := a.configKey()
restore, cleanup, e := backup.Prepare(filepath.Join(dir, in.ID+".dshbak"), in.Password, key)
clear(key)
if e != nil {
fail(w, 400, "invalid backup or original backup password")
return
}
defer cleanup()
staging, e := os.MkdirTemp("", "dsh-restore-quota-")
if e != nil {
fail(w, 500, "restore staging unavailable")
return
}
defer os.RemoveAll(staging)
if e = restore(staging); e != nil {
fail(w, 400, "invalid restore contents")
return
}
staged, e := os.OpenRoot(staging)
if e != nil {
fail(w, 500, "restore staging unavailable")
return
}
restoredBytes, e := quotaTreeBytes(staged, ".")
staged.Close()
if e != nil {
fail(w, 413, "restore size unavailable")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
parent, e := upgradeRoot(a.root, i)
if e != nil {
fail(w, 500, "storage unavailable")
return
}
defer parent.Close()
f, e := parent.Open(".")
if e != nil {
fail(w, 500, "snapshot storage unavailable")
return
}
entries, e := f.ReadDir(-1)
f.Close()
count := 0
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") {
count++
}
}
if e != nil || count >= 3 {
fail(w, 409, "restore snapshot limit reached; administrator cleanup required")
return
}
snapshot := "pre-restore-" + store.ID()
if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil {
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
if wasRunning {
if e = a.configStop(ctx, i.ContainerID); e != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "runtime stop failed")
return
}
}
snapshotBytes, quotaErr := a.quotaSnapshot(i.UID, parent, restoredBytes)
if quotaErr != nil {
if wasRunning {
a.configRestart(i)
}
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
if e = upgradeCopy(r.Context(), parent, "home", snapshot, snapshotBytes); e != nil {
cleanupErr := parent.RemoveAll(snapshot)
if wasRunning {
a.configRestart(i)
}
if cleanupErr != nil {
a.s.AddAudit(i.UID, "backup.snapshot.cleanup_failed", i.ID, snapshot)
fail(w, 500, "restore snapshot cleanup requires administrator recovery")
return
}
fail(w, 400, "cannot snapshot home for restore")
return
}
if _, e = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); e != nil {
if wasRunning {
a.configRestart(i)
}
fail(w, 500, "cannot record restore state")
return
}
restoreErr := restore(home)
if restoreErr == nil {
var root *os.Root
root, restoreErr = os.OpenRoot(home)
if restoreErr == nil {
for _, p := range []string{"profiles", ".credentials.yaml", "cordis.patch.yml", "dsh-ssh.json", "skins", "pets", "agent-presets", ".agent-presets"} {
if restoreErr = configChownTree(root, p); restoreErr != nil {
break
}
}
root.Close()
}
}
if restoreErr == nil {
restoreErr = a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil)
}
if restoreErr == nil {
restoreErr = a.upgradeReady(ctx, i.ContainerID, i.ID, a.runtimeHost(r))
}
if restoreErr == nil && !wasRunning {
restoreErr = a.configStop(ctx, i.ContainerID)
}
if restoreErr != nil {
rollbackCtx, done := context.WithTimeout(context.Background(), 45*time.Second)
defer done()
if a.configStop(rollbackCtx, i.ContainerID) != nil {
fail(w, 500, "restore rollback requires runtime stop")
return
}
failed := "failed-restore-" + store.ID()
if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 500, "restore rollback requires administrator recovery")
return
}
if wasRunning && a.configRestart(i) != nil {
fail(w, 500, "restore rollback restart requires recovery")
return
}
status := "exited"
if wasRunning {
status = "running"
}
if _, e = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); e != nil {
fail(w, 500, "restore state requires administrator recovery")
return
}
fail(w, 400, "restore rejected; previous home restored")
return
}
status := "exited"
if wasRunning {
status = "running"
}
if _, e = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); e != nil {
fail(w, 500, "restore state requires administrator recovery")
return
}
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
a.s.AddAudit(i.UID, "backup.restore", i.ID, in.ID)
writeJSON(w, map[string]bool{"ok": true})
default:
fail(w, 404, "not found")
}
}
func configReadBody(w http.ResponseWriter, r *http.Request, controller *http.ResponseController, limit int64) bool {
if r.ContentLength > limit {
fail(w, 413, "configuration request too large")
return false
}
if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 500, "cannot set configuration read deadline")
return false
}
defer controller.SetReadDeadline(time.Time{})
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil {
var oversized *http.MaxBytesError
switch {
case errors.As(err, &oversized):
fail(w, 413, "configuration request too large")
case errors.Is(err, os.ErrDeadlineExceeded):
fail(w, 408, "configuration upload timed out")
default:
fail(w, 400, "cannot read configuration request")
}
return false
}
r.Body = io.NopCloser(bytes.NewReader(body))
return true
}
func (a *API) configKey() ([]byte, error) {
path := filepath.Join(a.root, "backup.key")
f, e := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if e == nil {
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() {
return nil, errors.New("invalid key")
}
b, e := io.ReadAll(io.LimitReader(f, 33))
if e != nil || len(b) != 32 {
return nil, errors.New("invalid key")
}
return b, nil
}
return nil, e
}
func configCheckPath(root *os.Root, name string) error {
parts := strings.Split(name, "/")
for n := range parts {
st, e := root.Lstat(strings.Join(parts[:n+1], "/"))
if errors.Is(e, os.ErrNotExist) {
return nil
}
if e != nil {
return e
}
if st.Mode()&os.ModeSymlink != 0 || (n < len(parts)-1 && !st.IsDir()) {
return errors.New("unsafe configuration path")
}
}
return nil
}
func configProfileRoot(parent *os.Root, create bool) (*os.Root, error) {
root, err := parent.OpenRoot(".")
if err != nil {
return nil, err
}
for _, name := range []string{"home", "profiles", "web"} {
created := false
st, e := root.Lstat(name)
if errors.Is(e, os.ErrNotExist) && create && name != "home" {
e = root.Mkdir(name, 0750)
if e == nil {
created = true
st, e = root.Lstat(name)
}
}
if e != nil {
root.Close()
return nil, e
}
if !st.IsDir() {
root.Close()
return nil, errors.New("unsafe profile directory")
}
next, e := root.OpenRoot(name)
root.Close()
if e != nil {
return nil, e
}
actual, e := next.Stat(".")
if e != nil || !os.SameFile(st, actual) {
next.Close()
return nil, errors.New("profile directory changed")
}
if created {
if e = next.Chown(".", 1000, 1000); e != nil {
next.Close()
return nil, e
}
}
root = next
}
return root, nil
}
+205
View File
@@ -0,0 +1,205 @@
package api
import (
"bytes"
"io"
"mime/multipart"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
type configPausedBody struct {
io.Reader
started chan struct{}
release chan struct{}
once sync.Once
}
func (b *configPausedBody) Read(p []byte) (int, error) {
b.once.Do(func() { close(b.started) })
<-b.release
return b.Reader.Read(p)
}
func TestConfigSlowBodyRevalidatesWithoutLocks(t *testing.T) {
for _, action := range []string{"profile", "backups", "restore", "multipart"} {
for _, change := range []string{"lease", "deleted"} {
t.Run(action+"/"+change, func(t *testing.T) {
a, i, _ := configTestAPI(t, false)
a.workspaceReaders = make(map[string]int)
body := &configPausedBody{Reader: strings.NewReader(`{}`), started: make(chan struct{}), release: make(chan struct{})}
r := httptest.NewRequest("POST", "/", body)
if action == "profile" {
r.Method = "PUT"
}
target := action
if action == "multipart" {
target = "backups"
var buf bytes.Buffer
form := multipart.NewWriter(&buf)
part, _ := form.CreateFormFile("file", "test.dshbak")
part.Write(bytes.Repeat([]byte("x"), 2<<20))
form.Close()
body.Reader = &buf
r.Header.Set("Content-Type", form.FormDataContentType())
t.Setenv("TMPDIR", t.TempDir())
}
w := httptest.NewRecorder()
done := make(chan struct{})
go func() { defer close(done); a.config(w, r, i, target) }()
defer func() {
select {
case <-body.release:
default:
close(body.release)
}
<-done
}()
select {
case <-body.started:
case <-time.After(3 * time.Second):
t.Fatal("body read did not start")
}
if !a.mu.TryLock() {
t.Fatal("slow body holds API lock")
}
if change == "lease" {
a.workspaceReaders[i.ID]++
}
a.mu.Unlock()
if !workspaceMu.TryLock() {
t.Fatal("slow body holds workspace lock")
}
workspaceMu.Unlock()
want := 409
if change == "deleted" {
if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id=?", i.ID); err != nil {
t.Fatal(err)
}
want = 404
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("config did not finish")
}
if w.Code != want {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if action == "multipart" {
entries, err := os.ReadDir(os.Getenv("TMPDIR"))
if err != nil || len(entries) != 0 {
t.Fatalf("multipart temp files remain: %v %v", entries, err)
}
}
})
}
}
}
type configDeadlineWriter struct {
*httptest.ResponseRecorder
deadlines []time.Time
}
func (w *configDeadlineWriter) SetReadDeadline(deadline time.Time) error {
w.deadlines = append(w.deadlines, deadline)
return nil
}
type configErrorBody struct{}
func (configErrorBody) Read([]byte) (int, error) { return 0, os.ErrDeadlineExceeded }
func TestConfigBodyErrorsReturnWithoutLocks(t *testing.T) {
for _, tc := range []struct {
name string
body io.Reader
want int
}{
{"oversized", strings.NewReader(strings.Repeat("x", 4097)), 413},
{"timeout", configErrorBody{}, 408},
{"multipart", strings.NewReader("bad multipart"), 400},
} {
t.Run(tc.name, func(t *testing.T) {
a := &API{}
r := httptest.NewRequest("POST", "/", tc.body)
r.ContentLength = -1
if tc.name == "multipart" {
r.Header.Set("Content-Type", "multipart/form-data; boundary=test")
}
w := &configDeadlineWriter{ResponseRecorder: httptest.NewRecorder()}
a.mu.Lock()
workspaceMu.Lock()
done := make(chan struct{})
go func() { defer close(done); a.config(w, r, store.Instance{}, "backups") }()
select {
case <-done:
workspaceMu.Unlock()
a.mu.Unlock()
case <-time.After(3 * time.Second):
workspaceMu.Unlock()
a.mu.Unlock()
t.Fatal("body error waited for locks")
}
if w.Code != tc.want {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if len(w.deadlines) != 2 || w.deadlines[0].IsZero() || !w.deadlines[1].IsZero() {
t.Fatalf("deadlines: %v", w.deadlines)
}
})
}
}
func TestConfigBodySlotsRejectWithoutReading(t *testing.T) {
for n := 0; n < cap(configBodySlots); n++ {
configBodySlots <- struct{}{}
}
defer func() {
for n := 0; n < cap(configBodySlots); n++ {
<-configBodySlots
}
}()
w := httptest.NewRecorder()
(&API{}).config(w, httptest.NewRequest("POST", "/", configErrorBody{}), store.Instance{}, "backups")
if w.Code != 503 {
t.Fatalf("status %d", w.Code)
}
}
func TestConfigSlowResponseReleasesLocks(t *testing.T) {
for _, tc := range []struct{ method, action string }{{"POST", "restore"}, {"GET", "profile"}, {"GET", "backups"}} {
t.Run(tc.method+"/"+tc.action, func(t *testing.T) {
a, i, _ := configTestAPI(t, false)
w := &pausedDownload{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
done := make(chan struct{})
go func() {
defer close(done)
a.config(w, httptest.NewRequest(tc.method, "/", strings.NewReader(`{}`)), i, tc.action)
}()
defer func() { close(w.release); <-done }()
select {
case <-w.started:
case <-time.After(3 * time.Second):
t.Fatal("response write did not start")
}
if !a.mu.TryLock() {
t.Fatal("slow response holds API lock")
}
a.mu.Unlock()
if !workspaceMu.TryLock() {
t.Fatal("slow response holds workspace lock")
}
workspaceMu.Unlock()
})
}
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"context"
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"net/url"
"os"
"path/filepath"
"time"
)
func (a *API) indexBackup(i store.Instance, id, kind string, schema int, size int64) error {
_, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS backups (id TEXT PRIMARY KEY, uid TEXT NOT NULL, instance_id TEXT NOT NULL, kind TEXT NOT NULL, schema INTEGER NOT NULL, size INTEGER NOT NULL, created_at INTEGER NOT NULL)")
if err != nil {
return err
}
_, err = a.s.DB.Exec("INSERT INTO backups(id,uid,instance_id,kind,schema,size,created_at) VALUES(?,?,?,?,?,?,?)", id, i.UID, i.ID, kind, schema, size, time.Now().Unix())
return err
}
func configChownTree(root *os.Root, name string) error {
st, err := root.Lstat(name)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if st.Mode()&os.ModeSymlink != 0 {
return root.Lchown(name, 1000, 1000)
}
if err = root.Chown(name, 1000, 1000); err != nil {
return err
}
if !st.IsDir() {
return nil
}
f, err := root.Open(name)
if err != nil {
return err
}
entries, err := f.ReadDir(-1)
f.Close()
if err != nil {
return err
}
for _, entry := range entries {
if err = configChownTree(root, filepath.Join(name, entry.Name())); err != nil {
return err
}
}
return nil
}
func (a *API) configStop(ctx context.Context, id string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(id)+"/stop?t=10", nil, nil); err != nil {
return err
}
c, err := a.docker.Inspect(ctx, id)
if err != nil {
return err
}
if c.State.Running || c.State.Paused || c.State.Restarting {
return errors.New("runtime did not stop")
}
return nil
}
func (a *API) configRestart(i store.Instance) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil)
if err != nil {
a.s.SetInstance(i.ID, "error")
}
return err
}
+404
View File
@@ -0,0 +1,404 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
)
func TestConfigPasswordBackupImport(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost)
if _, err := a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: original")
for _, body := range []string{`{}`, `{"password":"wrong"}`} {
if w := configCall(a, i, "POST", "backups", body); w.Code < 400 {
t.Fatal("accepted wrong password")
}
}
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var out struct {
ID string `json:"id"`
}
json.Unmarshal(w.Body.Bytes(), &out)
if _, err := os.Stat(filepath.Join(a.root, "backup.key")); !os.IsNotExist(err) {
t.Fatal("new backup persisted key")
}
encrypted, err := os.ReadFile(filepath.Join(a.root, "backups", i.UID, i.ID, out.ID+".dshbak"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(encrypted, []byte("secret: original")) {
t.Fatal("plaintext archive")
}
hash, _ = bcrypt.GenerateFromPassword([]byte("changed-password"), bcrypt.MinCost)
a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID)
configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: changed")
w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"wrong"}`)
if w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
entries, _ := os.ReadDir(filepath.Dir(home))
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "pre-restore-") {
t.Fatal("bad password created snapshot")
}
}
var body bytes.Buffer
form := multipart.NewWriter(&body)
part, _ := form.CreateFormFile("file", "external.dshbak")
part.Write(encrypted)
form.WriteField("password", "original-password")
form.Close()
req := httptest.NewRequest("POST", "/api/instances/one/backups", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
w = httptest.NewRecorder()
a.config(w, req, i, "backups")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"original-password"}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
restored, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if string(restored) != "secret: original" {
t.Fatal(string(restored))
}
}
func configTestAPI(t *testing.T, running bool, failures ...string) (*API, store.Instance, string) {
t.Helper()
var mu sync.Mutex
httpFailure := false
backupRestart := running
failure := ""
if len(failures) != 0 {
failure = failures[0]
}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if httpFailure {
httpFailure = false
running = false
w.WriteHeader(503)
return
}
if r.URL.Query().Get("token") == "test-token" {
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"})
w.WriteHeader(303)
return
}
w.WriteHeader(200)
}))
t.Cleanup(upstream.Close)
transport := runtimeTransport
runtimeTransport = transport.Clone()
runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://"))
}
t.Cleanup(func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport })
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case strings.HasSuffix(r.URL.Path, "/stop"):
running = false
w.WriteHeader(204)
case strings.HasSuffix(r.URL.Path, "/start"):
if backupRestart {
backupRestart = false
running = true
w.WriteHeader(204)
return
}
running = failure != "reconcile"
httpFailure = failure == "readiness"
failure = ""
w.WriteHeader(204)
case strings.Contains(r.URL.Path, "/exec"), strings.HasSuffix(r.URL.Path, "/restart"):
t.Error("restore must use entrypoint prestart, not exec/restart")
w.WriteHeader(500)
case strings.HasSuffix(r.URL.Path, "/logs"):
fmt.Fprint(w, "http://runtime/?token=test-token")
default:
writeJSON(w, map[string]any{"State": map[string]any{"Running": running}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}})
}
})
hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost)
a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash))
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", Status: "exited", Created: time.Now()}
if e := a.s.AddInstance(i); e != nil {
t.Fatal(e)
}
home := filepath.Join(a.root, "workspaces", "owner", "instances", "one", "home")
if e := os.MkdirAll(home, 0750); e != nil {
t.Fatal(e)
}
return a, i, home
}
func configCall(a *API, i store.Instance, method, action, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, "/api/instances/one/"+action, strings.NewReader(body))
a.config(w, r, i, strings.Split(action, "?")[0])
return w
}
func configWrite(t *testing.T, path, content string) {
t.Helper()
if e := os.WriteFile(path, []byte(content), 0600); e != nil {
t.Fatal(e)
}
}
func TestConfigProfile(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
for _, tc := range []struct {
method, body string
code int
}{
{"GET", "", 200}, {"PUT", `{"content":"foo: ["}`, 400}, {"PUT", `{"content":"foo: bar\n"}`, 200}, {"GET", "", 200},
} {
w := configCall(a, i, tc.method, "profile", tc.body)
if w.Code != tc.code {
t.Fatalf("%s: %d %s", tc.method, w.Code, w.Body.String())
}
}
for _, path := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} {
st, e := os.Stat(filepath.Join(home, path))
if e != nil {
t.Fatal(e)
}
stat := st.Sys().(*syscall.Stat_t)
if stat.Uid != 1000 || stat.Gid != 1000 {
t.Fatalf("ownership of %s: %+v", path, stat)
}
}
w := configCall(a, i, "GET", "profile", "")
if !strings.Contains(w.Body.String(), "foo: bar") {
t.Fatal(w.Body.String())
}
}
func TestConfigProfileSymlinks(t *testing.T) {
for _, level := range []string{"profiles", "web", "leaf", "home"} {
t.Run(level, func(t *testing.T) {
a, i, home := configTestAPI(t, false)
secret := "credential-secret-never-return"
configWrite(t, filepath.Join(home, ".credentials.yaml"), secret)
if e := os.MkdirAll(filepath.Join(home, "profiles", "web"), 0750); e != nil {
t.Fatal(e)
}
var link, target string
switch level {
case "profiles":
link = filepath.Join(home, "profiles")
target = "private"
if e := os.MkdirAll(filepath.Join(home, "private", "web"), 0750); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(home, "private", "web", "cordis.patch.yml"), secret)
case "web":
link = filepath.Join(home, "profiles", "web")
target = "../private"
if e := os.Mkdir(filepath.Join(home, "private"), 0750); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(home, "private", "cordis.patch.yml"), secret)
case "leaf":
link = filepath.Join(home, "profiles", "web", "cordis.patch.yml")
target = "../../.credentials.yaml"
case "home":
link = home
target = home + "-other"
if e := os.Rename(home, target); e != nil {
t.Fatal(e)
}
}
if level != "home" {
if e := os.RemoveAll(link); e != nil {
t.Fatal(e)
}
}
if e := os.Symlink(target, link); e != nil {
t.Fatal(e)
}
for _, method := range []string{"GET", "PUT"} {
w := configCall(a, i, method, "profile", `{"content":"foo: bar"}`)
if w.Code != 400 || strings.Contains(w.Body.String(), secret) {
t.Fatalf("%s: %d %s", method, w.Code, w.Body.String())
}
}
})
}
}
func TestConfigRunningRejected(t *testing.T) {
a, i, _ := configTestAPI(t, true)
for _, tc := range []struct{ method, action string }{{"PUT", "profile"}} {
w := configCall(a, i, tc.method, tc.action, `{}`)
if w.Code != 409 {
t.Fatalf("%s: %d", tc.action, w.Code)
}
}
}
func TestConfigBackupRestore(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
const secret = "private-credential-token"
configWrite(t, filepath.Join(home, ".credentials.yaml"), secret)
if w := configCall(a, i, "PUT", "profile", `{"content":"foo: old"}`); w.Code != 200 {
t.Fatal(w.Body.String())
}
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
var result struct {
ID string `json:"id"`
}
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !backupIDPattern.MatchString(result.ID) {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
w = configCall(a, i, "GET", "backups?download="+result.ID, "")
if w.Code != 200 || !bytes.HasPrefix(w.Body.Bytes(), []byte("DSHPASS")) || bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("unsafe download: %d", w.Code)
}
if e := os.RemoveAll(filepath.Join(home, "profiles")); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "changed")
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
for n := 0; n < 3; n++ {
w = configCall(a, i, "POST", "restore", body)
if w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
}
w = configCall(a, i, "POST", "restore", body)
if w.Code != 409 {
t.Fatalf("snapshot retention: %d", w.Code)
}
got, e := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if e != nil || string(got) != secret {
t.Fatalf("restore: %q %v", got, e)
}
for _, p := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} {
st, e := os.Stat(filepath.Join(home, p))
if e != nil {
t.Fatal(e)
}
if st.Sys().(*syscall.Stat_t).Uid != 1000 {
t.Fatalf("not owned by runtime: %s", p)
}
}
state, e := a.s.GetInstance(i.ID)
if e != nil || state.Status != "exited" {
t.Fatalf("state: %+v %v", state, e)
}
id := store.ID()
if e := os.Symlink(filepath.Join(a.root, "backup.key"), filepath.Join(a.root, "backups", i.UID, i.ID, id+".dshbak")); e != nil {
t.Fatal(e)
}
w = configCall(a, i, "GET", "backups?download="+id, "")
if w.Code == 200 {
t.Fatal("symlink download exposed key")
}
}
func TestConfigRestoreInvalidArchive(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
configWrite(t, filepath.Join(home, "cordis.patch.yml"), "original")
id := store.ID()
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
if e := os.MkdirAll(dir, 0700); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(dir, id+".dshbak"), "corrupt")
w := configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id))
if w.Code != 400 || !strings.Contains(w.Body.String(), "invalid backup") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
got, e := os.ReadFile(filepath.Join(home, "cordis.patch.yml"))
if e != nil || string(got) != "original" {
t.Fatalf("rollback: %q %v", got, e)
}
a.s.SetInstance(i.ID, "error")
w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id))
if w.Code != 409 {
t.Fatalf("recovery guard: %d", w.Code)
}
}
func TestConfigRestoreOrchestration(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, running := range []bool{false, true} {
for _, failure := range []string{"", "reconcile", "readiness"} {
t.Run(fmt.Sprintf("running=%v/%s", running, failure), func(t *testing.T) {
a, i, home := configTestAPI(t, running, failure)
configWrite(t, filepath.Join(home, ".credentials.yaml"), "archive")
if err := os.MkdirAll(filepath.Join(home, "pets", "nested"), 0750); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(home, "pets", "nested", "pet.json"), "{}")
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var out struct{ ID string }
json.Unmarshal(w.Body.Bytes(), &out)
var kind string
var schema int
if err := a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=?", out.ID).Scan(&kind, &schema); err != nil || kind != "manual" || schema != 2 {
t.Fatalf("index: %s %d %v", kind, schema, err)
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "before-restore")
w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, out.ID))
wantCode, wantContent := 200, "archive"
if failure != "" {
wantCode, wantContent = 400, "before-restore"
}
if w.Code != wantCode {
t.Fatal(w.Code, w.Body.String())
}
b, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if string(b) != wantContent {
t.Fatalf("home: %s", b)
}
c, err := a.docker.Inspect(context.Background(), i.ContainerID)
if err != nil || c.State.Running != running {
t.Fatalf("runtime state: %+v %v", c, err)
}
st, err := os.Stat(filepath.Join(home, "pets", "nested", "pet.json"))
if err != nil || st.Sys().(*syscall.Stat_t).Uid != 1000 {
t.Fatalf("resource ownership: %v", err)
}
})
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRuntimePrestart(t *testing.T) {
for _, status := range []string{"0", "23", "124", "137", "empty"} {
t.Run(status, func(t *testing.T) {
dir := t.TempDir()
if status != "empty" {
if err := os.MkdirAll(filepath.Join(dir, "profiles/web"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "profiles/web/package.json"), []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
}
scripts := map[string]string{
"node": "#!/bin/bash\nexit 0\n",
"timeout": `#!/bin/bash
printf "timeout %s\n" "$*" >> "$TRACE"
test "$1 $2 $3" = "--signal=TERM --kill-after=5s 120s" || exit 90
shift 3
exec "$@"
`,
"dsh": `#!/bin/bash
printf "dsh %s\n" "$*" >> "$TRACE"
test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91
if [ "$1" = plugin ]; then
test "$*" = "plugin --profile web install --ignore-scripts" || exit 92
exit "$PLUGIN_STATUS"
fi
exit 0
`,
"caddy": "#!/bin/bash\necho caddy >> \"$TRACE\"\nsleep 0.1\n",
}
for name, script := range scripts {
if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0700); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "../../image/entrypoint.sh", "web", "--no-open", "--port", "3080")
cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "DSH_HOME="+dir, "TRACE="+filepath.Join(dir, "trace"), "PLUGIN_STATUS="+status)
output, err := cmd.CombinedOutput()
data, readErr := os.ReadFile(filepath.Join(dir, "trace"))
if readErr != nil {
t.Fatal(readErr)
}
trace := string(data)
if status == "empty" {
if err != nil || strings.Contains(trace, "plugin") || !strings.Contains(trace, "dsh web") {
t.Fatalf("%v: %s: %s", err, trace, output)
}
return
}
if !strings.Contains(trace, "dsh plugin --profile web install --ignore-scripts") {
t.Fatal(trace)
}
if status == "0" {
if err != nil || !strings.Contains(string(output), "reconciliation complete") {
t.Fatalf("%v: %s", err, output)
}
if !strings.Contains(trace, "dsh web") {
t.Fatal(trace)
}
} else {
if err == nil || !strings.Contains(string(output), "failed (exit "+status+")") {
t.Fatalf("%v: %s", err, output)
}
if strings.Contains(trace, "caddy") || strings.Contains(trace, "dsh web") {
t.Fatalf("application started after failure: %s", trace)
}
}
})
}
}
func TestRuntimePNPMPinned(t *testing.T) {
data, err := os.ReadFile("../../image/Dockerfile")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"[email protected]", `test "$(pnpm --version)" = "10.11.0"`, "USER node", `"/usr/bin/tini", "-g"`} {
if !strings.Contains(string(data), required) {
t.Errorf("missing %s", required)
}
}
}
+820
View File
@@ -0,0 +1,820 @@
package api
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"mime"
"net/http"
"os"
"path"
"sort"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
)
const maxUpload = 32 << 20
var uploadSlots = make(chan struct{}, 4)
const (
maxZipEntries = 10000
maxZipDepth = 64
maxZipBytes int64 = 10 << 30
zipTimeout = 2 * time.Minute
)
var zipSlots = make(chan struct{}, 4)
var errZipLimit = errors.New("archive limit exceeded")
var workspaceMu sync.Mutex
func renameNoReplace(root *os.Root, old, next string) error {
anchor, err := root.Open(".")
if err != nil {
return err
}
defer anchor.Close()
openParent := func(p string) (int, error) {
return unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{
Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC,
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS,
})
}
src, err := openParent(old)
if err != nil {
return err
}
defer unix.Close(src)
dst, err := openParent(next)
if err != nil {
return err
}
defer unix.Close(dst)
var st unix.Stat_t
if err = unix.Fstatat(src, path.Base(old), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return err
}
if st.Mode&unix.S_IFMT == unix.S_IFLNK {
return errors.New("unsafe path")
}
return unix.Renameat2(src, path.Base(old), dst, path.Base(next), unix.RENAME_NOREPLACE)
}
func safeRel(p string) (string, error) {
p = strings.TrimSpace(strings.ReplaceAll(p, `\`, `/`))
if p == "" {
return ".", nil
}
if strings.HasPrefix(p, "/") {
return "", errors.New("invalid path")
}
c := path.Clean(p)
if c == ".." || strings.HasPrefix(c, "../") {
return "", errors.New("invalid path")
}
return c, nil
}
func workspacePath(uid string) string { return path.Join("workspaces", uid, "files") }
func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) {
instanceID := ""
if len(instanceIDs) > 0 {
instanceID = instanceIDs[0]
}
base, err := os.OpenRoot(a.root)
if err != nil {
return nil, err
}
defer base.Close()
parts := []string{"workspaces", path.Join("workspaces", u.ID), workspacePath(u.ID)}
if instanceID != "" {
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
if err := rootSafe(base, rel, false); err != nil {
return nil, err
}
return base.OpenRoot(rel)
}
for _, p := range parts {
if err = rootSafe(base, p, true); err != nil {
return nil, err
}
if err = base.Mkdir(p, 0750); err != nil && !errors.Is(err, os.ErrExist) {
return nil, err
}
}
if err = base.Chown(workspacePath(u.ID), 1000, 1000); err != nil {
return nil, err
}
return base.OpenRoot(workspacePath(u.ID))
}
func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
if rel == "." {
return nil
}
parts := strings.Split(rel, "/")
for i := range parts {
q := strings.Join(parts[:i+1], "/")
st, err := root.Lstat(q)
if err != nil {
if finalMayMissing && i == len(parts)-1 && errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if st.Mode()&os.ModeSymlink != 0 || (i < len(parts)-1 && !st.IsDir()) {
return errors.New("unsafe path")
}
}
return nil
}
func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/ws/download" {
var flush func()
w, flush = deferJSONResponse(w)
defer flush()
}
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
instanceID := strings.TrimSpace(r.URL.Query().Get("instance"))
if instanceID == "" && r.URL.Path != "/api/ws/usage" {
fail(w, http.StatusGone, "shared files removed; instance required")
return
}
if instanceID != "" {
// Pin this instance's home without holding the global lifecycle lock
// across client reads/writes. Lifecycle mutations reject active leases.
a.mu.Lock()
i, e := a.s.GetInstance(instanceID)
if e != nil || i.UID != u.ID {
a.mu.Unlock()
fail(w, 404, "instance not found")
return
}
if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) {
a.mu.Unlock()
fail(w, 409, "instance requires recovery")
return
}
if a.workspaceReaders == nil {
a.workspaceReaders = make(map[string]int)
}
a.workspaceReaders[instanceID]++
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.workspaceReaders[instanceID]--
if a.workspaceReaders[instanceID] == 0 {
delete(a.workspaceReaders, instanceID)
}
a.mu.Unlock()
}()
}
workspaceMu.Lock()
root, err := a.openWorkspace(u, instanceID)
workspaceMu.Unlock()
if err != nil {
fail(w, 500, "storage unavailable")
return
}
defer root.Close()
switch strings.TrimPrefix(r.URL.Path, "/api/ws/") {
case "usage":
a.wsUsage(w, r, root, u)
case "tree":
a.wsTree(w, r, root)
case "mkdir":
a.wsMkdir(w, r, root, u)
case "rename":
a.wsRename(w, r, root, u)
case "delete":
a.wsDelete(w, r, root, u)
case "upload":
a.wsUpload(w, r, root, u)
case "download":
a.wsDownload(w, r, root)
default:
fail(w, 404, "not found")
}
}
func entry(root *os.Root, rel string) (os.FileInfo, error) {
if err := rootSafe(root, rel, false); err != nil {
return nil, err
}
return root.Lstat(rel)
}
func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != "GET" {
fail(w, 405, "method not allowed")
return
}
workspaceMu.Lock()
n, e := a.userUsage(u)
workspaceMu.Unlock()
if e != nil {
fail(w, 500, "usage unavailable")
return
}
writeJSON(w, map[string]int64{"used_bytes": n, "quota_bytes": u.Quota})
}
func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root) {
workspaceMu.Lock()
locked := true
defer func() {
if locked {
workspaceMu.Unlock()
}
}()
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
rel, err := safeRel(r.URL.Query().Get("path"))
if err != nil {
fail(w, 400, "invalid path")
return
}
st, err := entry(root, rel)
if err != nil || !st.IsDir() {
fail(w, 404, "not found")
return
}
es, err := readRootDir(root, rel)
if err != nil {
if errors.Is(err, errDirectoryLimit) {
fail(w, 413, "directory entry limit exceeded")
return
}
fail(w, 404, "not found")
return
}
out := make([]map[string]any, 0, len(es))
for _, de := range es {
p := de.Name()
if rel != "." {
p = rel + "/" + p
}
st, err := entry(root, p)
if err != nil || (!st.IsDir() && !st.Mode().IsRegular()) {
continue
}
sz := int64(0)
if !st.IsDir() {
sz = st.Size()
}
out = append(out, map[string]any{"name": de.Name(), "path": p, "is_dir": st.IsDir(), "size": sz, "mod_time": st.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
workspaceMu.Unlock()
locked = false
writeJSON(w, out)
}
func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var x struct {
Path string `json:"path"`
}
if json.NewDecoder(r.Body).Decode(&x) != nil {
fail(w, 400, "invalid json")
return
}
p, e := safeRel(x.Path)
workspaceMu.Lock()
locked := true
defer func() {
if locked {
workspaceMu.Unlock()
}
}()
if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil {
fail(w, 400, "cannot create directory")
return
}
if e = root.Mkdir(p, 0750); e != nil {
fail(w, 400, "cannot create directory")
return
}
if f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0); e == nil {
_ = f.Chown(1000, 1000)
f.Close()
}
workspaceMu.Unlock()
locked = false
a.s.AddAudit(u.ID, "workspace.mkdir", p, "")
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var x struct {
Path string `json:"path"`
NewPath string `json:"new_path"`
}
if json.NewDecoder(r.Body).Decode(&x) != nil {
fail(w, 400, "invalid json")
return
}
p, e := safeRel(x.Path)
q, e2 := safeRel(x.NewPath)
workspaceMu.Lock()
locked := true
defer func() {
if locked {
workspaceMu.Unlock()
}
}()
if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil {
fail(w, 400, "rename failed")
return
}
st, e := root.Lstat(p)
if e != nil || st.Mode()&os.ModeSymlink != 0 {
fail(w, 400, "rename failed")
return
}
if _, e = root.Lstat(q); e == nil {
fail(w, 400, "rename failed")
return
}
if e = renameNoReplace(root, p, q); e != nil {
fail(w, 400, "rename failed")
return
}
workspaceMu.Unlock()
locked = false
a.s.AddAudit(u.ID, "workspace.rename", p, q)
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
workspaceMu.Lock()
locked := true
defer func() {
if locked {
workspaceMu.Unlock()
}
}()
if r.Method != http.MethodDelete {
fail(w, 405, "method not allowed")
return
}
p, e := safeRel(r.URL.Query().Get("path"))
if e != nil || p == "." {
fail(w, 400, "delete failed")
return
}
st, e := entry(root, p)
if e != nil || st.Mode()&os.ModeSymlink != 0 {
fail(w, 400, "delete failed")
return
}
if st.IsDir() {
es, e := readRootDir(root, p)
if e != nil || len(es) != 0 {
fail(w, 400, "delete failed")
return
}
}
if e = root.Remove(p); e != nil {
fail(w, 400, "delete failed")
return
}
workspaceMu.Unlock()
locked = false
a.s.AddAudit(u.ID, "workspace.delete", p, "")
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
select {
case uploadSlots <- struct{}{}:
defer func() { <-uploadSlots }()
default:
fail(w, 429, "too many concurrent uploads")
return
}
usedBefore, usageErr := a.userUsage(u)
if usageErr != nil || usedBefore >= u.Quota {
fail(w, 413, "quota exceeded or usage unavailable")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUpload+(1<<20))
controller := http.NewResponseController(w)
if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 400, "upload unavailable")
return
}
defer controller.SetReadDeadline(time.Time{})
if r.ParseMultipartForm(1<<20) != nil {
fail(w, 400, "invalid upload")
return
}
defer r.MultipartForm.RemoveAll()
workspaceMu.Lock()
locked := true
defer func() {
if locked {
workspaceMu.Unlock()
}
}()
dir, e := safeRel(r.FormValue("path"))
if e != nil || rootSafe(root, dir, false) != nil {
fail(w, 400, "invalid directory")
return
}
input, h, e := r.FormFile("file")
if e != nil {
fail(w, 400, "file required")
return
}
defer input.Close()
name, e := safeRel(h.Filename)
if e != nil || name == "." || path.Base(name) != name {
fail(w, 400, "invalid filename")
return
}
if !uploadExtensionAllowed(name, os.Getenv("UPLOAD_ALLOWED_EXTENSIONS")) {
fail(w, 415, "upload file type denied")
return
}
target := path.Join(dir, name)
if _, e = root.Lstat(target); !errors.Is(e, os.ErrNotExist) {
fail(w, 409, "target exists")
return
}
used, e := a.userUsage(u)
if e != nil || h.Size > maxUpload || used+h.Size > u.Quota {
fail(w, 413, "quota or upload limit exceeded")
return
}
tmp := path.Join(dir, ".upload-"+store.ID())
f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
fail(w, 400, "upload directory unavailable")
return
}
defer root.Remove(tmp)
n, e := io.Copy(f, io.LimitReader(input, maxUpload+1))
if e == nil && (n > maxUpload || used+n > u.Quota) {
e = errors.New("quota exceeded")
}
if e == nil {
e = f.Chown(1000, 1000)
}
if e == nil {
e = f.Sync()
}
f.Close()
if e == nil {
e = renameNoReplace(root, tmp, target)
}
if errors.Is(e, os.ErrExist) {
fail(w, 409, "target exists")
return
}
if e != nil {
fail(w, 400, "upload failed")
return
}
workspaceMu.Unlock()
locked = false
a.s.AddAudit(u.ID, "workspace.upload", target, "")
writeJSON(w, map[string]bool{"ok": true})
}
// Empty policy permits arbitrary workspace files; configured entries are exact
// case-insensitive extensions, not MIME claims supplied by the client.
func uploadExtensionAllowed(name, policy string) bool {
if strings.TrimSpace(policy) == "" {
return true
}
ext := strings.ToLower(path.Ext(name))
for _, entry := range strings.Split(policy, ",") {
entry = strings.ToLower(strings.TrimSpace(entry))
if strings.HasPrefix(entry, ".") && len(entry) > 1 && ext == entry {
return true
}
}
return false
}
func (a *API) userUsage(u store.User) (int64, error) {
base, e := os.OpenRoot(a.root)
if e != nil {
return 0, e
}
defer base.Close()
root, e := base.OpenRoot(path.Join("workspaces", u.ID))
if e != nil {
return 0, e
}
defer root.Close()
var total int64
count := 0
var walk func(string, int) error
walk = func(p string, depth int) error {
count++
if count > 100000 || depth > 128 {
return errors.New("tree limit")
}
st, e := root.Lstat(p)
if e != nil {
return e
}
if st.Mode().IsRegular() {
total += st.Size()
return nil
}
if !st.IsDir() {
return nil
}
f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
if e != nil {
return e
}
defer f.Close()
for {
ds, readErr := f.ReadDir(128)
for _, d := range ds {
if e = walk(path.Join(p, d.Name()), depth+1); e != nil {
return e
}
}
if readErr == io.EOF {
return nil
}
if readErr != nil {
return readErr
}
if count >= 100000 {
return errors.New("tree limit")
}
}
}
e = walk(".", 0)
return total, e
}
func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root) {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
p, e := safeRel(r.URL.Query().Get("path"))
if e != nil {
fail(w, 400, "invalid path")
return
}
anchor, e := root.Open(".")
if e != nil {
fail(w, 404, "not found")
return
}
defer anchor.Close()
f, e := openDownload(anchor, p)
if e != nil {
fail(w, 404, "not found")
return
}
defer f.Close()
st, e := f.Stat()
if e == nil && st.IsDir() {
a.wsDownloadZip(w, r, f, p)
return
}
if e != nil || !st.Mode().IsRegular() {
fail(w, 400, "invalid file")
return
}
w.Header().Set("Content-Disposition", "attachment")
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
}
func openDownload(anchor *os.File, p string) (*os.File, error) {
fd, err := unix.Openat2(int(anchor.Fd()), p, &unix.OpenHow{
Flags: unix.O_RDONLY | unix.O_NONBLOCK | unix.O_CLOEXEC,
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
})
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), p)
st, err := f.Stat()
if err == nil && (!st.IsDir() && !st.Mode().IsRegular() || st.Mode().IsRegular() && st.Sys().(*syscall.Stat_t).Nlink != 1) {
err = errors.New("unsafe file")
}
if err != nil {
f.Close()
return nil, err
}
return f, nil
}
type zipEntry struct {
name string
info os.FileInfo
}
func zipManifest(ctx context.Context, anchor *os.File) ([]zipEntry, error) {
var entries []zipEntry
var total int64
var walk func(string, int) error
walk = func(p string, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
if depth > maxZipDepth || len(entries) >= maxZipEntries {
return errZipLimit
}
f, err := openDownload(anchor, p)
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if p != "." {
if st.Size() < 0 || st.Mode().IsRegular() && st.Size() > maxZipBytes-total {
return errZipLimit
}
if st.Mode().IsRegular() {
total += st.Size()
}
entries = append(entries, zipEntry{p, st})
}
if !st.IsDir() {
return nil
}
for {
ds, err := f.ReadDir(128)
if err != nil && err != io.EOF {
return err
}
for _, d := range ds {
if strings.ContainsAny(d.Name(), "\\:") {
return errors.New("unsafe archive name")
}
if e := walk(path.Join(p, d.Name()), depth+1); e != nil {
return e
}
}
if err == io.EOF {
return nil
}
}
}
err := walk(".", 0)
return entries, err
}
type zipContextWriter struct {
ctx context.Context
w io.Writer
}
func (w zipContextWriter) Write(p []byte) (int, error) {
if err := w.ctx.Err(); err != nil {
return 0, err
}
return w.w.Write(p)
}
func streamZip(ctx context.Context, w io.Writer, anchor *os.File, entries []zipEntry) error {
z := zip.NewWriter(zipContextWriter{ctx, w})
buf := make([]byte, 32<<10)
for _, item := range entries {
if err := ctx.Err(); err != nil {
return err
}
f, err := openDownload(anchor, item.name)
if err != nil {
return err
}
err = func() error {
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if !os.SameFile(st, item.info) || st.Mode() != item.info.Mode() || st.Size() != item.info.Size() || !st.ModTime().Equal(item.info.ModTime()) {
return errors.New("file changed")
}
h, err := zip.FileInfoHeader(st)
if err != nil {
return err
}
h.Name = item.name
if st.IsDir() {
h.Name += "/"
}
dst, err := z.CreateHeader(h)
if err != nil || st.IsDir() {
return err
}
n, err := io.CopyBuffer(dst, io.LimitReader(f, item.info.Size()), buf)
if err == nil && n != item.info.Size() {
err = io.ErrUnexpectedEOF
}
if err == nil {
after, statErr := f.Stat()
if statErr != nil {
return statErr
}
if after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
return errors.New("file changed")
}
}
return err
}()
if err != nil {
return err
}
}
return z.Close()
}
func (a *API) wsDownloadZip(w http.ResponseWriter, r *http.Request, anchor *os.File, p string) {
select {
case zipSlots <- struct{}{}:
defer func() { <-zipSlots }()
default:
fail(w, 429, "too many archive downloads")
return
}
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
defer cancel()
controller := http.NewResponseController(w)
if err := controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 500, "download unavailable")
return
}
defer controller.SetWriteDeadline(time.Time{})
entries, err := zipManifest(ctx, anchor)
if err != nil {
code := 400
if errors.Is(err, errZipLimit) {
code = 413
}
fail(w, code, "cannot archive directory")
return
}
name := path.Base(p)
if p == "." {
name = "workspace"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + ".zip"}))
if err := streamZip(ctx, w, anchor, entries); err != nil {
// A failed stream must not become a successful, silently incomplete archive.
panic(http.ErrAbortHandler)
}
}
const maxDirectoryEntries = 10000
var errDirectoryLimit = errors.New("directory entry limit exceeded")
func readRootDir(root *os.Root, p string) ([]os.DirEntry, error) {
f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
if e != nil {
return nil, e
}
defer f.Close()
entries, err := f.ReadDir(maxDirectoryEntries + 1)
if len(entries) > maxDirectoryEntries {
return nil, errDirectoryLimit
}
if err == io.EOF {
err = nil
}
return entries, err
}
+542
View File
@@ -0,0 +1,542 @@
package api
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
func TestUploadExtensionPolicy(t *testing.T) {
for _, tc := range []struct {
name, policy string
want bool
}{
{"file.exe", "", true}, {"file.TXT", ".txt, .md", true},
{"file.txt.exe", ".txt", false}, {"file", ".txt", false},
{"file.exe", "*,txt", false}, {"file.json", ".txt,.json", true},
} {
if got := uploadExtensionAllowed(tc.name, tc.policy); got != tc.want {
t.Errorf("%q %q: %v", tc.name, tc.policy, got)
}
}
}
func downloadTestRoot(t *testing.T) (string, *os.Root) {
t.Helper()
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { root.Close() })
return dir, root
}
func TestDirectoryDownloadZip(t *testing.T) {
dir, root := downloadTestRoot(t)
if err := os.MkdirAll(filepath.Join(dir, "docs/empty"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "docs/你好.txt"), []byte("hello zip"), 0600); err != nil {
t.Fatal(err)
}
for _, p := range []string{"docs", ".", "", "docs/empty"} {
t.Run(p, func(t *testing.T) {
w := httptest.NewRecorder()
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path="+url.QueryEscape(p), nil), root)
if w.Code != 200 || w.Header().Get("Content-Type") != "application/zip" || !strings.Contains(w.Header().Get("Content-Disposition"), ".zip") {
t.Fatal(w.Code, w.Header(), w.Body.String())
}
z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil {
t.Fatal(err)
}
want := map[string]string{"empty/": "", "你好.txt": "hello zip"}
if p == "." || p == "" {
want = map[string]string{"docs/": "", "docs/empty/": "", "docs/你好.txt": "hello zip"}
}
if p == "docs/empty" {
want = map[string]string{}
}
if len(z.File) != len(want) {
t.Fatalf("entries %d want %d", len(z.File), len(want))
}
for _, f := range z.File {
expect, ok := want[f.Name]
if !ok {
t.Fatal("unexpected entry", f.Name)
}
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(r)
r.Close()
if err != nil || string(b) != expect {
t.Fatal(f.Name, string(b), err)
}
}
})
}
r := httptest.NewRequest("GET", "/api/ws/download?path=docs/"+url.QueryEscape("你好.txt"), nil)
r.Header.Set("Range", "bytes=1-3")
w := httptest.NewRecorder()
(&API{}).wsDownload(w, r, root)
if w.Code != 206 || w.Body.String() != "ell" {
t.Fatal(w.Code, w.Body.String())
}
}
func TestDirectoryDownloadRejectsUnsafeEntries(t *testing.T) {
for _, kind := range []string{"symlink", "internal-link", "hardlink", "fifo", "backslash", "drive"} {
t.Run(kind, func(t *testing.T) {
dir, root := downloadTestRoot(t)
if err := os.Mkdir(filepath.Join(dir, "docs"), 0700); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "docs/unsafe")
var err error
switch kind {
case "symlink":
err = os.Symlink(outside, target)
case "internal-link":
err = os.Symlink(".", target)
case "hardlink":
err = os.Link(outside, target)
case "fifo":
err = syscall.Mkfifo(target, 0600)
case "backslash":
err = os.WriteFile(filepath.Join(dir, `docs/..\secret`), nil, 0600)
case "drive":
err = os.WriteFile(filepath.Join(dir, "docs/C:secret"), nil, 0600)
}
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=docs", nil), root)
if w.Code != 400 || strings.Contains(w.Body.String(), "secret") {
t.Fatal(w.Code, w.Body.String())
}
})
}
dir, root := downloadTestRoot(t)
if err := os.Mkdir(filepath.Join(dir, "real"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "real/file"), []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
for _, p := range []string{"../secret", "/etc/passwd", `..\secret`, "link", "link/file"} {
w := httptest.NewRecorder()
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path="+url.QueryEscape(p), nil), root)
if w.Code < 400 {
t.Fatal("unsafe path accepted", p)
}
}
}
func TestDirectoryDownloadLimits(t *testing.T) {
for _, limit := range []string{"size", "depth", "count"} {
t.Run(limit, func(t *testing.T) {
dir, root := downloadTestRoot(t)
switch limit {
case "size":
f, err := os.Create(filepath.Join(dir, "large"))
if err != nil {
t.Fatal(err)
}
err = f.Truncate(maxZipBytes + 1)
f.Close()
if err != nil {
t.Fatal(err)
}
case "depth":
if err := os.MkdirAll(filepath.Join(dir, strings.Repeat("d/", maxZipDepth+1)), 0700); err != nil {
t.Fatal(err)
}
case "count":
for i := 0; i <= maxZipEntries; i++ {
if err := os.WriteFile(filepath.Join(dir, fmt.Sprint(i)), nil, 0600); err != nil {
t.Fatal(err)
}
}
}
w := httptest.NewRecorder()
(&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root)
if w.Code != 413 {
t.Fatal(w.Code, w.Body.String())
}
})
}
}
func TestZipManifestRechecksAndCancellation(t *testing.T) {
for _, change := range []string{"symlink", "grow", "cancel"} {
t.Run(change, func(t *testing.T) {
dir, root := downloadTestRoot(t)
p := filepath.Join(dir, "file")
if err := os.WriteFile(p, []byte("data"), 0600); err != nil {
t.Fatal(err)
}
f, err := root.Open(".")
if err != nil {
t.Fatal(err)
}
defer f.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
es, err := zipManifest(ctx, f)
if err != nil {
t.Fatal(err)
}
switch change {
case "symlink":
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc/passwd", p); err != nil {
t.Fatal(err)
}
case "grow":
if err := os.WriteFile(p, []byte("more data"), 0600); err != nil {
t.Fatal(err)
}
case "cancel":
cancel()
}
if err := streamZip(ctx, io.Discard, f, es); err == nil {
t.Fatal("unsafe stream accepted")
}
if change == "cancel" {
if _, err := zipManifest(ctx, f); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
})
}
}
type failingZipWriter struct{}
func (failingZipWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func TestZipWriteFailureAndSizeBound(t *testing.T) {
dir, root := downloadTestRoot(t)
p := filepath.Join(dir, "file")
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), 65536), 0600); err != nil {
t.Fatal(err)
}
f, err := root.Open(".")
if err != nil {
t.Fatal(err)
}
defer f.Close()
es, err := zipManifest(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if err := streamZip(context.Background(), failingZipWriter{}, f, es); !errors.Is(err, io.ErrClosedPipe) {
t.Fatal(err)
}
w := &growingZipWriter{path: p}
if err := streamZip(context.Background(), w, f, es); err == nil {
t.Fatal("concurrent growth accepted")
}
if w.Len() > 65536+1024 {
t.Fatal("stream exceeded manifest bound", w.Len())
}
}
type growingZipWriter struct {
bytes.Buffer
path string
}
func (w *growingZipWriter) Write(p []byte) (int, error) {
if w.path != "" {
f, err := os.OpenFile(w.path, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return 0, err
}
_, err = f.Write([]byte("growth"))
f.Close()
if err != nil {
return 0, err
}
w.path = ""
}
return w.Buffer.Write(p)
}
type blockedZipWriter struct {
*httptest.ResponseRecorder
started chan struct{}
release chan struct{}
deadlines []time.Time
}
func (w *blockedZipWriter) Write(p []byte) (int, error) {
if w.started != nil {
close(w.started)
w.started = nil
<-w.release
}
return w.ResponseRecorder.Write(p)
}
func (w *blockedZipWriter) SetWriteDeadline(d time.Time) error {
w.deadlines = append(w.deadlines, d)
return nil
}
func TestZipTransferNoLocksAndConcurrencyLimit(t *testing.T) {
_, root := downloadTestRoot(t)
a := &API{}
started := make(chan struct{})
w := &blockedZipWriter{ResponseRecorder: httptest.NewRecorder(), started: started, release: make(chan struct{})}
done := make(chan struct{})
go func() {
defer close(done)
a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root)
}()
defer func() { close(w.release); <-done }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("download not started")
}
if !workspaceMu.TryLock() {
t.Fatal("workspace lock held across write")
}
workspaceMu.Unlock()
if !a.mu.TryLock() {
t.Fatal("lifecycle lock held across write")
}
a.mu.Unlock()
if len(w.deadlines) != 1 || w.deadlines[0].IsZero() {
t.Fatal("missing deadline")
}
for i := 1; i < cap(zipSlots); i++ {
zipSlots <- struct{}{}
}
defer func() {
for i := 1; i < cap(zipSlots); i++ {
<-zipSlots
}
}()
busy := httptest.NewRecorder()
a.wsDownload(busy, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root)
if busy.Code != 429 {
t.Fatal(busy.Code)
}
}
func TestRenameNoReplace(t *testing.T) {
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
for _, source := range []string{"source", ".upload-test"} {
if err := os.WriteFile(filepath.Join(dir, source), []byte("new"), 0600); err != nil {
t.Fatal(err)
}
if _, err := root.Lstat("target"); !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "target"), []byte("runtime"), 0600); err != nil {
t.Fatal(err)
}
if err := renameNoReplace(root, source, "target"); !errors.Is(err, os.ErrExist) {
t.Fatalf("overwrite: %v", err)
}
b, err := root.ReadFile("target")
if err != nil || string(b) != "runtime" {
t.Fatalf("target changed: %q %v", b, err)
}
if err := root.Remove("target"); err != nil {
t.Fatal(err)
}
if err := renameNoReplace(root, source, "target"); err != nil {
t.Fatal(err)
}
if err := root.Remove("target"); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(dir, "real"), 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real", filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "source"), []byte("new"), 0600); err != nil {
t.Fatal(err)
}
for _, target := range []string{"link/target", "../target"} {
if err := renameNoReplace(root, "source", target); err == nil {
t.Fatalf("unsafe parent accepted: %s", target)
}
}
if err := renameNoReplace(root, "link", "target"); err == nil {
t.Fatal("symlink source accepted")
}
}
type stalledUpload struct {
started chan struct{}
release chan struct{}
}
func (b *stalledUpload) Read([]byte) (int, error) {
close(b.started)
<-b.release
return 0, io.EOF
}
func (b *stalledUpload) Close() error { return nil }
type deadlineRecorder struct {
*httptest.ResponseRecorder
deadlines []time.Time
}
func (w *deadlineRecorder) SetReadDeadline(d time.Time) error {
w.deadlines = append(w.deadlines, d)
return nil
}
func TestLegacyWorkspaceRemoved(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected docker call") })
for _, method := range []string{"GET", "POST", "DELETE"} {
for _, endpoint := range []string{"tree", "download", "upload", "mkdir", "rename", "delete"} {
w := httptest.NewRecorder()
a.workspace(w, runtimeRequest(method, "/api/ws/"+endpoint, "", sid))
if w.Code != http.StatusGone {
t.Fatalf("%s %s: %d", method, endpoint, w.Code)
}
}
}
}
func TestWorkspaceSafetyAndUpload(t *testing.T) {
s, err := store.Open(filepath.Join(t.TempDir(), "test.sqlite"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
a := &API{s: s, root: t.TempDir()}
u := store.User{ID: "user", Quota: 8}
root, e := a.openWorkspace(u)
if e != nil {
t.Fatal(e)
}
defer root.Close()
for _, p := range []string{"", ".", "a/..", "../other", "/tmp"} {
w := httptest.NewRecorder()
a.wsDelete(w, httptest.NewRequest("DELETE", "/api/ws/delete?path="+p, nil), root, u)
if w.Code < 400 {
t.Fatalf("deleted %q", p)
}
}
outside := t.TempDir()
os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0600)
if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/files/link")); e != nil {
t.Fatal(e)
}
w := httptest.NewRecorder()
a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=link/secret", nil), root)
if w.Code < 400 {
t.Fatal("escaped root")
}
upload := func(name, content string) int {
var b bytes.Buffer
m := multipart.NewWriter(&b)
m.WriteField("path", ".")
f, _ := m.CreateFormFile("file", name)
f.Write([]byte(content))
m.Close()
r := httptest.NewRequest("POST", "/api/ws/upload", &b)
r.Header.Set("Content-Type", m.FormDataContentType())
w := httptest.NewRecorder()
a.wsUpload(w, r, root, u)
return w.Code
}
if code := upload("file.txt", "hello"); code != 200 {
t.Fatal(code)
}
if code := upload("big.txt", "12345"); code != 413 {
t.Fatal("quota", code)
}
if code := upload("file.txt", "x"); code != 409 {
t.Fatal("overwrite", code)
}
w = httptest.NewRecorder()
a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=file.txt", nil), root)
if w.Code != 200 || w.Body.String() != "hello" {
t.Fatal(w.Code, w.Body.String())
}
for _, op := range []struct{ action, body string }{
{"mkdir", `{"path":"folder"}`},
{"rename", `{"path":"folder","new_path":"renamed"}`},
{"delete", ""},
} {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/api/ws/"+op.action, strings.NewReader(op.body))
switch op.action {
case "mkdir":
a.wsMkdir(w, r, root, u)
case "rename":
a.wsRename(w, r, root, u)
case "delete":
a.wsDelete(w, httptest.NewRequest("DELETE", "/api/ws/delete?path=renamed", nil), root, u)
}
if w.Code != 200 {
t.Fatal(op.action, w.Code, w.Body.String())
}
}
rows, err := s.DB.Query("SELECT uid,action,target,detail FROM audit ORDER BY id")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
want := [][3]string{{"workspace.upload", "file.txt", ""}, {"workspace.mkdir", "folder", ""}, {"workspace.rename", "folder", "renamed"}, {"workspace.delete", "renamed", ""}}
i := 0
for rows.Next() {
var uid, action, target, detail string
if err := rows.Scan(&uid, &action, &target, &detail); err != nil {
t.Fatal(err)
}
if i >= len(want) || uid != u.ID || [3]string{action, target, detail} != want[i] {
t.Fatal("unexpected audit", uid, action, target, detail)
}
i++
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if i != len(want) {
t.Fatal("missing audit entries", i)
}
}
+512
View File
@@ -0,0 +1,512 @@
package api
import (
"archive/tar"
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
imageassets "git.misaka.ren/m1saka/dsh-web/image"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// Caller holds buildQueue; interrupted jobs cannot be reported as successful.
func (a *API) ensureBuilds() error {
if buildQueue.initialized[a.s.DB] {
return nil
}
_, err := a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS image_builds(id TEXT PRIMARY KEY,version TEXT NOT NULL,status TEXT NOT NULL,logs TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',image_id TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS image_build_requests(user_id TEXT NOT NULL,build_id TEXT PRIMARY KEY,created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS images(image_id TEXT PRIMARY KEY,version TEXT NOT NULL,recipe_rev TEXT,platform TEXT,tag TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,built_at TEXT NOT NULL,usage_count INTEGER NOT NULL DEFAULT 0);`)
if err != nil {
return err
}
for _, q := range []string{
"ALTER TABLE image_builds ADD COLUMN requested_by TEXT NOT NULL DEFAULT ''",
"ALTER TABLE image_builds ADD COLUMN recipe_rev TEXT",
"ALTER TABLE image_builds ADD COLUMN platform TEXT",
"ALTER TABLE image_builds ADD COLUMN finished_at TEXT",
"ALTER TABLE images ADD COLUMN recipe_rev TEXT",
"ALTER TABLE images ADD COLUMN platform TEXT",
"ALTER TABLE images ADD COLUMN usage_count INTEGER NOT NULL DEFAULT 0",
} {
if _, e := a.s.DB.Exec(q); e != nil && !strings.Contains(strings.ToLower(e.Error()), "duplicate column") {
return e
}
}
_, err = a.s.DB.Exec("DROP INDEX IF EXISTS image_builds_version; CREATE UNIQUE INDEX IF NOT EXISTS image_builds_identity ON image_builds(version,recipe_rev,platform) WHERE recipe_rev IS NOT NULL AND platform IS NOT NULL AND status IN ('queued','running','validating','success')")
if err != nil {
return err
}
if !buildQueue.initialized[a.s.DB] {
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='image template identity is unknown' WHERE status='queued' AND (recipe_rev IS NULL OR recipe_rev<>? OR platform IS NULL OR platform<>?)", imageRecipeRev, imagePlatform); err != nil {
return err
}
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='build interrupted by process restart' WHERE status IN ('running','validating')"); err != nil {
return err
}
buildQueue.initialized[a.s.DB] = true
}
return nil
}
func (a *API) builds(w http.ResponseWriter, r *http.Request) {
u, _, authErr := a.user(r)
if authErr != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var in struct{ Version string }
if r.Method == http.MethodPost {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
dec.DisallowUnknownFields()
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !validBuildVersion(in.Version) {
fail(w, 400, "a fixed version is required")
return
}
}
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
fail(w, 500, "database error")
return
}
if r.Method == http.MethodGet {
rows, err := a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC")
if err != nil {
fail(w, 500, "database error")
return
}
result := []buildRecord{}
for rows.Next() {
var b buildRecord
var finished, recipe, platform sql.NullString
if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil {
break
}
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
result = append(result, b)
}
scanErr := rows.Err()
rows.Close()
if err != nil || scanErr != nil {
fail(w, 500, "database error")
return
}
if err := a.reconcileBuilds(r.Context(), result); err != nil {
fail(w, 502, "docker image reconciliation failed")
return
}
a.startBuildWorker()
writeJSON(w, result)
return
}
b, err := a.enqueueBuildLocked(r.Context(), u.ID, in.Version)
if err != nil {
status := 502
if errors.Is(err, errBuildRateLimited) {
status = 429
w.Header().Set("Retry-After", "3600")
}
fail(w, status, sanitizeBuildLog(err.Error()))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSON(w, b)
}
var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour")
func (a *API) enqueueBuild(ctx context.Context, uid, version string) (buildRecord, error) {
buildQueue.Lock()
defer buildQueue.Unlock()
if !validBuildVersion(version) {
return buildRecord{}, errors.New("a fixed version is required")
}
if err := a.ensureBuilds(); err != nil {
return buildRecord{}, err
}
return a.enqueueBuildLocked(ctx, uid, version)
}
func (a *API) enqueueBuildLocked(ctx context.Context, uid, version string) (buildRecord, error) {
var b buildRecord
var finished, recipe, platform sql.NullString
err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds WHERE version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished)
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
if err == nil && b.Status == "success" {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
id, lookupErr := a.localBuildImage(ctx, b.Version, b.ImageID)
cancel()
if lookupErr != nil {
return b, lookupErr
}
if id != b.ImageID || id == "" {
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=?", b.ID); err == nil {
err = sql.ErrNoRows
}
}
}
if errors.Is(err, sql.ErrNoRows) {
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil {
return b, err
}
if count >= 3 {
return b, errBuildRateLimited
}
b = buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform}
err = a.insertBuildRequest(b, uid)
}
if err != nil {
return b, err
}
a.startBuildWorker()
return b, nil
}
func (a *API) startBuildWorker() {
if buildQueue.active[a.s.DB] {
return
}
buildQueue.active[a.s.DB] = true
go func() {
buildWorker.Lock()
defer buildWorker.Unlock()
for {
buildQueue.Lock()
var b buildRecord
err := a.s.DB.QueryRow("SELECT id,version FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1").Scan(&b.ID, &b.Version)
if err == nil {
_, err = a.s.DB.Exec("UPDATE image_builds SET status='running' WHERE id=?", b.ID)
}
if err != nil {
delete(buildQueue.active, a.s.DB)
buildQueue.Unlock()
return
}
buildQueue.Unlock()
a.runImageBuild(b)
}
}()
}
func imageBuildContext() (io.Reader, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, name := range []string{"Dockerfile", "Caddyfile", "entrypoint.sh", "install-mobile.mjs", "install-free-search.mjs", "install-workspace.mjs", "init-profile.mjs"} {
dataBytes, err := imageassets.Files.ReadFile(name)
if err != nil {
return nil, err
}
data := string(dataBytes)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))}); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(data)); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return &buf, nil
}
func (a *API) runImageBuild(b buildRecord) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
logs := ""
rawLogs := ""
var logErr error
appendLog := func(s string) {
if len(rawLogs) < buildLogLimit {
remaining := buildLogLimit - len(rawLogs)
if len(s) > remaining {
s = s[:remaining]
}
rawLogs += s
}
logs = sanitizeBuildLog(rawLogs)
if logErr == nil {
_, logErr = a.s.DB.Exec("UPDATE image_builds SET logs=? WHERE id=?", logs, b.ID)
}
}
tarball, err := imageBuildContext()
var id string
if err == nil {
id, err = a.localBuildImage(ctx, b.Version, "")
}
if err == nil && id == "" {
_, err = fetchNPMVersion(ctx, npmBuildClient, b.Version)
if err == nil {
err = a.checkBuildDisk(ctx)
}
}
if err == nil && id == "" {
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
}
if err == nil {
var matches bool
matches, err = a.imageMatches(ctx, id, b.Version)
if err == nil && !matches {
err = errors.New("image template or platform mismatch")
}
}
if err == nil {
_, err = a.s.DB.Exec("UPDATE image_builds SET status='validating',image_id=? WHERE id=?", id, b.ID)
}
if err == nil {
err = a.verifyBuiltImage(ctx, id, b.Version)
}
if err == nil {
err = logErr
}
status, detail := "success", ""
if err != nil {
status = "failed"
detail = sanitizeBuildLog(err.Error())
appendLog(string(rune(10)) + detail)
}
finished := time.Now().UTC().Format(time.RFC3339Nano)
_, _ = a.s.DB.Exec("UPDATE image_builds SET status=?,logs=?,error=?,image_id=?,finished_at=? WHERE id=?", status, logs, detail, id, finished, b.ID)
if status == "success" && id != "" {
_, _ = a.s.DB.Exec("INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at", id, b.Version, imageRecipeRev, imagePlatform, "dsh-web:"+b.Version+"-"+imageRecipeRev, "available", finished)
}
}
func (a *API) verifyBuiltImage(parent context.Context, image, version string) (result error) {
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var created struct {
ID string `json:"Id"`
}
config := map[string]any{"Image": image, "User": "1000:1000", "Labels": map[string]string{"dsh-mgr.role": "build-check"}, "HostConfig": map[string]any{"NetworkMode": "none", "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges:true"}, "Memory": int64(1 << 30), "NanoCpus": int64(1e9), "PidsLimit": 256, "Tmpfs": map[string]string{"/tmp": "rw,exec,nosuid,nodev,size=256m,mode=1777", "/workspace": "rw,nosuid,nodev,size=256m,uid=1000,gid=1000"}}}
if err := a.docker.Do(ctx, http.MethodPost, "/containers/create", config, &created); err != nil {
return err
}
if created.ID == "" {
return errors.New("verification container ID missing")
}
path := "/containers/" + url.PathEscape(created.ID)
defer func() {
cleanup, c := context.WithTimeout(context.Background(), 10*time.Second)
defer c()
err := a.docker.Do(cleanup, http.MethodDelete, path+"?force=1&v=1", nil, nil)
result = errors.Join(result, err)
}()
if err := a.docker.Do(ctx, http.MethodPost, path+"/start", nil, nil); err != nil {
return err
}
script := `const end=Date.now()+60000;async function probe(){try{const r=await fetch('http://127.0.0.1:8080/',{redirect:'manual',signal:AbortSignal.timeout(2000)});if(r.status>=200&&r.status<500)process.exit(0);}catch{}if(Date.now()>end)process.exit(1);setTimeout(probe,500);}probe();`
{
expected, _ := json.Marshal(version)
script = `const cp=require('node:child_process');const actual=cp.execFileSync('dsh',['--version'],{encoding:'utf8',timeout:20000}).trim();if(actual!==` + string(expected) + `)process.exit(2);` + script
}
script = `const fs=require('node:fs');for(const [k,v] of Object.entries({DSH_HOME:'/workspace',HOME:'/workspace',XDG_DATA_HOME:'/workspace/.local/share',XDG_CONFIG_HOME:'/workspace/.config'})){if(process.env[k]!==v)process.exit(3);}if(!fs.lstatSync('/data/dsh-home').isSymbolicLink()||fs.realpathSync('/data/dsh-home')!=='/workspace')process.exit(4);fs.writeFileSync('/workspace/.home-check','isolated');if(fs.readFileSync('/data/dsh-home/.home-check','utf8')!=='isolated')process.exit(5);fs.unlinkSync('/workspace/.home-check');` + script
var exec struct {
ID string `json:"Id"`
}
if err := a.docker.Do(ctx, http.MethodPost, path+"/exec", map[string]any{"Cmd": []string{"node", "-e", script}, "User": "1000:1000"}, &exec); err != nil {
return err
}
if exec.ID == "" {
return errors.New("verification exec ID missing")
}
ep := "/exec/" + url.PathEscape(exec.ID)
if err := a.docker.Do(ctx, http.MethodPost, ep+"/start", map[string]bool{"Detach": true, "Tty": false}, nil); err != nil {
return err
}
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
var state struct {
Running bool
ExitCode int
}
if err := a.docker.Do(ctx, http.MethodGet, ep+"/json", nil, &state); err != nil {
return err
}
if !state.Running {
if state.ExitCode != 0 {
return fmt.Errorf("image startup verification failed (exit %d)", state.ExitCode)
}
container, err := a.docker.Inspect(ctx, created.ID)
if err != nil {
return err
}
if !container.State.Running {
return errors.New("verification container exited")
}
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
const buildLogLimit = 64 << 10
var fixedVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$`)
var buildSecrets = regexp.MustCompile(`(?i)(authorization["']?[[:space:]]*[:=][[:space:]]*["']?(?:bearer[[:space:]]+|basic[[:space:]]+)?|(?:token|password|passwd|secret|api[_-]?key|cookie)["']?[[:space:]]*[:=][[:space:]]*["']?)[^[:space:]"'&,;]+`)
var buildURLCredentials = regexp.MustCompile(`(https?://)[^/@[:space:]]+:[^/@[:space:]]+@`)
var buildQueue = struct {
sync.Mutex
initialized map[*sql.DB]bool
active map[*sql.DB]bool
}{initialized: map[*sql.DB]bool{}, active: map[*sql.DB]bool{}}
var buildWorker sync.Mutex
type imageRecord struct {
ID string `json:"id"`
ImageID string `json:"image_id"`
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Status string `json:"status"`
Created string `json:"created_at"`
RecipeRev string `json:"recipe_rev,omitempty"`
Platform string `json:"platform,omitempty"`
UsageCount int `json:"usage_count"`
}
type buildRecord struct {
ImageID string `json:"image_id,omitempty"`
ID string `json:"id"`
Version string `json:"version"`
Status string `json:"status"`
Logs string `json:"logs"`
Error string `json:"error,omitempty"`
Created string `json:"created_at"`
RequestedBy string `json:"requested_by,omitempty"`
RecipeRev string `json:"recipe_rev,omitempty"`
Platform string `json:"platform,omitempty"`
Finished string `json:"finished_at,omitempty"`
}
func (a *API) images(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
a.deleteImage(w, r)
return
}
if _, _, err := a.user(r); err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
result, err := a.imageIndex(ctx)
if err != nil {
fail(w, 502, "image index reconciliation failed")
return
}
writeJSON(w, result)
}
func (a *API) verifiedImages(ctx context.Context) (map[string]string, error) {
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
return nil, err
}
rows, err := a.s.DB.QueryContext(ctx, "SELECT image_id,version FROM image_builds WHERE status='success' AND image_id<>'' AND recipe_rev=? AND platform=?", imageRecipeRev, imagePlatform)
if err != nil {
return nil, err
}
defer rows.Close()
images := make(map[string]string)
for rows.Next() {
var id, version string
if err := rows.Scan(&id, &version); err != nil {
return nil, err
}
images[id] = version
}
return images, rows.Err()
}
func (a *API) latest(w http.ResponseWriter, r *http.Request) {
if _, _, err := a.user(r); err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
v, err := fetchLatest(r.Context(), npmBuildClient)
if err != nil {
fail(w, 502, "npm version lookup failed")
return
}
writeJSON(w, map[string]string{"version": v, "checked_at": time.Now().UTC().Format(time.RFC3339)})
}
func fetchLatest(ctx context.Context, client *http.Client) (string, error) {
return fetchNPMVersion(ctx, client, "latest")
}
func fetchNPMVersion(ctx context.Context, client *http.Client, version string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://registry.npmjs.org/@deepseek-ai%2Fdsh/"+url.PathEscape(version), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("npm status %d", resp.StatusCode)
}
var v struct{ Version string }
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&v); err != nil {
return "", err
}
if !validBuildVersion(v.Version) || (version != "latest" && v.Version != version) {
return "", errors.New("invalid npm version")
}
return v.Version, nil
}
func validBuildVersion(v string) bool { return len(v) <= 100 && fixedVersion.MatchString(v) }
func sanitizeBuildLog(s string) string {
s = buildURLCredentials.ReplaceAllString(s, "$1[REDACTED]@")
s = buildSecrets.ReplaceAllString(s, "$1[REDACTED]")
s = strings.ToValidUTF8(s, "")
if len(s) > buildLogLimit {
s = strings.ToValidUTF8(s[:buildLogLimit], "")
}
return s
}
func (a *API) localBuildImage(ctx context.Context, version, preferred string) (string, error) {
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime", "dsh-mgr.version=" + version}})
var images []struct {
ID string
Labels map[string]string
}
if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &images); err != nil {
return "", err
}
for _, im := range images {
if im.ID != "" && im.Labels["dsh-mgr.role"] == "runtime" && im.Labels["dsh-mgr.version"] == version && im.Labels["dsh-mgr.recipe"] == imageRecipeRev && (preferred == "" || im.ID == preferred) {
ok, err := a.imageMatches(ctx, im.ID, version)
if err != nil {
return "", err
}
if ok {
return im.ID, nil
}
}
}
return "", nil
}
+140
View File
@@ -0,0 +1,140 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"net/url"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
)
func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
id := r.URL.Query().Get("image_id")
if !runtimeImageID.MatchString(id) {
fail(w, 400, "invalid image_id")
return
}
// Upgrades also acquire a.mu before buildQueue. Keep both through deletion.
a.mu.Lock()
defer a.mu.Unlock()
buildQueue.Lock()
defer buildQueue.Unlock()
if buildQueue.active[a.s.DB] {
fail(w, 409, "image build worker is active")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
tx, err := a.s.DB.BeginTx(ctx, nil)
if err != nil {
fail(w, 500, "database error")
return
}
defer tx.Rollback()
// Take SQLite's writer lock before checking intents: reservation does not use a.mu.
if _, err = tx.ExecContext(ctx, "UPDATE images SET status=status WHERE image_id=?", id); err != nil {
fail(w, 500, "image index unavailable")
return
}
var version string
err = tx.QueryRowContext(ctx, "SELECT version FROM images WHERE image_id=?", id).Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
fail(w, 404, "managed image not found")
return
}
if err != nil {
fail(w, 500, "image index unavailable")
return
}
for _, check := range []struct{ table, query string }{
{"image_builds", "SELECT count(*) FROM image_builds WHERE status IN ('queued','running','validating')"},
{"creation_intents", "SELECT count(*) FROM creation_intents WHERE status IN ('waiting','creating')"},
{"upgrade_journal", "SELECT count(*) FROM upgrade_journal WHERE phase NOT IN ('complete','rolled_back')"},
} {
var exists, count int
if err = tx.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", check.table).Scan(&exists); err == nil && exists > 0 {
err = tx.QueryRowContext(ctx, check.query).Scan(&count)
}
if err != nil {
fail(w, 500, "active task lookup failed")
return
}
if count > 0 {
fail(w, 409, "active task prevents image deletion")
return
}
}
var references int
if err = tx.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE image_id=?", id).Scan(&references); err != nil {
fail(w, 500, "instance reference lookup failed")
return
}
if references > 0 {
fail(w, 409, "image is referenced by an instance")
return
}
var image struct {
ID string `json:"Id"`
Config struct{ Labels map[string]string }
}
path := "/images/" + url.PathEscape(id)
if err = a.docker.Do(ctx, http.MethodGet, path+"/json", nil, &image); err != nil {
fail(w, 502, "image inspection failed")
return
}
if image.ID != id || image.Config.Labels["dsh-mgr.role"] != "runtime" || version == "" || image.Config.Labels["dsh-mgr.version"] != version {
fail(w, 409, "managed runtime image required")
return
}
var containers []struct{ ImageID string }
if err = a.docker.Do(ctx, http.MethodGet, "/containers/json?all=1", nil, &containers); err != nil || containers == nil {
fail(w, 502, "container reference lookup failed")
return
}
for _, container := range containers {
if container.ImageID == "" {
fail(w, 502, "container image identity unavailable")
return
}
if container.ImageID == id {
fail(w, 409, "image is referenced by a container")
return
}
}
if _, err = tx.ExecContext(ctx, "UPDATE images SET status='missing',usage_count=0 WHERE image_id=?", id); err == nil {
_, err = tx.ExecContext(ctx, "UPDATE image_builds SET status='missing',error='image deleted by administrator' WHERE image_id=? AND status='success'", id)
}
if err == nil {
_, err = tx.ExecContext(ctx, "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,'image.delete',?,'',?)", u.ID, id, time.Now().Unix())
}
if err != nil {
fail(w, 500, "image deletion recording failed")
return
}
if err = a.docker.Do(ctx, http.MethodDelete, path+"?force=false", nil, nil); err != nil {
status := http.StatusBadGateway
var apiErr *docker.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict {
status = http.StatusConflict
}
fail(w, status, "docker image deletion failed")
return
}
if err = tx.Commit(); err != nil {
fail(w, 500, "image deleted but recording failed")
return
}
writeJSON(w, map[string]string{"image_id": id, "status": "missing"})
}
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestImageDelete(t *testing.T) {
id := "sha256:" + strings.Repeat("a", 64)
for _, mode := range []string{"success", "anonymous", "non-admin", "invalid", "unmanaged", "role", "instance", "container-running", "container-exited", "containers-failure", "containers-null", "containers-unknown", "inspect-failure", "delete-failure", "delete-conflict", "queued", "running", "validating", "worker", "waiting", "creating", "journal", "complete", "rolled_back", "db-failure", "task-query-failure", "audit-failure"} {
t.Run(mode, func(t *testing.T) {
var deletes atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodDelete:
deletes.Add(1)
if r.URL.Path != "/v1.47/images/"+id || r.URL.Query().Get("force") != "false" {
t.Errorf("unsafe delete: %s", r.URL)
}
if mode == "delete-failure" {
w.WriteHeader(500)
return
}
if mode == "delete-conflict" {
w.WriteHeader(409)
return
}
writeJSON(w, []any{})
case r.URL.Path == "/v1.47/images/"+id+"/json":
if mode == "inspect-failure" {
w.WriteHeader(500)
return
}
if mode == "role" {
writeJSON(w, map[string]any{"Id": id})
return
}
writeImageInspect(w, id, "1.2.3")
case r.URL.Path == "/v1.47/containers/json":
if r.URL.RawQuery != "all=1" {
t.Errorf("must inspect all containers: %s", r.URL)
}
if mode == "containers-failure" {
w.WriteHeader(500)
return
}
if mode == "containers-null" {
writeJSON(w, nil)
return
}
if mode == "containers-unknown" {
writeJSON(w, []any{map[string]string{"Image": "tag"}})
return
}
if strings.HasPrefix(mode, "container-") {
writeJSON(w, []any{map[string]string{"ImageID": id, "State": strings.TrimPrefix(mode, "container-")}})
return
}
writeJSON(w, []any{})
default:
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
w.WriteHeader(500)
}
})
exec := func(q string, args ...any) {
t.Helper()
if _, err := a.s.DB.Exec(q, args...); err != nil {
t.Fatal(err)
}
}
seedVerifiedImage(t, a, id, "1.2.3")
exec("INSERT INTO images(image_id,version,status,built_at) VALUES(?,'1.2.3','available','now')", id)
if mode != "non-admin" {
exec("UPDATE users SET admin=1 WHERE id='u'")
}
want := 409
switch mode {
case "success", "complete", "rolled_back":
want = 200
case "anonymous":
sid = ""
want = 401
case "non-admin":
want = 403
case "invalid":
want = 400
case "unmanaged":
exec("DELETE FROM images")
want = 404
case "inspect-failure", "containers-failure", "containers-null", "containers-unknown", "delete-failure":
want = 502
case "instance":
exec("INSERT INTO instances(id,uid,name,image_id,created) VALUES('i','other','i',?,0)", id)
case "queued", "running", "validating":
exec("INSERT INTO image_builds(id,version,status,created_at) VALUES('active','2.0.0',?,'now')", mode)
case "worker":
buildQueue.Lock()
buildQueue.active[a.s.DB] = true
buildQueue.Unlock()
defer func() { buildQueue.Lock(); delete(buildQueue.active, a.s.DB); buildQueue.Unlock() }()
case "waiting", "creating":
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
exec("INSERT INTO creation_intents(id,uid,name,version,status,host,created) VALUES('intent','other','i','2.0.0',?,'host',0)", mode)
case "db-failure":
exec("ALTER TABLE instances RENAME COLUMN image_id TO broken")
want = 500
case "task-query-failure":
exec("CREATE TABLE upgrade_journal(broken TEXT)")
want = 500
case "audit-failure":
exec("CREATE TRIGGER deny_audit BEFORE INSERT ON audit BEGIN SELECT RAISE(FAIL,'unavailable'); END")
want = 500
}
if mode == "journal" || mode == "complete" || mode == "rolled_back" {
exec("CREATE TABLE upgrade_journal(phase TEXT)")
exec("INSERT INTO upgrade_journal VALUES(?)", mode)
}
queryID := id
if mode == "invalid" {
queryID = "runtime:latest"
}
r := httptest.NewRequest(http.MethodDelete, "/api/images?image_id="+queryID, nil)
if sid != "" {
r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid})
addTestAccess(r, sid)
}
w := httptest.NewRecorder()
a.images(w, r)
if w.Code != want {
t.Fatalf("status=%d want=%d body=%s", w.Code, want, w.Body.String())
}
wantDeletes := int32(0)
if want == 200 || mode == "delete-failure" || mode == "delete-conflict" {
wantDeletes = 1
}
if deletes.Load() != wantDeletes {
t.Fatalf("deletes=%d want=%d", deletes.Load(), wantDeletes)
}
if mode != "unmanaged" {
var status string
if err := a.s.DB.QueryRow("SELECT status FROM images WHERE image_id=?", id).Scan(&status); err != nil {
t.Fatal(err)
}
wantStatus := "available"
if want == 200 {
wantStatus = "missing"
}
if status != wantStatus {
t.Fatalf("image status=%s want=%s", status, wantStatus)
}
}
var audits int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='image.delete' AND uid='u' AND target=?", id).Scan(&audits); err != nil {
t.Fatal(err)
}
if (audits == 1) != (want == 200) {
t.Fatalf("audits=%d", audits)
}
var status string
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id=?", id).Scan(&status); err != nil {
t.Fatal(err)
}
if (status == "missing") != (want == 200) {
t.Fatalf("build status=%s", status)
}
})
}
}
+252
View File
@@ -0,0 +1,252 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"syscall"
"time"
)
var npmBuildClient = &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
const imageRecipeRev = "7"
var imagePlatform = runtime.GOOS + "/" + runtime.GOARCH
const minimumBuildDiskBytes = uint64(2 << 30)
var buildDiskAvailable = func(path string) (uint64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, err
}
return uint64(stat.Bavail) * uint64(stat.Bsize), nil
}
type inspectedImage struct {
ID string `json:"Id"`
Os, Architecture, Variant, Created string
RepoTags []string
Config struct{ Labels map[string]string }
}
func (im inspectedImage) platform() string {
if im.Os == "" || im.Architecture == "" {
return ""
}
p := im.Os + "/" + im.Architecture
if im.Variant != "" {
p += "/" + im.Variant
}
return p
}
func (a *API) inspectLocalImage(ctx context.Context, id string) (*inspectedImage, error) {
if id == "" {
return nil, nil
}
var im inspectedImage
if err := a.docker.Do(ctx, http.MethodGet, "/images/"+url.PathEscape(id)+"/json", nil, &im); err != nil {
var apiErr *docker.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return nil, nil
}
return nil, err
}
return &im, nil
}
func (a *API) imageMatches(ctx context.Context, id, version string) (bool, error) {
im, err := a.inspectLocalImage(ctx, id)
if err != nil || im == nil {
return false, err
}
return im.ID == id && im.platform() == imagePlatform && im.Config.Labels["dsh-mgr.role"] == "runtime" && im.Config.Labels["dsh-mgr.version"] == version && im.Config.Labels["dsh-mgr.recipe"] == imageRecipeRev, nil
}
func (a *API) imageIndex(ctx context.Context) ([]imageRecord, error) {
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
return nil, err
}
if _, err := a.s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO images(image_id,version,recipe_rev,platform,tag,status,built_at)
SELECT image_id,version,recipe_rev,platform,'','missing',COALESCE(finished_at,created_at) FROM image_builds WHERE status IN ('success','missing') AND image_id<>'' ORDER BY created_at DESC,id DESC`); err != nil {
return nil, err
}
rows, err := a.s.DB.QueryContext(ctx, `SELECT image_id,version,COALESCE(recipe_rev,''),COALESCE(platform,''),tag,status,built_at,
(SELECT count(*) FROM instances WHERE instances.image_id=images.image_id) FROM images ORDER BY built_at DESC,image_id`)
if err != nil {
return nil, err
}
result := []imageRecord{}
for rows.Next() {
var im imageRecord
if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil {
break
}
im.ID = im.ImageID
result = append(result, im)
}
scanErr := rows.Err()
rows.Close()
if err != nil {
return nil, err
}
if scanErr != nil {
return nil, scanErr
}
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime"}})
var local []struct {
ID string
Labels map[string]string
}
if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &local); err != nil {
return nil, err
}
known := make(map[string]bool, len(result))
for _, im := range result {
known[im.ImageID] = true
}
for _, im := range local {
if im.ID != "" && !known[im.ID] && im.Labels["dsh-mgr.role"] == "runtime" {
known[im.ID] = true
result = append(result, imageRecord{ID: im.ID, ImageID: im.ID})
}
}
for i := range result {
im := &result[i]
im.Status = "missing"
actual, err := a.inspectLocalImage(ctx, im.ImageID)
if err != nil {
return nil, err
}
if actual == nil {
continue
}
im.Status = "unverified"
im.Platform = actual.platform()
im.Version = actual.Config.Labels["dsh-mgr.version"]
im.RecipeRev = actual.Config.Labels["dsh-mgr.recipe"]
im.Tag = ""
if len(actual.RepoTags) > 0 {
im.Tag = actual.RepoTags[0]
}
if actual.Created != "" {
im.Created = actual.Created
}
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE image_id=?", im.ImageID).Scan(&im.UsageCount); err != nil {
return nil, err
}
var verified int
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM image_builds WHERE image_id=? AND version=? AND recipe_rev=? AND platform=? AND status='success'", im.ImageID, im.Version, imageRecipeRev, imagePlatform).Scan(&verified); err != nil {
return nil, err
}
if verified > 0 && actual.ID == im.ImageID && actual.Config.Labels["dsh-mgr.role"] == "runtime" && im.RecipeRev == imageRecipeRev && im.Platform == imagePlatform {
im.Status = "available"
}
}
tx, err := a.s.DB.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
for _, im := range result {
if _, err = tx.ExecContext(ctx, `INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at,usage_count) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at,usage_count=excluded.usage_count`, im.ImageID, im.Version, im.RecipeRev, im.Platform, im.Tag, im.Status, im.Created, im.UsageCount); err != nil {
return nil, err
}
if im.Status == "missing" {
if _, err = tx.ExecContext(ctx, "UPDATE image_builds SET status='missing',error='image no longer available or template identity is unknown' WHERE image_id=? AND status='success'", im.ImageID); err != nil {
return nil, err
}
}
}
return result, tx.Commit()
}
func (a *API) StartImageBuilds() error {
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
return err
}
a.startBuildWorker()
return nil
}
func (a *API) checkBuildDisk(ctx context.Context) error {
var info struct{ DockerRootDir string }
if err := a.docker.Do(ctx, http.MethodGet, "/info", nil, &info); err != nil {
return fmt.Errorf("build disk check: %w", err)
}
// A matching pathname alone cannot establish daemon filesystem identity.
confirmed := os.Getenv("DSH_DOCKER_ROOT_VISIBLE")
if !filepath.IsAbs(info.DockerRootDir) || confirmed != info.DockerRootDir {
return errors.New("Docker disk capacity unknown: mount the daemon DockerRootDir at the identical path and set DSH_DOCKER_ROOT_VISIBLE to that path; new builds are blocked")
}
available, err := buildDiskAvailable(confirmed)
if err != nil {
return fmt.Errorf("Docker disk capacity unavailable: %w", err)
}
return requireBuildDisk(available)
}
func requireBuildDisk(available uint64) error {
if available < minimumBuildDiskBytes {
return fmt.Errorf("insufficient Docker disk space: %d available bytes, require %d", available, minimumBuildDiskBytes)
}
return nil
}
func (a *API) insertBuildRequest(b buildRecord, user string) error {
tx, err := a.s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec("INSERT INTO image_builds(id,version,status,created_at,requested_by,recipe_rev,platform) VALUES(?,?,?,?,?,?,?)", b.ID, b.Version, b.Status, b.Created, b.RequestedBy, b.RecipeRev, b.Platform); err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO image_build_requests(user_id,build_id,created_at) VALUES(?,?,?)", user, b.ID, b.Created); err != nil {
return err
}
return tx.Commit()
}
func (a *API) reconcileBuilds(parent context.Context, records []buildRecord) error {
ctx, cancel := context.WithTimeout(parent, 15*time.Second)
defer cancel()
for i := range records {
b := &records[i]
if b.Status != "success" {
continue
}
if b.RecipeRev != imageRecipeRev || b.Platform != imagePlatform {
b.Status, b.Error = "missing", "image template identity is unknown"
if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil {
return err
}
continue
}
id, err := a.localBuildImage(ctx, b.Version, b.ImageID)
if err != nil {
return err
}
if id == "" || id != b.ImageID {
b.Status, b.Error = "missing", "image no longer available"
if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil {
return err
}
}
}
return nil
}
+381
View File
@@ -0,0 +1,381 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestImageLegacyMigrationNeverCertifies(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/json" {
io.WriteString(w, "[]")
return
}
writeImageInspect(w, "legacy-image", "1.2.3")
})
_, err := a.s.DB.Exec(`CREATE TABLE image_builds(id TEXT PRIMARY KEY,version TEXT NOT NULL,status TEXT NOT NULL,logs TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',image_id TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL);
CREATE UNIQUE INDEX image_builds_version ON image_builds(version) WHERE status IN ('queued','running','validating','success');
INSERT INTO image_builds(id,version,status,image_id,created_at) VALUES('legacy','1.2.3','success','legacy-image','now'),('queued','1.2.4','queued','','now');
CREATE TABLE images(image_id TEXT PRIMARY KEY,version TEXT NOT NULL,tag TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,built_at TEXT NOT NULL);
INSERT INTO images VALUES('legacy-image','1.2.3','','available','now');`)
if err != nil {
t.Fatal(err)
}
verified, err := a.verifiedImages(context.Background())
if err != nil || len(verified) != 0 {
t.Fatalf("%v %v", verified, err)
}
var recipe, platform sql.NullString
if err = a.s.DB.QueryRow("SELECT recipe_rev,platform FROM image_builds WHERE id='legacy'").Scan(&recipe, &platform); err != nil || recipe.Valid || platform.Valid {
t.Fatalf("legacy metadata inferred: %v %v %v", recipe, platform, err)
}
w := imageRequest(a.images, sid, "GET", "")
var images []imageRecord
if err = json.Unmarshal(w.Body.Bytes(), &images); err != nil || len(images) != 1 || images[0].Status != "unverified" || images[0].RecipeRev != imageRecipeRev || images[0].Platform != imagePlatform {
t.Fatal(w.Body.String(), err)
}
if err = a.s.DB.QueryRow("SELECT recipe_rev,platform FROM image_builds WHERE id='legacy'").Scan(&recipe, &platform); err != nil || recipe.Valid || platform.Valid {
t.Fatalf("GET certified legacy metadata: %v %v %v", recipe, platform, err)
}
var status string
if err = a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id='queued'").Scan(&status); err != nil || status != "failed" {
t.Fatal(status, err)
}
seedVerifiedImage(t, a, "new-image", "1.2.3")
verified, err = a.verifiedImages(context.Background())
if err != nil || len(verified) != 1 || verified["new-image"] != "1.2.3" {
t.Fatal(verified, err)
}
}
func TestImageInspectIdentity(t *testing.T) {
for _, mode := range []string{"valid", "recipe", "wrong-key", "platform", "variant", "unknown-platform", "role", "version", "id", "missing", "unavailable"} {
t.Run(mode, func(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatal("unexpected mutation")
}
if mode == "missing" {
w.WriteHeader(404)
return
}
if mode == "unavailable" {
w.WriteHeader(500)
return
}
parts := strings.Split(imagePlatform, "/")
labels := map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}
im := map[string]any{"Id": "image", "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": labels}}
switch mode {
case "recipe":
labels["dsh-mgr.recipe"] = "old"
case "wrong-key":
delete(labels, "dsh-mgr.recipe")
labels["dsh-mgr.recipe_rev"] = imageRecipeRev
case "platform":
im["Architecture"] = "other"
case "variant":
im["Variant"] = "v7"
case "unknown-platform":
delete(im, "Architecture")
case "role":
labels["dsh-mgr.role"] = "other"
case "version":
labels["dsh-mgr.version"] = "2.0.0"
case "id":
im["Id"] = "other"
}
writeJSON(w, im)
})
ok, err := a.imageMatches(context.Background(), "image", "1.2.3")
if ok != (mode == "valid") || (err != nil) != (mode == "unavailable") {
t.Fatal(ok, err)
}
})
}
}
func TestImageIndexLocalInventory(t *testing.T) {
for _, mode := range []string{"external", "old-recipe", "other-platform", "inspect-failure"} {
t.Run(mode, func(t *testing.T) {
var deleted atomic.Bool
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("unexpected Docker mutation: %s", r.Method)
}
if r.URL.Path == "/v1.47/images/json" {
if deleted.Load() {
io.WriteString(w, "[]")
} else {
io.WriteString(w, `[{"Id":"image","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.recipe":"2","dsh-mgr.version":"9.9.9"}}]`)
}
return
}
if mode == "inspect-failure" {
w.WriteHeader(500)
return
}
if deleted.Load() {
w.WriteHeader(404)
return
}
parts := strings.Split(imagePlatform, "/")
im := inspectedImage{ID: "image", Os: parts[0], Architecture: parts[1], Created: "2026-01-01T00:00:00Z", RepoTags: []string{"dsh-web:1.2.3"}}
im.Config.Labels = map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}
if mode == "old-recipe" {
im.Config.Labels["dsh-mgr.recipe"] = "1"
}
if mode == "other-platform" {
im.Architecture, im.Variant = "arm", "v7"
}
writeJSON(w, im)
})
if mode != "external" {
seedVerifiedImage(t, a, "image", "1.2.3")
}
if mode == "old-recipe" {
if _, err := a.s.DB.Exec("UPDATE image_builds SET recipe_rev='1'"); err != nil {
t.Fatal(err)
}
}
w := imageRequest(a.images, sid, "GET", "")
if mode == "inspect-failure" {
var status string
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "success" || w.Code != 502 {
t.Fatal(status, w.Code, err)
}
return
}
var images []imageRecord
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "unverified" || images[0].Version != "1.2.3" || images[0].Created != "2026-01-01T00:00:00Z" || images[0].Tag != "dsh-web:1.2.3" {
t.Fatal(w.Body.String(), err)
}
if mode == "other-platform" && images[0].Platform != strings.Split(imagePlatform, "/")[0]+"/arm/v7" {
t.Fatal(images[0].Platform)
}
if mode == "old-recipe" && images[0].RecipeRev != "1" {
t.Fatal(images[0].RecipeRev)
}
if w := imageRequest(a.instances, sid, "POST", `{"name":"rejected","image_id":"image"}`); w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
deleted.Store(true)
w = imageRequest(a.images, sid, "GET", "")
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "missing" {
t.Fatal(w.Body.String(), err)
}
})
}
}
func TestImageIndexUsageMissingAndDockerFailure(t *testing.T) {
var mode atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("unexpected mutation")
}
if r.URL.Path == "/v1.47/images/json" && mode.Load() != 1 {
io.WriteString(w, "[]")
return
}
switch mode.Load() {
case 1:
w.WriteHeader(500)
case 2:
w.WriteHeader(404)
default:
writeImageInspect(w, "image", "1.2.3")
}
})
seedVerifiedImage(t, a, "image", "1.2.3")
if _, err := a.s.DB.Exec(`INSERT INTO instances(id,uid,name,image_id,status,created) VALUES('one','u','one','image','running',0),('two','other-user','two','image','stopped',0),('three','u','three','other','running',0)`); err != nil {
t.Fatal(err)
}
check := func(status string, count int) {
t.Helper()
w := imageRequest(a.images, sid, "GET", "")
var images []imageRecord
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != status || images[0].UsageCount != count {
t.Fatal(w.Body.String(), err)
}
var storedStatus string
var storedCount int
if err := a.s.DB.QueryRow("SELECT status,usage_count FROM images WHERE image_id='image'").Scan(&storedStatus, &storedCount); err != nil || storedStatus != status || storedCount != count {
t.Fatal(storedStatus, storedCount, err)
}
}
check("available", 2)
mode.Store(1)
if w := imageRequest(a.images, sid, "GET", ""); w.Code != 502 {
t.Fatal(w.Code, w.Body.String())
}
var status string
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "success" {
t.Fatal(status, err)
}
mode.Store(2)
check("missing", 2)
if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id='two'"); err != nil {
t.Fatal(err)
}
check("missing", 1)
verified, err := a.verifiedImages(context.Background())
if err != nil || len(verified) != 0 {
t.Fatal(verified, err)
}
}
func TestBuildRateLimitDoesNotPenalizeSharedTask(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/verified/json" {
writeImageInspect(w, "verified", "1.2.3")
return
}
io.WriteString(w, `[{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
})
seedVerifiedImage(t, a, "verified", "1.2.3")
for _, id := range []string{"a", "b", "c"} {
if _, err := a.s.DB.Exec("INSERT INTO image_build_requests VALUES('u',?,?)", id, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
}
if w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`); w.Code != 202 {
t.Fatal(w.Code, w.Body.String())
}
if w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.4"}`); w.Code != 429 || w.Header().Get("Retry-After") == "" {
t.Fatal(w.Code, w.Body.String())
}
var n int
a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests").Scan(&n)
if n != 3 {
t.Fatal(n)
}
}
func TestBuildDiskPolicy(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected build") })
if err := a.checkBuildDisk(context.Background()); err != nil {
t.Fatal(err)
}
probeErr := errors.New("capacity probe failed")
for _, tc := range []struct {
name string
available uint64
err error
want string
}{
{"insufficient", minimumBuildDiskBytes - 1, nil, "insufficient"},
{"exact", minimumBuildDiskBytes, nil, ""},
{"probe-error", 0, probeErr, "unavailable"},
} {
t.Run(tc.name, func(t *testing.T) {
buildDiskAvailable = func(path string) (uint64, error) {
if path != os.Getenv("DSH_DOCKER_ROOT_VISIBLE") {
t.Fatalf("unexpected probe path %q", path)
}
return tc.available, tc.err
}
err := a.checkBuildDisk(context.Background())
if tc.want == "" && err != nil || tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
t.Fatal(err)
}
if tc.err != nil && !errors.Is(err, tc.err) {
t.Fatalf("probe error not preserved: %v", err)
}
})
}
buildDiskAvailable = func(string) (uint64, error) {
t.Fatal("unconfirmed Docker root must not be probed")
return 0, nil
}
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "")
if err := a.checkBuildDisk(context.Background()); err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatal(err)
}
if requireBuildDisk(minimumBuildDiskBytes-1) == nil || requireBuildDisk(minimumBuildDiskBytes) != nil {
t.Fatal("disk threshold")
}
}
func TestUnknownDockerDiskBlocksBuild(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1.47/images/json" {
t.Errorf("unexpected Docker operation %s", r.URL.Path)
}
io.WriteString(w, "[]")
})
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "")
w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`)
if w.Code != 202 {
t.Fatal(w.Body.String())
}
deadline := time.Now().Add(3 * time.Second)
for {
var status, detail string
if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds").Scan(&status, &detail); err != nil {
t.Fatal(err)
}
if status == "failed" {
if !strings.Contains(detail, "capacity unknown") {
t.Fatal(detail)
}
break
}
if time.Now().After(deadline) {
t.Fatal(status, detail)
}
time.Sleep(time.Millisecond)
}
}
func TestExactNPMMetadata(t *testing.T) {
for _, test := range []struct {
body string
status int
ok bool
}{
{`{"version":"1.2.3"}`, 200, true},
{`{"version":"1.2.4"}`, 200, false},
{`{"version":"latest"}`, 200, false},
{`{}`, 404, false},
{`invalid`, 200, false},
} {
client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.URL.Host != "registry.npmjs.org" || !strings.HasSuffix(r.URL.Path, "/1.2.3") {
t.Error(r.URL)
}
return &http.Response{StatusCode: test.status, Body: io.NopCloser(strings.NewReader(test.body)), Header: make(http.Header)}, nil
})}
_, err := fetchNPMVersion(context.Background(), client, "1.2.3")
if (err == nil) != test.ok {
t.Fatalf("%s: %v", test.body, err)
}
}
}
func TestBuildListReconcilesMissingAndLatestTimestamp(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "[]") })
seedVerifiedImage(t, a, "deleted", "1.2.3")
w := imageRequest(a.builds, sid, "GET", "")
var records []buildRecord
if err := json.Unmarshal(w.Body.Bytes(), &records); err != nil || len(records) != 1 || records[0].Status != "missing" {
t.Fatal(w.Body.String())
}
w = imageRequest(a.latest, sid, "GET", "")
var latest map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &latest); err != nil {
t.Fatal(err)
}
if _, err := time.Parse(time.RFC3339, latest["checked_at"]); err != nil || latest["version"] != "1.2.3" {
t.Fatal(latest, err)
}
}
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"archive/tar"
"context"
"encoding/json"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func imageTestAPI(t *testing.T, h http.HandlerFunc) (*API, string) {
t.Helper()
oldDiskAvailable := buildDiskAvailable
buildDiskAvailable = func(string) (uint64, error) { return minimumBuildDiskBytes, nil }
t.Cleanup(func() { buildDiskAvailable = oldDiskAvailable })
oldClient := npmBuildClient
npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"1.2.3"}`))}, nil
})}
t.Cleanup(func() { npmBuildClient = oldClient })
root := t.TempDir()
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", root)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/info" {
writeJSON(w, map[string]string{"DockerRootDir": root})
return
}
h(w, r)
}))
t.Cleanup(srv.Close)
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
s.DB.SetMaxOpenConns(1)
a := New(s, t.TempDir())
a.docker = docker.New(srv.URL)
t.Cleanup(func() {
deadline := time.Now().Add(5 * time.Second)
for {
buildQueue.Lock()
active := buildQueue.active[s.DB]
buildQueue.Unlock()
if !active {
break
}
if time.Now().After(deadline) {
t.Error("worker did not finish")
break
}
time.Sleep(time.Millisecond)
}
s.Close()
})
if err = s.CreateUser(store.User{ID: "u", Username: "user"}, "unused"); err != nil {
t.Fatal(err)
}
sid, _, err := s.Session("u")
if err != nil {
t.Fatal(err)
}
registerTestAccess(t, a, sid)
return a, sid
}
func imageRequest(h http.HandlerFunc, sid, method, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/", strings.NewReader(body))
if sid != "" {
r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid})
addTestAccess(r, sid)
}
w := httptest.NewRecorder()
h(w, r)
return w
}
func TestImageListContract(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/unverified/json" {
writeImageInspect(w, "unverified", "1.2.3")
return
}
if r.URL.Path == "/v1.47/images/sha256:abc/json" {
writeImageInspect(w, "sha256:abc", "1.2.3")
return
}
if !strings.Contains(r.URL.Query().Get("filters"), "dsh-mgr.role=runtime") {
t.Error("missing label filter")
}
io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"},"RepoTags":["runtime:1.2.3"]},{"Id":"other","Labels":{}},{"Id":"unverified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"9.9.9"}}]`)
})
seedVerifiedImage(t, a, "sha256:abc", "1.2.3")
if w := imageRequest(a.images, "", "GET", ""); w.Code != 401 {
t.Fatal(w.Code)
}
w := imageRequest(a.images, sid, "GET", "")
var images []imageRecord
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
t.Fatal(err)
}
if len(images) != 2 || images[0].ID != "sha256:abc" || images[0].ImageID != images[0].ID || images[0].Status != "available" || images[1].Status != "unverified" {
t.Fatal(w.Body.String())
}
if w = imageRequest(a.images, sid, "POST", ""); w.Code != 405 {
t.Fatal(w.Code)
}
}
func seedVerifiedImage(t *testing.T, a *API, id, version string) {
t.Helper()
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,image_id,created_at,recipe_rev,platform) VALUES(?,?,'success',?,'now',?,?)", store.ID(), version, id, imageRecipeRev, imagePlatform); err != nil {
t.Fatal(err)
}
}
func TestImageContextAndSanitizing(t *testing.T) {
r, err := imageBuildContext()
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(r)
n := 0
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(tr)
original, err := os.ReadFile(filepath.Join("..", "..", "image", h.Name))
if err != nil || string(data) != string(original) {
t.Fatalf("recipe drift %s: %v", h.Name, err)
}
if h.Name == "Dockerfile" && (!strings.Contains(string(data), "ARG RECIPE_REV="+imageRecipeRev+"\n") || !strings.Contains(string(data), "dsh-mgr.recipe=$RECIPE_REV")) {
t.Fatal("recipe revision or label differs from image policy")
}
n++
}
if n != 7 {
t.Fatal(n)
}
s := sanitizeBuildLog("token=topsecret Authorization: Bearer hidden password=private https://user:[email protected]/")
for _, secret := range []string{"topsecret", "hidden", "private", "user:pass"} {
if strings.Contains(s, secret) {
t.Fatal(s)
}
}
if len(sanitizeBuildLog(strings.Repeat("x", 2*buildLogLimit))) > buildLogLimit {
t.Fatal("unbounded")
}
for _, v := range []string{"latest", "*", "^1.2.3", "1.2", "1.2.3;id", "https://example.com", ""} {
if validBuildVersion(v) {
t.Fatal(v)
}
}
if !validBuildVersion("1.2.3-beta.1") {
t.Fatal("fixed prerelease rejected")
}
}
type imageRoundTrip func(*http.Request) (*http.Response, error)
func (f imageRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestLatestOfficialRegistry(t *testing.T) {
client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.URL.Host != "registry.npmjs.org" || r.URL.Scheme != "https" {
t.Fatal(r.URL)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"1.2.3"}`)), Header: make(http.Header)}, nil
})}
v, err := fetchLatest(context.Background(), client)
if err != nil || v != "1.2.3" {
t.Fatalf("%s %v", v, err)
}
}
func TestBuildDedupAndVerification(t *testing.T) {
var calls, deleted atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json":
writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "1.2.3")
case "/v1.47/images/json":
if calls.Load() == 0 {
io.WriteString(w, "[]")
} else {
io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
}
case "/v1.47/build":
calls.Add(1)
if r.URL.Query().Get("t") != "dsh-web:1.2.3-"+imageRecipeRev {
t.Error("unexpected tag", r.URL.Query())
}
var args map[string]string
json.Unmarshal([]byte(r.URL.Query().Get("buildargs")), &args)
if args["DSH_VERSION"] != "1.2.3" {
t.Error(args)
}
io.Copy(io.Discard, r.Body)
io.WriteString(w, `{"stream":"token=secretvalue"}`+string(rune(10))+`{"aux":{"ID":"sha256:abc"}}`)
case "/v1.47/containers/create":
var cfg struct {
Image string
HostConfig struct {
NetworkMode string
ReadonlyRootfs bool
Tmpfs map[string]string
}
}
json.NewDecoder(r.Body).Decode(&cfg)
if cfg.Image != "sha256:abc" || cfg.HostConfig.NetworkMode != "none" || !cfg.HostConfig.ReadonlyRootfs || !strings.Contains(cfg.HostConfig.Tmpfs["/tmp"], "exec") {
t.Errorf("unsafe config %+v", cfg)
}
io.WriteString(w, `{"Id":"check"}`)
case "/v1.47/containers/check/start", "/v1.47/exec/probe/start":
w.WriteHeader(204)
case "/v1.47/containers/check/exec":
var cfg struct{ Cmd []string }
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil || len(cfg.Cmd) != 3 || !strings.Contains(cfg.Cmd[2], "execFileSync('dsh',['--version']") || !strings.Contains(cfg.Cmd[2], `actual!=="1.2.3"`) {
t.Errorf("missing version verification: %+v %v", cfg, err)
}
io.WriteString(w, `{"Id":"probe"}`)
case "/v1.47/exec/probe/json":
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
case "/v1.47/containers/check/json":
io.WriteString(w, `{"State":{"Running":true}}`)
case "/v1.47/containers/check":
deleted.Add(1)
w.WriteHeader(204)
default:
t.Errorf("unexpected %s", r.URL)
w.WriteHeader(500)
}
})
responses := make(chan *httptest.ResponseRecorder, 8)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
responses <- imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`)
}()
}
wg.Wait()
close(responses)
first := ""
for w := range responses {
if w.Code != 202 {
t.Fatal(w.Body.String())
}
var b buildRecord
json.Unmarshal(w.Body.Bytes(), &b)
if first == "" {
first = b.ID
}
if first != b.ID {
t.Fatal("duplicate job")
}
}
deadline := time.Now().Add(5 * time.Second)
for {
w := imageRequest(a.builds, sid, "GET", "")
var jobs []buildRecord
json.Unmarshal(w.Body.Bytes(), &jobs)
if len(jobs) == 1 && jobs[0].Status == "success" {
if strings.Contains(jobs[0].Logs, "secretvalue") {
t.Fatal(jobs)
}
break
}
if time.Now().After(deadline) {
t.Fatal(w.Body.String())
}
time.Sleep(time.Millisecond)
}
if calls.Load() != 1 || deleted.Load() != 1 {
t.Fatalf("builds %d cleanup %d", calls.Load(), deleted.Load())
}
for _, body := range []string{`{"version":"latest"}`, `{"version":"1.2.3","url":"bad"}`, `{"version":"1.2.3"} {}`} {
if w := imageRequest(a.builds, sid, "POST", body); w.Code != 400 {
t.Fatal(w.Code)
}
}
}
func TestVerificationFailureCleansUp(t *testing.T) {
var deleted atomic.Bool
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/create"):
io.WriteString(w, `{"Id":"bad"}`)
case r.Method == "DELETE":
deleted.Store(true)
w.WriteHeader(204)
default:
w.WriteHeader(500)
io.WriteString(w, `{"message":"cannot start"}`)
}
})
if err := a.verifyBuiltImage(context.Background(), "bad", "1.2.3"); err == nil {
t.Fatal("accepted failed start")
}
if !deleted.Load() {
t.Fatal("container leaked")
}
}
func TestBuildReuseAndMissingImage(t *testing.T) {
var exists atomic.Bool
exists.Store(true)
var builds, checks atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json":
writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "1.2.3")
case "/v1.47/images/json":
if exists.Load() {
io.WriteString(w, `[{"Id":"sha256:reuse","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
} else {
io.WriteString(w, "[]")
}
case "/v1.47/build":
builds.Add(1)
exists.Store(true)
io.Copy(io.Discard, r.Body)
io.WriteString(w, `{"aux":{"ID":"sha256:reuse"}}`)
case "/v1.47/containers/create":
checks.Add(1)
io.WriteString(w, `{"Id":"c"}`)
case "/v1.47/containers/c/start", "/v1.47/exec/e/start", "/v1.47/containers/c":
w.WriteHeader(204)
case "/v1.47/containers/c/exec":
io.WriteString(w, `{"Id":"e"}`)
case "/v1.47/exec/e/json":
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
case "/v1.47/containers/c/json":
io.WriteString(w, `{"State":{"Running":true}}`)
default:
t.Errorf("unexpected %s", r.URL)
w.WriteHeader(500)
}
})
wait := func(id string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
var status, image string
err := a.s.DB.QueryRow("SELECT status,image_id FROM image_builds WHERE id=?", id).Scan(&status, &image)
if err == nil && status == "success" {
if image != "sha256:reuse" {
t.Fatal(image)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("status %s: %v", status, err)
}
time.Sleep(time.Millisecond)
}
}
post := func() buildRecord {
t.Helper()
w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`)
if w.Code != 202 {
t.Fatal(w.Body.String())
}
var b buildRecord
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil {
t.Fatal(err)
}
return b
}
first := post()
wait(first.ID)
if builds.Load() != 0 || checks.Load() != 1 {
t.Fatal("local image not reused and verified")
}
exists.Store(false)
second := post()
if second.ID == first.ID {
t.Fatal("missing image deduplicated")
}
wait(second.ID)
if builds.Load() != 1 || checks.Load() != 2 {
t.Fatal("missing image not rebuilt")
}
}
func TestBuildRestartRecovery(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) })
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,created_at) VALUES('old','1.2.3','running','now')"); err != nil {
t.Fatal(err)
}
delete(buildQueue.initialized, a.s.DB)
if err := a.ensureBuilds(); err != nil {
t.Fatal(err)
}
var status, detail string
if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds WHERE id='old'").Scan(&status, &detail); err != nil {
t.Fatal(err)
}
if status != "failed" || !strings.Contains(detail, "restart") {
t.Fatalf("%s %s", status, detail)
}
}
func TestFailedVerificationNeverAvailable(t *testing.T) {
var deleted atomic.Bool
imageID := "sha256:" + strings.Repeat("c", 64)
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json":
writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "1.2.3")
case "/v1.47/images/json":
writeJSON(w, []any{map[string]any{"Id": imageID, "Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}}})
case "/v1.47/containers/create":
io.WriteString(w, `{"Id":"check"}`)
case "/v1.47/containers/check/start", "/v1.47/exec/probe/start":
w.WriteHeader(204)
case "/v1.47/containers/check/exec":
io.WriteString(w, `{"Id":"probe"}`)
case "/v1.47/exec/probe/json":
io.WriteString(w, `{"Running":false,"ExitCode":1}`)
case "/v1.47/containers/check":
deleted.Store(true)
w.WriteHeader(204)
case "/v1.47/images/" + imageID + "/json":
writeImageInspect(w, imageID, "1.2.3")
default:
t.Errorf("unexpected Docker call %s", r.URL)
w.WriteHeader(500)
}
})
w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`)
if w.Code != 202 {
t.Fatal(w.Body.String())
}
deadline := time.Now().Add(5 * time.Second)
for {
var status, id, detail string
err := a.s.DB.QueryRow("SELECT status,image_id,error FROM image_builds").Scan(&status, &id, &detail)
if err == nil && status == "failed" {
if id != imageID || !strings.Contains(detail, "verification failed") || !deleted.Load() {
t.Fatalf("%s %s cleanup=%v", id, detail, deleted.Load())
}
break
}
if time.Now().After(deadline) {
t.Fatalf("status=%s error=%v", status, err)
}
time.Sleep(time.Millisecond)
}
w = imageRequest(a.images, sid, "GET", "")
var images []imageRecord
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "unverified" {
t.Fatal("failed image advertised", w.Body.String())
}
w = imageRequest(a.instances, sid, "POST", fmt.Sprintf(`{"name":"rejected","image_id":%q}`, imageID))
if w.Code != 400 {
t.Fatal("unverified image accepted", w.Code, w.Body.String())
}
}
func TestLocalBuildImagePrefersPersistedID(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/verified/json" {
writeImageInspect(w, "verified", "1.2.3")
return
}
io.WriteString(w, `[{"Id":"newer","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
})
id, err := a.localBuildImage(context.Background(), "1.2.3", "verified")
if err != nil || id != "verified" {
t.Fatalf("id=%s err=%v", id, err)
}
}
func writeImageInspect(w http.ResponseWriter, id, version string) {
parts := strings.Split(imagePlatform, "/")
writeJSON(w, map[string]any{"Id": id, "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": version, "dsh-mgr.recipe": imageRecipeRev}}})
}
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func (a *API) intents(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if err = a.s.EnsureIntents(); err != nil {
fail(w, 500, "database error")
return
}
switch r.Method {
case http.MethodGet:
list, err := a.s.Intents(u.ID)
if err != nil {
fail(w, 500, "database error")
return
}
writeJSON(w, list)
case http.MethodPost:
var in struct {
Name string `json:"name"`
Version string `json:"version"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
dec.DisallowUnknownFields()
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || strings.TrimSpace(in.Name) == "" || len(in.Name) > 200 || !validBuildVersion(in.Version) {
fail(w, 400, "name and fixed version required")
return
}
i := store.Intent{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), Version: in.Version, Host: a.runtimeHost(r), Status: "waiting"}
if err = a.s.ReserveIntent(i); err != nil {
if errors.Is(err, store.ErrInstanceLimit) {
fail(w, 409, err.Error())
} else {
fail(w, 500, "database error")
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSON(w, i)
default:
fail(w, 405, "method not allowed")
}
}
func (a *API) intent(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodDelete {
fail(w, 405, "method not allowed")
return
}
if err = a.s.EnsureIntents(); err != nil {
fail(w, 500, "database error")
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/intents/")
if err = a.s.CancelIntent(id, u.ID); err != nil {
if errors.Is(err, store.ErrIntentState) {
fail(w, 409, "intent not found or no longer waiting")
} else {
fail(w, 500, "database error")
}
return
}
writeJSON(w, map[string]string{"status": "cancelled"})
}
// Call once at startup, after the build queue has recovered interrupted jobs.
func (a *API) StartIntents(ctx context.Context) error {
if err := a.s.EnsureIntents(); err != nil {
return err
}
if err := a.recoverIntents(ctx); err != nil {
return err
}
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
}
a.processIntents(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return nil
}
func (a *API) finishIntent(id, status, detail string) {
_, _ = a.s.DB.Exec(`UPDATE creation_intents SET status=?,error=? WHERE id=? AND status IN ('waiting','creating')`, status, detail, id)
}
func (a *API) recoverIntents(ctx context.Context) error {
list, err := a.s.Intents("")
if err != nil {
return err
}
for _, in := range list {
if in.Status != "creating" {
continue
}
i, err := a.s.GetInstance(in.InstanceID)
if err == nil && i.Status == "running" {
a.finishIntent(in.ID, "success", "")
continue
}
cleanup, cancel := context.WithTimeout(ctx, 30*time.Second)
// Docker may have created the container before its ID reached SQLite.
err = a.removeRuntimeContainer(cleanup, runtimeNetwork(in.InstanceID))
if err == nil {
err = a.removeRuntimeNetwork(cleanup, in.InstanceID)
}
cancel()
if err != nil {
return err
}
a.s.DeleteInstance(in.InstanceID)
a.finishIntent(in.ID, "failed", "instance creation interrupted by process restart")
}
return nil
}
func (a *API) processIntents(ctx context.Context) {
list, err := a.s.Intents("")
if err != nil {
return
}
for _, in := range list {
if ctx.Err() != nil {
return
}
if in.Status != "waiting" {
continue
}
if in.BuildID == "" {
b, err := a.enqueueBuild(ctx, in.UID, in.Version)
if err != nil {
a.finishIntent(in.ID, "failed", "build submission failed")
continue
}
if _, err = a.s.DB.Exec(`UPDATE creation_intents SET build_id=? WHERE id=? AND status='waiting'`, b.ID, in.ID); err != nil {
continue
}
in.BuildID = b.ID
}
var status, imageID, version string
err := a.s.DB.QueryRow(`SELECT status,image_id,version FROM image_builds WHERE id=?`, in.BuildID).Scan(&status, &imageID, &version)
if err != nil {
continue
}
if version != in.Version || status == "failed" || status == "cancelled" || status == "missing" {
a.finishIntent(in.ID, "failed", "build failed; requested version was not created")
continue
}
if status != "success" {
continue
}
work, cancel := context.WithTimeout(ctx, 90*time.Second)
verified, err := a.verifiedImages(work)
if err != nil {
cancel()
continue
}
if !runtimeImageID.MatchString(imageID) || verified[imageID] != in.Version {
cancel()
a.finishIntent(in.ID, "failed", "verified image for requested version unavailable")
continue
}
u, err := a.s.UserByID(in.UID)
if err != nil {
cancel()
a.finishIntent(in.ID, "failed", "owner unavailable")
continue
}
i := store.Instance{ID: store.ID(), UID: in.UID, Name: in.Name, Version: in.Version, ImageID: imageID, Status: "creating", Created: time.Now()}
if err = a.s.ClaimIntent(in.ID, i); err != nil {
cancel()
continue
}
_, err = a.createRuntime(work, u, i, in.Host)
cancel()
if err != nil {
a.finishIntent(in.ID, "failed", err.Error())
} else {
a.finishIntent(in.ID, "success", "")
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"context"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestIntentsReserveCancel(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("reservation contacted Docker") })
if err := a.initAuth(); err != nil {
t.Fatal(err)
}
for n := 0; n < 3; n++ {
w := httptest.NewRecorder()
a.intents(w, runtimeRequest("POST", "/api/intents", `{"name":"one","version":"1.2.3"}`, sid))
want := 202
if n == 2 {
want = 409
}
if w.Code != want {
t.Fatalf("%d: %d %s", n, w.Code, w.Body.String())
}
}
list, err := a.s.Intents("owner")
if err != nil || len(list) != 2 {
t.Fatalf("%v %v", list, err)
}
w := httptest.NewRecorder()
a.intent(w, runtimeRequest("DELETE", "/api/intents/"+list[0].ID, "", sid))
if w.Code != 200 {
t.Fatal(w.Code)
}
if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil {
t.Fatal(err)
}
}
func TestIntentBuildSuccessUsesRealRuntime(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("runtime filesystem ownership requires root")
}
image := "sha256:" + strings.Repeat("a", 64)
creates := 0
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/images/"):
writeJSON(w, map[string]any{"Id": image, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3"}}})
case strings.HasSuffix(r.URL.Path, "/containers/create"):
creates++
writeJSON(w, map[string]string{"Id": "runtime-created"})
default:
w.WriteHeader(204)
}
})
seedVerifiedImage(t, a, image, "1.2.3")
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
var build string
if err := a.s.DB.QueryRow("SELECT id FROM image_builds WHERE version='1.2.3'").Scan(&build); err != nil {
t.Fatal(err)
}
in := store.Intent{ID: store.ID(), UID: "owner", Name: "one", Version: "1.2.3", Host: "panel.test"}
if err := a.s.ReserveIntent(in); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("UPDATE creation_intents SET build_id=? WHERE id=?", build, in.ID); err != nil {
t.Fatal(err)
}
a.processIntents(context.Background())
a.processIntents(context.Background())
list, err := a.s.Intents("owner")
if err != nil || len(list) != 1 || list[0].Status != "success" || creates != 1 {
t.Fatalf("%+v err=%v creates=%d", list, err, creates)
}
instances, err := a.s.Instances("owner")
if err != nil || len(instances) != 1 || instances[0].ImageID != image || instances[0].Version != "1.2.3" {
t.Fatalf("%+v %v", instances, err)
}
}
func TestIntentFailedBuildReleasesAndCancelPreservesBuild(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("failed intent contacted Docker") })
seedVerifiedImage(t, a, "sha256:"+strings.Repeat("b", 64), "1.2.3")
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
var build string
a.s.DB.QueryRow("SELECT id FROM image_builds LIMIT 1").Scan(&build)
for _, id := range []string{"cancel", "fail"} {
if err := a.s.ReserveIntent(store.Intent{ID: id, UID: "owner", Version: "1.2.3"}); err != nil {
t.Fatal(err)
}
a.s.DB.Exec("UPDATE creation_intents SET build_id=? WHERE id=?", build, id)
}
if err := a.s.CancelIntent("cancel", "owner"); err != nil {
t.Fatal(err)
}
var status string
a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id=?", build).Scan(&status)
if status != "success" {
t.Fatal(status)
}
a.s.DB.Exec("UPDATE image_builds SET status='failed' WHERE id=?", build)
a.processIntents(context.Background())
for n := 0; n < 2; n++ {
if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil {
t.Fatal(err)
}
}
}
func TestIntentRestartCleansInterruptedCreationAndRetainsWaiting(t *testing.T) {
deletes := 0
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
deletes++
}
w.WriteHeader(204)
})
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
for _, id := range []string{"waiting", "creating"} {
if err := a.s.ReserveIntent(store.Intent{ID: id, UID: "owner", Version: "1.2.3"}); err != nil {
t.Fatal(err)
}
}
if err := a.s.ClaimIntent("creating", store.Instance{ID: "reserved", UID: "owner", Version: "1.2.3", Status: "creating"}); err != nil {
t.Fatal(err)
}
if err := a.recoverIntents(context.Background()); err != nil {
t.Fatal(err)
}
if deletes != 2 {
t.Fatalf("cleanup calls=%d", deletes)
}
list, err := a.s.Intents("owner")
if err != nil {
t.Fatal(err)
}
for _, in := range list {
if in.ID == "waiting" && in.Status != "waiting" {
t.Fatal(in)
}
if in.ID == "creating" && in.Status != "failed" {
t.Fatal(in)
}
}
if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil {
t.Fatal(err)
}
}
+143
View File
@@ -0,0 +1,143 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var orphanID = regexp.MustCompile(`^[a-f0-9]{64}$`)
var orphanLabelID = regexp.MustCompile(`^[a-f0-9]{32}$`)
func (a *API) checkOrphan(ctx context.Context, id string) error {
var c struct {
ID, Name string
Config struct{ Labels map[string]string }
State struct {
Status string
Running, Paused, Restarting bool
}
Mounts []struct{ Type, Source, Destination string }
}
if !orphanID.MatchString(id) {
return errors.New("invalid container ID")
}
if err := a.docker.Do(ctx, "GET", "/containers/"+id+"/json", nil, &c); err != nil {
return errors.New("container unavailable")
}
inst, owner := c.Config.Labels["dsh-mgr.instance"], c.Config.Labels["dsh-mgr.owner"]
if c.ID != id || c.Config.Labels["dsh-mgr.role"] != "dsh" || !orphanLabelID.MatchString(inst) || !orphanLabelID.MatchString(owner) || strings.TrimPrefix(c.Name, "/") != runtimeNetwork(inst) {
return errors.New("container identity not eligible")
}
if c.State.Running || c.State.Paused || c.State.Restarting || (c.State.Status != "exited" && c.State.Status != "created") {
return errors.New("only stopped containers can be reclaimed")
}
var n int
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE id=? OR container_id=?", inst, id).Scan(&n); err != nil || n != 0 {
return errors.New("instance reference or database unavailable")
}
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM users WHERE id=?", owner).Scan(&n); err != nil || n != 1 {
return errors.New("unknown owner")
}
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&n); err != nil {
return err
}
if n != 0 {
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM upgrade_journal WHERE instance_id=? OR old_container=? OR new_container=?", inst, id, id).Scan(&n); err != nil || n != 0 {
return errors.New("upgrade reference or database unavailable")
}
}
host := os.Getenv("HOST_DATA_ROOT")
if host == "" {
host = a.root
}
if !filepath.IsAbs(host) {
return errors.New("host root unavailable")
}
expected := map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "instances", inst, "home")}
// Accept complete historical layouts, never arbitrary bind sources.
for _, m := range c.Mounts {
if m.Destination == "/data/dsh-home" {
expected = map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "files"), "/data/dsh-home": filepath.Join(host, "workspaces", owner, "instances", inst, "home")}
}
}
for _, m := range c.Mounts {
if m.Destination == "/shared" {
expected["/shared"] = filepath.Join(host, "workspaces", owner, "files")
}
}
for _, m := range c.Mounts {
if m.Type == "tmpfs" && m.Destination == "/tmp" {
continue
}
want, ok := expected[m.Destination]
if !ok || m.Type != "bind" || filepath.Clean(m.Source) != want {
return errors.New("mount identity not eligible")
}
delete(expected, m.Destination)
}
if len(expected) != 0 {
return errors.New("required mounts missing")
}
return nil
}
func (a *API) orphans(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
a.mu.Lock()
defer a.mu.Unlock()
switch r.Method {
case "GET":
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=dsh"}})
var list []struct {
ID string `json:"Id"`
}
if a.docker.Do(ctx, "GET", "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil, &list) != nil {
fail(w, 502, "container inventory unavailable")
return
}
eligible := []string{}
for _, c := range list {
if a.checkOrphan(ctx, c.ID) == nil {
eligible = append(eligible, c.ID)
}
}
writeJSON(w, eligible)
case "DELETE":
id := r.URL.Query().Get("id")
if r.URL.Query().Get("confirm") != id || id == "" {
fail(w, 400, "exact container ID confirmation required")
return
}
if err := a.checkOrphan(ctx, id); err != nil {
fail(w, 409, err.Error())
return
}
// Never force removal or remove volumes. Docker rejects a concurrent start.
if a.docker.Do(ctx, "DELETE", "/containers/"+id+"?force=false&v=false", nil, nil) != nil {
fail(w, 409, "container changed or removal rejected")
return
}
a.s.AddAudit(u.ID, "reconcile.reclaim", id, "stopped orphan removed; bind data retained")
writeJSON(w, map[string]bool{"ok": true})
default:
fail(w, 405, "method not allowed")
}
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"context"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOrphanReclaimGuards(t *testing.T) {
for _, kind := range []string{"safe", "running", "foreign-mount", "reference", "upgrade", "confirmation", "nonadmin"} {
t.Run(kind, func(t *testing.T) {
t.Setenv("HOST_DATA_ROOT", "/host/data")
id, inst, owner := strings.Repeat("a", 64), strings.Repeat("b", 32), strings.Repeat("c", 32)
deleted := false
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
if r.URL.Query().Get("force") != "false" || r.URL.Query().Get("v") != "false" {
t.Fatal("unsafe deletion")
}
deleted = true
w.WriteHeader(204)
return
}
source := "/host/data/workspaces/" + owner + "/instances/" + inst + "/home"
if kind == "foreign-mount" {
source = "/unrelated"
}
writeJSON(w, map[string]any{"Id": id, "Name": "/dsh-inst-" + inst, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "dsh", "dsh-mgr.instance": inst, "dsh-mgr.owner": owner}}, "State": map[string]any{"Status": "exited", "Running": kind == "running"}, "Mounts": []map[string]string{{"Type": "bind", "Source": source, "Destination": "/workspace"}}})
})
if err := a.s.CreateUser(store.User{ID: owner, Username: "orphan-owner"}, "unused"); err != nil {
t.Fatal(err)
}
if kind != "nonadmin" {
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='owner'"); err != nil {
t.Fatal(err)
}
}
if kind == "reference" {
if err := a.s.AddInstance(store.Instance{ID: inst, UID: owner, ContainerID: id}); err != nil {
t.Fatal(err)
}
}
if kind == "upgrade" {
if _, err := a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,old_container TEXT,new_container TEXT); INSERT INTO upgrade_journal VALUES(?,?,'')", inst, id); err != nil {
t.Fatal(err)
}
}
if kind == "safe" && a.checkOrphan(context.Background(), id) != nil {
t.Fatal("safe orphan rejected")
}
confirm := id
if kind == "confirmation" {
confirm = "wrong"
}
w := httptest.NewRecorder()
a.orphans(w, runtimeRequest("DELETE", "/api/orphans?id="+id+"&confirm="+confirm, "", sid))
if (w.Code == 200) != (kind == "safe") || deleted != (kind == "safe") {
t.Fatalf("status=%d deleted=%v body=%s", w.Code, deleted, w.Body.String())
}
})
}
}
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"encoding/json"
"io"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
func (a *API) changePassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
if _, _, err := a.user(r); err != nil {
fail(w, 401, "authentication required")
return
}
var in struct {
CurrentPassword string `json:"current_password"`
Password string `json:"password"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
decoder.DisallowUnknownFields()
if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || in.CurrentPassword == "" || len(in.Password) < 12 || len(in.Password) > 72 {
fail(w, 400, "current password and new password 12–72 bytes required")
return
}
// Recheck credentials after waiting for concurrent logins or password resets.
a.authLoginMu.Lock()
defer a.authLoginMu.Unlock()
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
locked, err := a.accountLocked(u.Username)
if err != nil {
fail(w, 503, "authentication unavailable")
return
}
if locked {
fail(w, 429, "account temporarily locked; retry later")
return
}
currentHash, err := a.s.Password(u.Username)
if err != nil {
fail(w, 503, "authentication unavailable")
return
}
if len(in.CurrentPassword) > 72 || bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(in.CurrentPassword)) != nil {
if err = a.failedLogin(u.Username); err != nil {
fail(w, 503, "authentication unavailable")
return
}
fail(w, 400, "invalid current password")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(w, 500, "password error")
return
}
tx, err := a.s.DB.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, "password change failed")
return
}
defer tx.Rollback()
_, err = tx.ExecContext(r.Context(), "UPDATE users SET pass_hash=? WHERE id=?", string(hash), u.ID)
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)", u.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE uid=?", u.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM auth_failures WHERE username=?", u.Username)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "user.change_password", u.ID, "", time.Now().Unix())
}
if err == nil {
err = tx.Commit()
}
if err != nil {
fail(w, 500, "password change failed")
return
}
writeJSON(w, map[string]bool{"ok": true, "reauthenticate": true})
}
+365
View File
@@ -0,0 +1,365 @@
package api
import (
"encoding/json"
"fmt"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const changePath = "/api/me/password"
func TestChangePasswordCurrentLength(t *testing.T) {
a, req := authFixture(t)
password := strings.Repeat("a", 72)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if _, err = a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='alice'", string(hash)); err != nil {
t.Fatal(err)
}
cs := req("POST", "/api/login", resetLogin("alice", password), "", nil).Result().Cookies()
if w := req("POST", changePath, changeBody(password+"x", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 400 {
t.Fatal("accepted prefix", w.Code)
}
if w := req("POST", changePath, changeBody(password, resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 200 {
t.Fatal("72-byte current rejected", w.Code)
}
}
func TestChangePasswordConcurrentChanges(t *testing.T) {
a, req := authFixture(t)
cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
start := make(chan struct{})
results := make(chan int, 2)
for i := 0; i < 2; i++ {
go func() {
<-start
results <- req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs).Code
}()
}
close(start)
successes := 0
for i := 0; i < 2; i++ {
code := <-results
if code == 200 {
successes++
} else if code != 401 && code != 403 {
t.Fatal(code)
}
}
if successes != 1 {
t.Fatal("multiple changes", successes)
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='user.change_password'").Scan(&n); err != nil || n != 1 {
t.Fatal(n, err)
}
}
func TestChangePasswordRechecksWaitingCredentials(t *testing.T) {
for _, revoke := range []string{"session", "password"} {
t.Run(revoke, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
reader := &resetSignalReader{reader: strings.NewReader(changeBody("password-123", resetSecret)), started: make(chan struct{})}
r := httptest.NewRequest("POST", changePath, reader)
r.Header.Set("Origin", a.origin)
r.Header.Set("X-CSRF-Token", cookieValue(admin, "dsh_csrf"))
for _, c := range admin {
r.AddCookie(c)
}
newHash, err := bcrypt.GenerateFromPassword([]byte(resetSecret), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if _, err = a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='bob'", string(newHash)); err != nil {
t.Fatal(err)
}
a.authLoginMu.Lock()
locked := true
defer func() {
if locked {
a.authLoginMu.Unlock()
}
}()
done := make(chan *httptest.ResponseRecorder, 1)
go func() { w := httptest.NewRecorder(); a.Handler().ServeHTTP(w, r); done <- w }()
select {
case <-reader.started:
case <-time.After(5 * time.Second):
t.Fatal("request did not reach decoder")
}
select {
case w := <-done:
t.Fatal("reset bypassed login lock", w.Code)
case <-time.After(150 * time.Millisecond):
}
query := "UPDATE users SET pass_hash=(SELECT pass_hash FROM users WHERE id='bob') WHERE id='alice'"
want := 400
if revoke == "session" {
query = "DELETE FROM sessions WHERE uid='alice'"
want = 401
}
if _, err := a.s.DB.Exec(query); err != nil {
t.Fatal(err)
}
a.authLoginMu.Unlock()
locked = false
select {
case w := <-done:
if w.Code != want {
t.Fatal(w.Code, w.Body.String())
}
case <-time.After(5 * time.Second):
t.Fatal("reset did not finish")
}
expected := resetSecret
if revoke == "session" {
expected = "password-123"
}
hash, err := a.s.Password("alice")
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(expected)) != nil {
t.Fatal("unauthorized reset", err)
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='user.change_password'").Scan(&n); err != nil || n != 0 {
t.Fatal(n, err)
}
})
}
}
func changeBody(current, password string) string {
b, _ := json.Marshal(map[string]string{"current_password": current, "password": password})
return string(b)
}
func TestChangePasswordValidation(t *testing.T) {
a, req := authFixture(t)
cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
csrf := cookieValue(cs, "dsh_csrf")
valid := changeBody("password-123", resetSecret)
for _, tc := range []struct {
name, method, body, csrf string
cookies []*http.Cookie
code int
}{
{"anonymous", "POST", valid, "", nil, 401},
{"csrf", "POST", valid, "bad", cs, 403},
{"method", "GET", "", csrf, cs, 405},
{"short", "POST", changeBody("password-123", strings.Repeat("a", 11)), csrf, cs, 400},
{"long", "POST", changeBody("password-123", strings.Repeat("a", 73)), csrf, cs, 400},
{"unicode long", "POST", changeBody("password-123", strings.Repeat("密", 25)), csrf, cs, 400},
{"missing current", "POST", changeBody("", resetSecret), csrf, cs, 400},
{"malformed", "POST", "{", csrf, cs, 400},
{"trailing", "POST", valid + "{}", csrf, cs, 400},
{"oversized", "POST", changeBody("password-123", strings.Repeat("a", 4096)), csrf, cs, 400},
{"target id", "POST", strings.TrimSuffix(valid, "}") + `,"id":"bob"}`, csrf, cs, 400},
{"wrong current", "POST", changeBody("wrong", resetSecret), csrf, cs, 400},
{"missing access", "POST", valid, csrf, []*http.Cookie{{Name: "dsh_session", Value: cookieValue(cs, "dsh_session")}}, 401},
} {
t.Run(tc.name, func(t *testing.T) {
w := req(tc.method, changePath, tc.body, tc.csrf, tc.cookies)
if w.Code != tc.code {
t.Fatal(w.Code, w.Body.String())
}
})
}
for _, origin := range []string{"", "https://evil.test"} {
r := httptest.NewRequest("POST", changePath, strings.NewReader(valid))
r.Header.Set("Origin", origin)
r.Header.Set("X-CSRF-Token", csrf)
for _, c := range cs {
r.AddCookie(c)
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(w.Code)
}
}
if w := req("GET", "/api/me", "", "", cs); w.Code != 200 {
t.Fatal("validation revoked session", w.Code)
}
for _, user := range []string{"alice", "bob"} {
if w := req("POST", "/api/login", resetLogin(user, "password-123"), "", nil); w.Code != 200 {
t.Fatal("validation changed password", user, w.Code)
}
}
}
func TestChangePasswordLifecycle(t *testing.T) {
for _, password := range []string{strings.Repeat("a", 12), strings.Repeat("密", 24)} {
t.Run(fmt.Sprint(len(password)), func(t *testing.T) {
a, req := authFixture(t)
login := func(user string) []*http.Cookie {
w := req("POST", "/api/login", resetLogin(user, "password-123"), "", nil)
if w.Code != 200 {
t.Fatal(w.Code)
}
return w.Result().Cookies()
}
cs, second, other := login("alice"), login("alice"), login("bob")
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('alice',2,0)"); err != nil {
t.Fatal(err)
}
w := req("POST", changePath, changeBody("password-123", password), cookieValue(cs, "dsh_csrf"), cs)
var result map[string]bool
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !result["ok"] || !result["reauthenticate"] || w.Header().Get("Cache-Control") != "no-store" {
t.Fatal(w.Code, w.Body.String())
}
for _, old := range [][]*http.Cookie{cs, second} {
if w := req("GET", "/api/me", "", "", old); w.Code != 401 {
t.Fatal("old access", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(old, "dsh_csrf"), old); w.Code != 403 {
t.Fatal("old refresh", w.Code)
}
}
for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='alice'": 0, "SELECT count(*) FROM auth_refresh": 1, "SELECT count(*) FROM auth_failures WHERE username='alice'": 0, "SELECT count(*) FROM audit WHERE action='user.change_password' AND uid='alice' AND target='alice' AND detail=''": 1} {
var n int
if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want {
t.Fatal(query, n, err)
}
}
if w := req("GET", "/api/me", "", "", other); w.Code != 200 {
t.Fatal("other session", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(other, "dsh_csrf"), other); w.Code != 200 {
t.Fatal("other refresh", w.Code)
}
if w := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil); w.Code != 401 {
t.Fatal("old password", w.Code)
}
if w := req("POST", "/api/login", resetLogin("alice", password), "", nil); w.Code != 200 {
t.Fatal("new password", w.Code)
}
if w := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil); w.Code != 200 {
t.Fatal("other password", w.Code)
}
})
}
}
func TestChangePasswordConcurrentFailures(t *testing.T) {
a, req := authFixture(t)
cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
results := make(chan int, 12)
start := make(chan struct{})
for i := 0; i < 12; i++ {
go func() {
<-start
results <- req("POST", changePath, changeBody("wrong", resetSecret), cookieValue(cs, "dsh_csrf"), cs).Code
}()
}
close(start)
counts := map[int]int{}
for i := 0; i < 12; i++ {
counts[<-results]++
}
if counts[400] != 5 || counts[429] != 7 {
t.Fatal(counts)
}
var n int
var until int64
if err := a.s.DB.QueryRow("SELECT failures,locked_until FROM auth_failures WHERE username='alice'").Scan(&n, &until); err != nil || n != 5 || until <= time.Now().Unix() {
t.Fatal(n, until, err)
}
if w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 429 {
t.Fatal("logged-in bypass", w.Code)
}
if w := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil); w.Code != 429 {
t.Fatal("login bypass", w.Code)
}
if w := req("GET", "/api/me", "", "", cs); w.Code != 200 {
t.Fatal("wrong password logged out", w.Code)
}
if _, err := a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); err != nil {
t.Fatal(err)
}
if w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 200 {
t.Fatal("expired lock", w.Code)
}
}
func TestChangePasswordRollback(t *testing.T) {
for _, step := range []struct{ table, event string }{{"users", "UPDATE"}, {"auth_refresh", "DELETE"}, {"sessions", "DELETE"}, {"auth_failures", "DELETE"}, {"audit", "INSERT"}} {
t.Run(step.table, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
_ = req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
target := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
before, err := a.s.Password("bob")
if err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('bob',2,0)"); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec(fmt.Sprintf("CREATE TRIGGER reject_reset BEFORE %s ON %s BEGIN SELECT RAISE(ABORT,'forced failure'); END", step.event, step.table)); err != nil {
t.Fatal(err)
}
w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(target, "dsh_csrf"), target)
if w.Code != 500 || strings.Contains(w.Body.String(), resetSecret) {
t.Fatal(w.Code, w.Body.String())
}
after, err := a.s.Password("bob")
if err != nil || before != after {
t.Fatal("hash changed", err)
}
for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='bob'": 1, "SELECT count(*) FROM auth_refresh": 2, "SELECT count(*) FROM auth_failures WHERE username='bob' AND failures=2 AND locked_until=0": 1, "SELECT count(*) FROM audit WHERE action='user.change_password'": 0} {
var n int
if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want {
t.Fatal(query, n, err)
}
}
if w := req("GET", "/api/me", "", "", target); w.Code != 200 {
t.Fatal("session changed", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(target, "dsh_csrf"), target); w.Code != 200 {
t.Fatal("refresh changed", w.Code)
}
})
}
}
func TestChangePasswordConcurrentLogin(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
start := make(chan struct{})
results := make(chan *httptest.ResponseRecorder, 8)
for i := 0; i < 8; i++ {
go func() { <-start; results <- req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil) }()
}
close(start)
w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(admin, "dsh_csrf"), admin)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
for i := 0; i < 8; i++ {
w := <-results
if w.Code != 200 && w.Code != 401 && w.Code != 429 {
t.Fatal(w.Code, w.Body.String())
}
if w.Code == 200 {
if check := req("GET", "/api/me", "", "", w.Result().Cookies()); check.Code != 401 {
t.Fatal("old-password login retained session", check.Code)
}
}
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM sessions WHERE uid='bob'").Scan(&n); err != nil || n != 0 {
t.Fatal("old login sessions", n, err)
}
}
+18
View File
@@ -0,0 +1,18 @@
package api
import (
"errors"
"path/filepath"
"strings"
)
func toHostPath(containerRoot, hostRoot, containerPath string) (string, error) {
if !filepath.IsAbs(containerRoot) || !filepath.IsAbs(hostRoot) || !filepath.IsAbs(containerPath) {
return "", errors.New("absolute storage paths required")
}
rel, err := filepath.Rel(containerRoot, containerPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("storage path escapes root")
}
return filepath.Join(hostRoot, rel), nil
}
+15
View File
@@ -0,0 +1,15 @@
package api
import "testing"
func TestToHostPath(t *testing.T) {
for _, tc := range []struct {
input, want string
bad bool
}{{"/data/workspaces/u/files", "/host/workspaces/u/files", false}, {"/data", "/host", false}, {"/data-other/x", "", true}, {"/data/../secret", "", true}, {"relative", "", true}} {
got, err := toHostPath("/data", "/host", tc.input)
if (err != nil) != tc.bad || got != tc.want {
t.Fatalf("%s: %q %v", tc.input, got, err)
}
}
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"syscall"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"gopkg.in/yaml.v3"
)
const previewLimit = 1 << 20
var bundleReference = regexp.MustCompile("^(@[a-z0-9._-]+/)?[a-z0-9._-]+$")
// The caller must authenticate and check instance ownership before dispatching.
func (a *API) profilePreview(w http.ResponseWriter, r *http.Request, i store.Instance) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
bundles, err := a.profileBundles(i)
if err != nil {
fail(w, 400, "bundle manifest unavailable")
return
}
result := map[string]any{"bundles": bundles, "config": "", "redacted": true}
if i.ContainerID == "" || i.Status != "running" {
result["preview_error"] = "启动实例后可预览已保存的配置。"
writeJSON(w, result)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
content, err := a.dumpProfile(ctx, i.ContainerID)
if err != nil {
result["preview_error"] = "配置预览不可用;命令输出和错误详情已隐藏。"
} else {
result["config"] = content
}
writeJSON(w, result)
}
func (a *API) profileBundles(i store.Instance) (any, error) {
parent, err := upgradeRoot(a.root, i)
if err != nil {
return nil, err
}
defer parent.Close()
if err = configCheckPath(parent, "home/profiles/web/package.json"); err != nil {
return nil, err
}
root, err := configProfileRoot(parent, false)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer root.Close()
f, err := root.OpenFile("package.json", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() || st.Size() > previewLimit {
return nil, errors.New("invalid manifest")
}
data, err := io.ReadAll(io.LimitReader(f, previewLimit+1))
if err != nil || len(data) > previewLimit {
return nil, errors.New("invalid manifest")
}
var pkg map[string]json.RawMessage
if json.Unmarshal(data, &pkg) != nil {
return nil, errors.New("invalid manifest")
}
var profile any
raw := pkg["dsh.profile"]
if raw == nil {
var dsh map[string]json.RawMessage
if pkg["dsh"] != nil && json.Unmarshal(pkg["dsh"], &dsh) != nil {
return nil, errors.New("invalid manifest")
}
raw = dsh["profile"]
}
if raw == nil {
return nil, nil
}
if json.Unmarshal(raw, &profile) != nil {
return nil, errors.New("invalid manifest")
}
return redactPreview(profile, false), nil
}
func (a *API) dumpProfile(ctx context.Context, container string) (string, error) {
var exec struct{ ID string }
err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(container)+"/exec", map[string]any{
"Cmd": []string{"dsh", "--profile", "web", "--dump-config"}, "AttachStdout": true, "AttachStderr": true, "Tty": false,
}, &exec)
if err != nil {
return "", err
}
if exec.ID == "" {
return "", errors.New("missing exec ID")
}
path := "/exec/" + url.PathEscape(exec.ID)
body, _ := json.Marshal(map[string]bool{"Detach": false, "Tty": false})
resp, err := a.docker.Stream(ctx, "POST", path+"/start", bytes.NewReader(body), "application/json")
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := previewStdout(resp.Body)
if err != nil {
return "", err
}
var state struct {
Running bool
ExitCode int
}
if err = a.docker.Do(ctx, "GET", path+"/json", nil, &state); err != nil {
return "", err
}
if state.Running || state.ExitCode != 0 {
return "", errors.New("preview command failed")
}
var document any
decoder := yaml.NewDecoder(bytes.NewReader(data))
if err = decoder.Decode(&document); err != nil {
return "", errors.New("invalid preview")
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return "", errors.New("multiple preview documents")
}
switch document.(type) {
case map[string]any, []any:
// DSH exports its composed plugin tree as a YAML sequence.
default:
return "", errors.New("invalid preview structure")
}
safe, err := yaml.Marshal(redactPreview(document, true))
return string(safe), err
}
func previewStdout(r io.Reader) ([]byte, error) {
var output bytes.Buffer
total := 0
for {
var header [8]byte
_, err := io.ReadFull(r, header[:])
if err == io.EOF {
return output.Bytes(), nil
}
if err != nil {
return nil, err
}
if (header[0] != 1 && header[0] != 2) || header[1] != 0 || header[2] != 0 || header[3] != 0 {
return nil, errors.New("invalid exec stream")
}
size := int64(binary.BigEndian.Uint32(header[4:]))
total += 8
if size > int64(previewLimit-total) {
return nil, errors.New("preview too large")
}
total += int(size)
var dst io.Writer = io.Discard
if header[0] == 1 {
dst = &output
}
if _, err = io.CopyN(dst, r, size); err != nil {
return nil, err
}
}
}
func redactPreview(value any, strict bool) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, item := range v {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key))
sensitive := false
for _, word := range []string{"key", "secret", "token", "password", "passwd", "credential", "authorization", "cookie", "env", "header"} {
sensitive = sensitive || strings.Contains(normalized, word)
}
if sensitive {
out[key] = "[REDACTED]"
} else {
out[key] = redactPreview(item, strict)
}
}
return out
case []any:
out := make([]any, len(v))
for j, item := range v {
out[j] = redactPreview(item, strict)
}
return out
case string:
// Plugins can resolve credentials into arbitrary string fields, not just apiKey.
if strict || !bundleReference.MatchString(v) {
return "[REDACTED]"
}
return v
case map[any]any:
return "[REDACTED]"
default:
return v
}
}
+188
View File
@@ -0,0 +1,188 @@
package api
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func previewFrame(stream byte, text string) []byte {
header := make([]byte, 8)
header[0] = stream
binary.BigEndian.PutUint32(header[4:], uint32(len(text)))
return append(header, []byte(text)...)
}
func TestProfileDumpFixedCommandAndRedaction(t *testing.T) {
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/containers/container/exec":
var request struct {
Cmd []string
AttachStdout, AttachStderr, Tty bool
}
if json.NewDecoder(r.Body).Decode(&request) != nil {
t.Error("invalid exec request")
}
if r.Method != "POST" || !reflect.DeepEqual(request.Cmd, []string{"dsh", "--profile", "web", "--dump-config"}) || !request.AttachStdout || !request.AttachStderr || request.Tty {
t.Errorf("unsafe command: %+v", request)
}
writeJSON(w, map[string]string{"Id": "exec"})
case "/v1.47/exec/exec/start":
w.Write(previewFrame(2, "stderr-secret"))
w.Write(previewFrame(1, "provider:\n apiKey: direct-secret\n API_KEY: underscore-secret\n password: numeric-secret\n nested:\n - token: nested-secret\n arbitrary: resolved-credential\n endpoint: https://name:[email protected]/?token=secret\n enabled: true\n"))
case "/v1.47/exec/exec/json":
writeJSON(w, map[string]any{"Running": false, "ExitCode": 0})
default:
t.Errorf("unexpected Docker call %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer daemon.Close()
a := &API{docker: docker.New(daemon.URL)}
out, err := a.dumpProfile(context.Background(), "container")
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"secret", "resolved-credential", "https://"} {
if strings.Contains(out, secret) {
t.Fatalf("secret leaked: %s", out)
}
}
if !strings.Contains(out, "[REDACTED]") || !strings.Contains(out, "enabled: true") {
t.Fatalf("missing structure: %s", out)
}
}
func TestProfileDumpPluginSequence(t *testing.T) {
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/exec"):
writeJSON(w, map[string]string{"Id": "exec"})
case strings.HasSuffix(r.URL.Path, "/start"):
w.Write(previewFrame(1, "# bundle\n- id: plugin\n disabled: !!js '!ctx.get(\"profileContext\")'\n config:\n apiKey: private-secret\n enabled: true\n"))
default:
writeJSON(w, map[string]any{"Running": false, "ExitCode": 0})
}
}))
defer daemon.Close()
a := &API{docker: docker.New(daemon.URL)}
out, err := a.dumpProfile(context.Background(), "container")
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "private-secret") || strings.Contains(out, "ctx.get") || !strings.Contains(out, "enabled: true") || !strings.Contains(out, "[REDACTED]") {
t.Fatalf("invalid sequence redaction: %s", out)
}
}
func TestProfileDumpRejectsUnsafeOutput(t *testing.T) {
for _, tc := range []struct {
name, output string
exit int
}{
{"invalid", "apiKey: [secret", 0}, {"plain", "secret", 0},
{"documents", "a: b\n---\napiKey: secret", 0}, {"failure", "a: b", 1},
{"empty", "", 0},
} {
t.Run(tc.name, func(t *testing.T) {
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/exec"):
writeJSON(w, map[string]string{"Id": "exec"})
case strings.HasSuffix(r.URL.Path, "/start"):
w.Write(previewFrame(1, tc.output))
default:
writeJSON(w, map[string]any{"Running": false, "ExitCode": tc.exit})
}
}))
defer daemon.Close()
a := &API{docker: docker.New(daemon.URL)}
if out, err := a.dumpProfile(context.Background(), "container"); err == nil || out != "" {
t.Fatalf("unsafe output returned: %q, %v", out, err)
}
})
}
}
func TestPreviewStreamBounds(t *testing.T) {
for _, data := range [][]byte{
[]byte("raw secret"), {1, 0}, previewFrame(3, "secret"),
previewFrame(1, strings.Repeat("x", previewLimit)),
previewFrame(1, "secret")[:10],
} {
if _, err := previewStdout(bytes.NewReader(data)); err == nil {
t.Fatal("accepted invalid stream")
}
}
data := append(previewFrame(2, "secret"), previewFrame(1, "safe")...)
got, err := previewStdout(bytes.NewReader(data))
if err != nil || string(got) != "safe" {
t.Fatalf("stdout: %q %v", got, err)
}
}
func previewFixture(t *testing.T) (*API, store.Instance, string) {
t.Helper()
root := t.TempDir()
i := store.Instance{ID: "instance", UID: "owner", ContainerID: "container", Status: "stopped"}
path := filepath.Join(root, "workspaces", i.UID, "instances", i.ID, "home", "profiles", "web")
if err := os.MkdirAll(path, 0700); err != nil {
t.Fatal(err)
}
return &API{root: root}, i, path
}
func TestProfilePreviewManifestAndErrors(t *testing.T) {
a, i, path := previewFixture(t)
if err := os.WriteFile(filepath.Join(path, "package.json"), []byte(`{"dsh":{"profile":{"bundles":["@deepseek/bundle"],"apiKey":"private-value"}},"scripts":{"secret":"ignored-value"}}`), 0600); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.profilePreview(w, httptest.NewRequest("GET", "/preview?cmd=cat&path=.credentials.yaml", nil), i)
if w.Code != 200 || !strings.Contains(w.Body.String(), "@deepseek/bundle") || strings.Contains(w.Body.String(), "private-value") || strings.Contains(w.Body.String(), "ignored-value") {
t.Fatalf("manifest response: %d %s", w.Code, w.Body.String())
}
if w.Header().Get("Cache-Control") != "no-store" {
t.Fatal("preview must not be cached")
}
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "daemon-private-value", 500) }))
defer daemon.Close()
a.docker = docker.New(daemon.URL)
i.Status = "running"
w = httptest.NewRecorder()
a.profilePreview(w, httptest.NewRequest("GET", "/preview", nil), i)
if strings.Contains(w.Body.String(), "daemon-private-value") || !strings.Contains(w.Body.String(), "preview_error") {
t.Fatalf("unsafe error: %s", w.Body.String())
}
w = httptest.NewRecorder()
a.profilePreview(w, httptest.NewRequest("POST", "/preview", nil), i)
if w.Code != 405 {
t.Fatalf("method: %d", w.Code)
}
}
func TestProfileBundlesRejectsCredentialSymlink(t *testing.T) {
a, i, path := previewFixture(t)
credentials := filepath.Join(path, "..", "..", ".credentials.yaml")
if err := os.WriteFile(credentials, []byte("private-value"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(credentials, filepath.Join(path, "package.json")); err != nil {
t.Fatal(err)
}
if _, err := a.profileBundles(i); err == nil {
t.Fatal("accepted credential symlink")
}
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"context"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"log"
"time"
)
// RunQuotaMonitor reports soft quota crossings; it never claims to constrain
// runtime writes and never deletes user data to recover capacity.
func (a *API) RunQuotaMonitor(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
exceeded := map[string]bool{}
for {
a.checkQuotas(ctx, exceeded)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *API) checkQuotas(ctx context.Context, exceeded map[string]bool) {
rows, err := a.s.DB.QueryContext(ctx, "SELECT id,quota FROM users")
if err != nil {
return
}
var users []store.User
for rows.Next() {
var u store.User
if rows.Scan(&u.ID, &u.Quota) != nil {
rows.Close()
return
}
users = append(users, u)
}
err = rows.Err()
rows.Close()
if err != nil {
return
}
for _, u := range users {
if ctx.Err() != nil {
return
}
used, err := a.userUsage(u)
if err != nil {
log.Printf("quota usage unavailable for user %s", u.ID)
continue
}
over := used > u.Quota
if over && !exceeded[u.ID] {
a.s.AddAudit(u.ID, "quota.exceeded", u.ID, "soft quota exceeded; runtime writes remain unrestricted")
}
if !over && exceeded[u.ID] {
a.s.AddAudit(u.ID, "quota.recovered", u.ID, "")
}
exceeded[u.ID] = over
}
}
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
)
func TestQuotaMonitorReportsTransitions(t *testing.T) {
a, _ := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) {})
dir := filepath.Join(a.root, "workspaces", "owner", "files")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "test"), []byte("over"), 0600); err != nil {
t.Fatal(err)
}
state := map[string]bool{}
a.checkQuotas(context.Background(), state)
a.checkQuotas(context.Background(), state)
if _, err := a.s.DB.Exec("UPDATE users SET quota=100 WHERE id='owner'"); err != nil {
t.Fatal(err)
}
a.checkQuotas(context.Background(), state)
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action IN ('quota.exceeded','quota.recovered')").Scan(&count); err != nil || count != 2 {
t.Fatalf("transitions=%d error=%v", count, err)
}
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io/fs"
"os"
"strings"
)
// Call with workspaceMu held through the write. Runtime writes remain soft-limited.
func (a *API) quotaGrowth(uid string, growth int64) error {
var u store.User
u.ID = uid
if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", uid).Scan(&u.Quota); err != nil {
return err
}
used, err := a.userUsage(u)
if err != nil {
return err
}
if growth > 0 && (used > u.Quota || growth > u.Quota-used) {
return errors.New("quota exceeded")
}
return nil
}
func quotaTreeBytes(root *os.Root, name string) (int64, error) {
var total int64
entries := 0
err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
entries++
if entries > 100000 || strings.Count(p, "/") > 128 {
return errors.New("tree limit")
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode().IsRegular() {
if info.Size() > upgradeSnapshotLimit-total {
return errors.New("snapshot size limit")
}
total += info.Size()
}
return nil
})
return total, err
}
func (a *API) quotaSnapshot(uid string, root *os.Root, extra int64) (int64, error) {
size, err := quotaTreeBytes(root, "home")
if err != nil {
return 0, err
}
if err = a.quotaGrowth(uid, size+extra); err != nil {
return 0, err
}
return size, nil
}
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestProfileQuotaReplacement(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
profile := filepath.Join(home, "profiles/web/cordis.patch.yml")
if err := os.MkdirAll(filepath.Dir(profile), 0700); err != nil {
t.Fatal(err)
}
configWrite(t, profile, "foo: old")
if _, err := a.s.DB.Exec("UPDATE users SET quota=8 WHERE id=?", i.UID); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
body string
code int
}{
{`{"content":"foo: biggg"}`, 413},
{`{"content":"foo: new"}`, 200},
{`{"content":"foo: x"}`, 200},
} {
w := configCall(a, i, "PUT", "profile", tc.body)
if w.Code != tc.code {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
}
data, err := os.ReadFile(profile)
if err != nil || string(data) != "foo: x" {
t.Fatalf("%q %v", data, err)
}
}
func TestSnapshotQuotaIncludesResourcesAndExistingSnapshots(t *testing.T) {
a, i, home := configTestAPI(t, false)
configWrite(t, filepath.Join(home, "state"), "1234")
if err := os.MkdirAll(filepath.Join(filepath.Dir(home), "pre-restore-old"), 0700); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(filepath.Dir(home), "pre-restore-old/state"), "1234")
root, err := os.OpenRoot(filepath.Dir(home))
if err != nil {
t.Fatal(err)
}
defer root.Close()
if _, err = a.s.DB.Exec("UPDATE users SET quota=15 WHERE id=?", i.UID); err != nil {
t.Fatal(err)
}
if _, err = a.quotaSnapshot(i.UID, root, 4); err == nil {
t.Fatal("resource bytes omitted")
}
if size, err := a.quotaSnapshot(i.UID, root, 3); err != nil || size != 4 {
t.Fatalf("size=%d err=%v", size, err)
}
if _, err := root.Lstat("pre-restore-old/state"); err != nil {
t.Fatal("existing snapshot changed")
}
}
func TestQuotaSerializedWriters(t *testing.T) {
a, i, home := configTestAPI(t, false)
if _, err := a.s.DB.Exec("UPDATE users SET quota=4 WHERE id=?", i.UID); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
accepted := 0
for _, name := range []string{"one", "two"} {
wg.Add(1)
go func() {
defer wg.Done()
workspaceMu.Lock()
defer workspaceMu.Unlock()
if a.quotaGrowth(i.UID, 4) == nil {
if err := os.WriteFile(filepath.Join(home, name), []byte("1234"), 0600); err != nil {
t.Error(err)
return
}
accepted++
}
}()
}
wg.Wait()
if accepted != 1 {
t.Fatalf("accepted %d writes", accepted)
}
}
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
)
// Reconcile checks persisted identities against Docker without deleting unknown
// containers. Orphans require explicit administrator review, never blind adoption.
func (a *API) Reconcile(ctx context.Context) error {
a.mu.Lock()
defer a.mu.Unlock()
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.instance"}})
var containers []struct {
ID string
State string
Labels map[string]string
}
if err := a.docker.Do(ctx, "GET", "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil, &containers); err != nil {
return err
}
rows, err := a.s.DB.QueryContext(ctx, "SELECT id,uid,container_id,status FROM instances")
if err != nil {
return err
}
type record struct{ id, uid, container, status string }
var records []record
for rows.Next() {
var r record
if err = rows.Scan(&r.id, &r.uid, &r.container, &r.status); err != nil {
break
}
records = append(records, r)
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return err
}
known := make(map[string]record)
for _, r := range records {
known[r.id] = r
}
matched := make(map[string]bool)
panel := os.Getenv("PANEL_CONTAINER_ID")
var networkErrors []error
for _, c := range containers {
id := c.Labels["dsh-mgr.instance"]
r, ok := known[id]
if !ok || r.container != c.ID || r.uid != c.Labels["dsh-mgr.owner"] {
a.s.AddAudit("", "reconcile.orphan", c.ID, "container identity requires administrator review")
continue
}
matched[id] = true
if panel != "" {
if networkErr := a.reconcilePanelNetwork(ctx, id, panel); networkErr != nil {
if _, err = a.s.DB.ExecContext(ctx, "UPDATE instances SET status='error' WHERE id=?", id); err != nil {
return err
}
a.s.AddAudit(r.uid, "reconcile.network_error", id, networkErr.Error())
networkErrors = append(networkErrors, fmt.Errorf("instance %s: %w", id, networkErr))
continue
}
}
if r.status != "error" {
if _, err = a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", c.State, id); err != nil {
return err
}
}
}
for _, r := range records {
if r.container == "" || matched[r.id] || r.status == "error" {
continue
}
if _, err = a.s.DB.ExecContext(ctx, "UPDATE instances SET status='error' WHERE id=?", r.id); err != nil {
return err
}
a.s.AddAudit(r.uid, "reconcile.missing", r.id, "recorded container is absent or identity mismatch")
}
if ctx.Err() != nil {
return fmt.Errorf("reconcile: %w", ctx.Err())
}
return errors.Join(networkErrors...)
}
func (a *API) reconcilePanelNetwork(ctx context.Context, id, panel string) error {
name := runtimeNetwork(id)
var network struct {
ID, Name, Driver string
Labels map[string]string
}
if err := a.docker.Do(ctx, "GET", "/networks/"+url.PathEscape(name), nil, &network); err != nil {
return fmt.Errorf("inspect instance network: %w", err)
}
if network.ID == "" || network.Name != name || network.Driver != "bridge" || network.Labels["dsh-mgr.instance"] != id {
return fmt.Errorf("instance network identity mismatch")
}
container, err := a.docker.Inspect(ctx, panel)
if err != nil {
return fmt.Errorf("inspect panel: %w", err)
}
if container.ID == "" {
return fmt.Errorf("panel container identity missing")
}
for _, endpoint := range container.NetworkSettings.Networks {
if endpoint.NetworkID == network.ID {
return nil
}
}
if err := a.docker.Do(ctx, "POST", "/networks/"+url.PathEscape(network.ID)+"/connect", map[string]string{"Container": container.ID}, nil); err != nil {
// Another reconciliation may have connected the panel after inspection.
if current, inspectErr := a.docker.Inspect(ctx, container.ID); inspectErr == nil && current.ID == container.ID {
for _, endpoint := range current.NetworkSettings.Networks {
if endpoint.NetworkID == network.ID {
return nil
}
}
}
return fmt.Errorf("connect panel network: %w", err)
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"context"
"encoding/json"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"net/http"
"testing"
"time"
)
func TestReconcileIdentityAndMissing(t *testing.T) {
t.Setenv("PANEL_CONTAINER_ID", "")
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("unexpected destructive request %s", r.Method)
}
w.Write([]byte(`[{"Id":"c1","State":"running","Labels":{"dsh-mgr.instance":"one","dsh-mgr.owner":"owner"}},{"Id":"c3","State":"running","Labels":{"dsh-mgr.instance":"orphan","dsh-mgr.owner":"owner"}}]`))
})
for _, i := range []store.Instance{{ID: "one", ContainerID: "c1", Status: "exited"}, {ID: "missing", ContainerID: "c2", Status: "running"}, {ID: "locked", ContainerID: "c4", Status: "error"}} {
i.UID = "owner"
i.Created = time.Now()
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
}
}
if err := a.Reconcile(context.Background()); err != nil {
t.Fatal(err)
}
for id, want := range map[string]string{"one": "running", "missing": "error", "locked": "error"} {
i, err := a.s.GetInstance(id)
if err != nil || i.Status != want {
t.Fatalf("%s: %+v %v", id, i, err)
}
}
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='reconcile.orphan'").Scan(&count); err != nil || count != 1 {
t.Fatalf("orphan audit: %d %v", count, err)
}
}
func TestReconcilePanelNetwork(t *testing.T) {
for _, tc := range []struct {
name string
connected, race, connectFail, missing, foreign, wrongDriver, panelFail bool
wantConnect int
wantError bool
}{
{name: "already connected", connected: true},
{name: "reconnect", wantConnect: 1},
{name: "concurrent connection", race: true, wantConnect: 1},
{name: "connect failure", connectFail: true, wantConnect: 1, wantError: true},
{name: "missing network", missing: true, wantError: true},
{name: "foreign network", foreign: true, wantError: true},
{name: "not bridge", wrongDriver: true, wantError: true},
{name: "panel inspect failure", panelFail: true, wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PANEL_CONTAINER_ID", "configured-panel")
connects := 0
connected := tc.connected
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /v1.47/containers/json":
fmt.Fprint(w, `[{"Id":"c1","State":"running","Labels":{"dsh-mgr.instance":"one","dsh-mgr.owner":"owner"}},{"Id":"foreign","State":"running","Labels":{"dsh-mgr.instance":"foreign","dsh-mgr.owner":"other"}},{"Id":"unknown","State":"running","Labels":{"dsh-mgr.instance":"unknown","dsh-mgr.owner":"owner"}}]`)
case "GET /v1.47/networks/" + runtimeNetwork("one"):
if tc.missing {
http.Error(w, "network not found", http.StatusNotFound)
return
}
owner, driver := "one", "bridge"
if tc.foreign {
owner = "other"
}
if tc.wrongDriver {
driver = "host"
}
json.NewEncoder(w).Encode(map[string]any{"Id": "network-id", "Name": runtimeNetwork("one"), "Driver": driver, "Labels": map[string]string{"dsh-mgr.instance": owner}})
case "GET /v1.47/containers/configured-panel/json", "GET /v1.47/containers/panel-full-id/json":
if tc.panelFail {
http.Error(w, "panel missing", http.StatusNotFound)
return
}
networkID := "unrelated-network"
if connected {
networkID = "network-id"
}
fmt.Fprintf(w, `{"Id":"panel-full-id","NetworkSettings":{"Networks":{"bridge":{"NetworkID":%q}}}}`, networkID)
case "POST /v1.47/networks/network-id/connect":
connects++
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body) != 1 || body["Container"] != "panel-full-id" {
t.Errorf("unsafe connection body: %v, %v", body, err)
}
if tc.connectFail || tc.race {
connected = tc.race
http.Error(w, "connection failed", http.StatusForbidden)
return
}
connected = true
w.WriteHeader(http.StatusOK)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.Error(w, "unexpected request", http.StatusBadRequest)
}
})
for _, i := range []store.Instance{
{ID: "one", UID: "owner", ContainerID: "c1", Status: "exited", Created: time.Now()},
{ID: "foreign", UID: "owner", ContainerID: "foreign", Status: "running", Created: time.Now()},
} {
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
}
}
if err := a.Reconcile(context.Background()); (err != nil) != tc.wantError {
t.Fatalf("Reconcile error = %v, want error %v", err, tc.wantError)
}
if !tc.wantError {
if err := a.Reconcile(context.Background()); err != nil {
t.Fatal(err)
}
}
if connects != tc.wantConnect {
t.Fatalf("connections = %d, want %d", connects, tc.wantConnect)
}
wantStatus, wantAudits := "running", 0
if tc.wantError {
wantStatus, wantAudits = "error", 1
}
i, err := a.s.GetInstance("one")
if err != nil || i.Status != wantStatus {
t.Fatalf("instance: %+v %v", i, err)
}
var audits int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='reconcile.network_error'").Scan(&audits); err != nil || audits != wantAudits {
t.Fatalf("network audits: %d %v", audits, err)
}
})
}
}
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"bytes"
"net/http"
)
// Only use for bounded JSON responses, never file or runtime streams. Register
// flush before lock-release defers so a slow client cannot hold storage locks.
func deferJSONResponse(w http.ResponseWriter) (http.ResponseWriter, func()) {
b := &jsonResponseBuffer{ResponseWriter: w}
return b, func() {
if b.status != 0 {
w.WriteHeader(b.status)
}
if b.body.Len() > 0 {
_, _ = w.Write(b.body.Bytes())
}
}
}
type jsonResponseBuffer struct {
http.ResponseWriter
body bytes.Buffer
status int
}
func (w *jsonResponseBuffer) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
}
func (w *jsonResponseBuffer) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.body.Write(p)
}
func (w *jsonResponseBuffer) Unwrap() http.ResponseWriter { return w.ResponseWriter }
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRestoreSnapshotCopyFailureDoesNotConsumeRetention(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
configWrite(t, filepath.Join(home, ".credentials.yaml"), "archive")
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
var result struct{ ID string }
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil {
t.Fatal(w.Code, w.Body.String())
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "live")
base := filepath.Dir(home)
for _, name := range []string{"pre-restore-valuable", "failed-restore-valuable"} {
if err := os.Mkdir(filepath.Join(base, name), 0700); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(base, name, "keep"), name)
}
if err := os.Symlink("missing-target", filepath.Join(home, "dangling")); err != nil {
t.Fatal(err)
}
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
for attempt := 0; attempt < 3; attempt++ {
w = configCall(a, i, "POST", "restore", body)
if w.Code != 400 || !strings.Contains(w.Body.String(), "cannot snapshot home") {
t.Fatalf("attempt %d: %d %s", attempt, w.Code, w.Body.String())
}
entries, err := os.ReadDir(base)
if err != nil || len(entries) != 3 {
t.Fatalf("partial snapshot leaked: %v %v", entries, err)
}
for _, name := range []string{"pre-restore-valuable", "failed-restore-valuable"} {
data, err := os.ReadFile(filepath.Join(base, name, "keep"))
if err != nil || string(data) != name {
t.Fatalf("valuable snapshot lost: %s %v", name, err)
}
}
data, err := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if err != nil || string(data) != "live" {
t.Fatalf("live home changed: %q %v", data, err)
}
}
if err := os.Remove(filepath.Join(home, "dangling")); err != nil {
t.Fatal(err)
}
if w = configCall(a, i, "POST", "restore", body); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
entries, err := os.ReadDir(base)
if err != nil || len(entries) != 4 {
t.Fatalf("completed snapshot not retained: %v %v", entries, err)
}
if w = configCall(a, i, "POST", "restore", body); w.Code != 409 {
t.Fatal("retention not enforced", w.Code, w.Body.String())
}
}
func TestWorkspaceLeaseBlocksConfigAndUpgrade(t *testing.T) {
a, i, _ := configTestAPI(t, false)
a.workspaceReaders = map[string]int{i.ID: 1}
for _, action := range []string{"profile", "restore", "backups"} {
w := configCall(a, i, "POST", action, `{}`)
if w.Code != 409 || !strings.Contains(w.Body.String(), "instance files busy") {
t.Fatal(w.Code, w.Body.String())
}
}
w := configCall(a, i, "GET", "backups", "")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = httptest.NewRecorder()
body, _ := json.Marshal(map[string]string{"image_id": "sha256:" + strings.Repeat("a", 64), "password": "original-password"})
r := httptest.NewRequest("POST", "/", strings.NewReader(string(body)))
a.upgrade(w, r, i)
if w.Code != 409 || !strings.Contains(w.Body.String(), "instance files busy") {
t.Fatal(w.Code, w.Body.String())
}
}
func TestUpgradeCopyMultipleDirectoryBatches(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
root, err := os.OpenRoot(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err = root.Mkdir("home", 0700); err != nil {
t.Fatal(err)
}
for n := 0; n < 257; n++ {
configWrite(t, filepath.Join(root.Name(), "home", fmt.Sprint(n)), "x")
}
if err = upgradeCopy(context.Background(), root, "home", "snapshot", 257); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(filepath.Join(root.Name(), "snapshot"))
if err != nil || len(entries) != 257 {
t.Fatal(len(entries), err)
}
}
+965
View File
@@ -0,0 +1,965 @@
package api
import (
"bufio"
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
var runtimeImageID = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var runtimeToken = regexp.MustCompile(`(?i)[?&]token=([^\s&#\x1b"'<>]+)`)
var runtimeStartupToken = regexp.MustCompile(`(?im)^(?:dsh web:\s*)?https?://[^\s]*[?&]token=([^\s&#\x1b"'<>]+)`)
var runtimeSecrets = regexp.MustCompile(`(?i)((?:token|api[_-]?key|authorization|password|secret|cookie)\s*[=:]\s*)[^\r\n]+`)
func runtimeNetwork(id string) string { return "dsh-inst-" + id }
func (a *API) instances(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method == http.MethodGet {
instances, err := a.s.Instances(u.ID)
if err != nil {
fail(w, 500, "database error")
return
}
if instances == nil {
instances = []store.Instance{}
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
for n := range instances {
if instances[n].ContainerID == "" {
continue
}
c, err := a.docker.Inspect(ctx, instances[n].ContainerID)
if err == nil {
if instances[n].Status != "error" {
instances[n].Status = c.State.Status
}
} else {
instances[n].Status = "unavailable"
}
}
writeJSON(w, instances)
return
}
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var in struct {
Name string `json:"name"`
ImageID string `json:"image_id"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || strings.TrimSpace(in.Name) == "" || len(in.Name) > 200 || !runtimeImageID.MatchString(in.ImageID) {
fail(w, 400, "invalid instance")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
var image struct {
ID string `json:"Id"`
Config struct{ Labels map[string]string }
}
if err := a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(in.ImageID)+"/json", nil, &image); err != nil {
fail(w, 400, "local runtime image required")
return
}
if image.ID != in.ImageID || image.Config.Labels["dsh-mgr.role"] != "runtime" || image.Config.Labels["dsh-mgr.version"] == "" {
fail(w, 400, "local runtime image required")
return
}
verified, err := a.verifiedImages(ctx)
if err != nil {
fail(w, 500, "database error")
return
}
if verified[image.ID] != image.Config.Labels["dsh-mgr.version"] {
fail(w, 400, "verified runtime image required")
return
}
i := store.Instance{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), ImageID: image.ID, Version: image.Config.Labels["dsh-mgr.version"], Status: "creating", Created: time.Now()}
if err := a.s.EnsureIntents(); err != nil {
fail(w, 500, "database error")
return
}
if err := a.s.ReserveInstance(i); err != nil {
if errors.Is(err, store.ErrInstanceLimit) {
fail(w, 409, err.Error())
return
}
fail(w, 500, "database error")
return
}
created, err := a.createRuntime(ctx, u, i, a.runtimeHost(r))
if err != nil {
fail(w, 502, err.Error())
return
}
writeJSON(w, created)
}
func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, trustedHost string) (store.Instance, error) {
a.mu.Lock()
defer a.mu.Unlock()
if _, err := a.s.GetInstance(i.ID); err != nil {
return i, errors.New("instance reservation unavailable")
}
networkCreated, complete := false, false
defer func() {
if complete {
return
}
cleanup, done := context.WithTimeout(context.Background(), 20*time.Second)
defer done()
if i.ContainerID != "" {
if err := a.removeRuntimeContainer(cleanup, i.ContainerID); err != nil {
a.s.SetInstance(i.ID, "error")
return
}
}
if networkCreated {
if err := a.removeRuntimeNetwork(cleanup, i.ID); err != nil {
a.s.SetInstance(i.ID, "error")
return
}
}
a.s.DeleteInstance(i.ID)
}()
root, err := filepath.Abs(a.root)
if err != nil {
return i, errors.New("storage unavailable")
}
suffix := filepath.Join("workspaces", u.ID)
for _, relative := range []string{filepath.Join(suffix, "files"), filepath.Join(suffix, "instances", i.ID, "home")} {
dir := filepath.Join(root, relative)
if err := os.MkdirAll(dir, 0750); err != nil {
return i, errors.New("storage unavailable")
}
if err := os.Chown(dir, 1000, 1000); err != nil {
return i, errors.New("storage ownership unavailable")
}
}
hostRoot := os.Getenv("HOST_DATA_ROOT")
if hostRoot == "" {
hostRoot = root
}
if !filepath.IsAbs(hostRoot) {
return i, errors.New("HOST_DATA_ROOT must be absolute")
}
homeSource, err := toHostPath(root, hostRoot, filepath.Join(root, suffix, "instances", i.ID, "home"))
if err != nil {
return i, err
}
network := runtimeNetwork(i.ID)
if err := a.docker.Do(ctx, "POST", "/networks/create", map[string]any{"Name": network, "Driver": "bridge", "CheckDuplicate": true, "Labels": map[string]string{"dsh-mgr.instance": i.ID}}, nil); err != nil {
return i, errors.New("network creation failed")
}
networkCreated = true
if panel := os.Getenv("PANEL_CONTAINER_ID"); panel != "" {
if err := a.docker.Do(ctx, "POST", "/networks/"+network+"/connect", map[string]string{"Container": panel}, nil); err != nil {
return i, errors.New("panel network connection failed")
}
}
host := trustedHost
body := map[string]any{
"Image": i.ImageID, "User": "1000:1000", "WorkingDir": "/workspace",
"Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"},
"Cmd": []string{"web", "--no-open", "--port", "3080", "--trusted-host", host},
"Labels": map[string]string{"dsh-mgr.instance": i.ID, "dsh-mgr.owner": u.ID, "dsh-mgr.role": "dsh", "dsh-mgr.version": i.Version},
"HostConfig": map[string]any{
"NetworkMode": network, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges"},
"Memory": int64(2 << 30), "NanoCpus": int64(2e9), "PidsLimit": 256,
"Tmpfs": map[string]string{"/tmp": "rw,exec,nosuid,nodev,size=256m"},
"LogConfig": map[string]any{"Type": "json-file", "Config": map[string]string{"max-size": "10m", "max-file": "3"}},
"Mounts": []map[string]any{
{"Type": "bind", "Source": homeSource, "Target": "/workspace"},
// DSH_HOME is the same bind mount as the visible workspace.
},
},
}
var created struct {
ID string `json:"Id"`
}
if err := a.docker.Do(ctx, "POST", "/containers/create?name="+network, body, &created); err != nil || created.ID == "" {
return i, errors.New("container creation failed")
}
i.ContainerID = created.ID
if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET container_id=? WHERE id=?", i.ContainerID, i.ID); err != nil {
return i, errors.New("database error")
}
if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); err != nil {
return i, errors.New("container start failed")
}
i.Status = "running"
if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", i.Status, i.ID); err != nil {
return i, errors.New("database error")
}
complete = true
a.s.AddAudit(u.ID, "instance.create", i.ID, i.ImageID)
return i, nil
}
func dockerNotFound(err error) bool {
var e *docker.APIError
return errors.As(err, &e) && e.StatusCode == 404
}
func (a *API) removeRuntimeContainer(ctx context.Context, id string) error {
err := a.docker.Do(ctx, "DELETE", "/containers/"+url.PathEscape(id)+"?force=1", nil, nil)
if dockerNotFound(err) {
return nil
}
return err
}
func (a *API) removeRuntimeNetwork(ctx context.Context, id string) error {
path := "/networks/" + runtimeNetwork(id)
if panel := os.Getenv("PANEL_CONTAINER_ID"); panel != "" {
err := a.docker.Do(ctx, "POST", path+"/disconnect", map[string]any{"Container": panel, "Force": true}, nil)
if err != nil && !dockerNotFound(err) {
return err
}
}
err := a.docker.Do(ctx, "DELETE", path, nil, nil)
if dockerNotFound(err) {
return nil
}
return err
}
func (a *API) instance(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
p := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/instances/"), "/")
if len(p) > 2 || p[0] == "" {
fail(w, 404, "not found")
return
}
i, err := a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID {
fail(w, 404, "not found")
return
}
action := ""
if len(p) == 2 {
action = p[1]
}
switch action {
case "profile-preview":
a.profilePreview(w, r, i)
return
case "profile", "backups", "restore":
a.config(w, r, i, action)
return
case "upgrade":
a.upgrade(w, r, i)
return
}
w, flush := deferJSONResponse(w)
defer flush()
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
if action == "logs" {
if r.Method != "GET" {
fail(w, 405, "method not allowed")
return
}
logs, err := a.docker.Logs(ctx, i.ContainerID)
if err != nil {
fail(w, 502, "logs unavailable")
return
}
writeJSON(w, map[string]string{"logs": redactRuntimeLogs(logs)})
return
}
if action == "" && r.Method == "GET" {
c, err := a.docker.Inspect(ctx, i.ContainerID)
if err != nil {
fail(w, 502, "container unavailable")
return
}
if i.Status != "error" {
i.Status = c.State.Status
}
writeJSON(w, i)
return
}
if action == "" && r.Method == "DELETE" {
a.mu.Lock()
defer a.mu.Unlock()
i, err = a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID {
fail(w, 404, "not found")
return
}
if a.upgradeRecoveryRequired(i) {
fail(w, 409, "instance requires recovery")
return
}
if a.workspaceReaders[i.ID] > 0 {
fail(w, 409, "instance files busy; retry later")
return
}
if i.ContainerID != "" {
if err := a.removeRuntimeContainer(ctx, i.ContainerID); err != nil {
fail(w, 502, "container deletion failed")
return
}
}
if err := a.removeRuntimeNetwork(ctx, i.ID); err != nil {
fail(w, 502, "network deletion failed")
return
}
if _, err := a.s.DB.ExecContext(ctx, "DELETE FROM instances WHERE id=?", i.ID); err != nil {
fail(w, 500, "database error")
return
}
a.s.AddAudit(u.ID, "instance.delete", i.ID, "data retained")
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
writeJSON(w, map[string]bool{"ok": true})
return
}
if action != "start" && action != "stop" && action != "restart" {
fail(w, 404, "not found")
return
}
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
a.mu.Lock()
defer a.mu.Unlock()
i, err = a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID {
fail(w, 404, "not found")
return
}
if action != "stop" && (i.Status == "error" || a.upgradeRecoveryRequired(i)) {
fail(w, 409, "instance requires recovery")
return
}
if a.workspaceReaders[i.ID] > 0 {
fail(w, 409, "instance files busy; retry later")
return
}
if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/"+action+"?t=10", nil, nil); err != nil {
fail(w, 502, "container action failed")
return
}
c, err := a.docker.Inspect(ctx, i.ContainerID)
if err != nil {
fail(w, 502, "container inspection failed")
return
}
if i.Status != "error" {
i.Status = c.State.Status
}
if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", i.Status, i.ID); err != nil {
fail(w, 500, "database error")
return
}
a.s.AddAudit(u.ID, "instance."+action, i.ID, i.Status)
writeJSON(w, i)
}
func redactRuntimeLogs(s string) string {
s = runtimeToken.ReplaceAllString(s, "?token=[REDACTED]")
return runtimeSecrets.ReplaceAllString(s, "${1}[REDACTED]")
}
func latestRuntimeToken(logs string) string {
matches := runtimeStartupToken.FindAllStringSubmatch(logs, -1)
if len(matches) == 0 {
return ""
}
token, err := url.QueryUnescape(matches[len(matches)-1][1])
if err != nil {
return ""
}
return token
}
func (a *API) runtimeHost(r *http.Request) string {
if u, err := url.Parse(a.origin); err == nil && u.Host != "" {
return u.Host
}
return r.Host
}
func (a *API) runtimeOriginAllowed(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return (r.Method == "GET" || r.Method == "HEAD") && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
expected := strings.TrimRight(a.origin, "/")
if expected == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
expected = scheme + "://" + r.Host
}
return origin == expected
}
var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second}
type runtimeAuthKey struct {
api *API
id string
}
type runtimeAuth struct {
mu sync.Mutex
entries map[string]runtimeAuthEntry
}
type runtimeAuthEntry struct {
Container string
Started string
Token string
Host string
Cookies string
}
type runtimeAuthDisk struct {
Nonce string `json:"nonce"`
Data string `json:"data"`
}
var runtimeAuthCache sync.Map
func (a *API) runtimeCookie(ctx context.Context, i store.Instance, started string, target *url.URL, host string) (string, error) {
if a.authErr != nil || len(a.authKey) != 32 {
return "", errors.New("runtime authentication unavailable")
}
value, _ := runtimeAuthCache.LoadOrStore(runtimeAuthKey{a, i.ID}, &runtimeAuth{})
auth := value.(*runtimeAuth)
auth.mu.Lock()
defer auth.mu.Unlock()
if auth.entries == nil {
auth.entries = make(map[string]runtimeAuthEntry)
}
baseKey := i.ContainerID + "/" + started
key := baseKey + "/" + host
if entry, ok := auth.entries[key]; ok && entry.Cookies != "" {
return entry.Cookies, nil
}
if entry, ok := a.loadRuntimeAuth(i.ID, baseKey); ok {
if entry.Host == host && entry.Cookies != "" {
auth.entries[key] = entry
return entry.Cookies, nil
}
cookies, err := exchangeRuntimeCookie(ctx, target, host, entry.Token)
if err != nil {
return "", err
}
entry.Host, entry.Cookies = host, cookies
if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil {
return "", errors.New("runtime authentication unavailable")
}
auth.entries[key] = entry
return cookies, nil
}
startedTime, err := time.Parse(time.RFC3339Nano, started)
if err != nil {
return "", errors.New("invalid start time")
}
since := fmt.Sprintf("%d.%09d", startedTime.Unix(), startedTime.Nanosecond())
query := url.Values{"stdout": {"1"}, "stderr": {"1"}, "follow": {"0"}, "since": {since}}
resp, err := a.docker.Stream(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/logs?"+query.Encode(), nil, "")
if err != nil {
return "", errors.New("logs unavailable")
}
defer resp.Body.Close()
token, err := runtimeTokenFromStream(resp.Body)
if err != nil {
return "", errors.New("logs unavailable")
}
if token == "" {
return "", errors.New("token unavailable")
}
cookies, err := exchangeRuntimeCookie(ctx, target, host, token)
if err != nil {
return "", err
}
entry := runtimeAuthEntry{Container: i.ContainerID, Started: started, Token: token, Host: host, Cookies: cookies}
if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil {
return "", errors.New("runtime authentication unavailable")
}
auth.entries[key] = entry
return cookies, nil
}
func (a *API) runtimeAuthKey(id string) []byte {
h := sha256.New()
h.Write(a.authKey)
h.Write([]byte("runtime-auth/" + id))
return h.Sum(nil)
}
func (a *API) runtimeAuthPath(id, key string) string {
h := sha256.Sum256([]byte(key))
return filepath.Join(a.root, "runtime-auth", id, hex.EncodeToString(h[:])+".json")
}
func (a *API) loadRuntimeAuth(id, key string) (runtimeAuthEntry, bool) {
var zero runtimeAuthEntry
if len(a.authKey) != 32 {
return zero, false
}
f, err := os.OpenFile(a.runtimeAuthPath(id, key), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return zero, false
}
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 || st.Size() > 64<<10 {
return zero, false
}
b, err := io.ReadAll(io.LimitReader(f, (64<<10)+1))
if err != nil || len(b) > 64<<10 {
return zero, false
}
var disk runtimeAuthDisk
if json.Unmarshal(b, &disk) != nil {
return zero, false
}
nonce, err := base64.RawStdEncoding.DecodeString(disk.Nonce)
if err != nil {
return zero, false
}
ciphertext, err := base64.RawStdEncoding.DecodeString(disk.Data)
if err != nil {
return zero, false
}
bl, err := aes.NewCipher(a.runtimeAuthKey(id))
if err != nil {
return zero, false
}
gcm, err := cipher.NewGCM(bl)
if err != nil || len(nonce) != gcm.NonceSize() {
return zero, false
}
plain, err := gcm.Open(nil, nonce, ciphertext, []byte(key))
if err != nil {
return zero, false
}
var entry runtimeAuthEntry
if json.Unmarshal(plain, &entry) != nil || entry.Container == "" || entry.Started == "" || entry.Token == "" || entry.Host == "" || entry.Container+"/"+entry.Started != key {
return zero, false
}
return entry, true
}
func (a *API) saveRuntimeAuth(id, key string, entry runtimeAuthEntry) error {
if len(a.authKey) != 32 {
return errors.New("invalid authentication key")
}
bl, err := aes.NewCipher(a.runtimeAuthKey(id))
if err != nil {
return err
}
gcm, err := cipher.NewGCM(bl)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return err
}
plain, err := json.Marshal(entry)
if err != nil {
return err
}
disk, err := json.Marshal(runtimeAuthDisk{base64.RawStdEncoding.EncodeToString(nonce), base64.RawStdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, []byte(key)))})
if err != nil {
return err
}
dir := filepath.Dir(a.runtimeAuthPath(id, key))
if err = os.MkdirAll(dir, 0700); err != nil {
return err
}
if err = os.Chmod(dir, 0700); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".credential-*")
if err != nil {
return err
}
name := tmp.Name()
defer os.Remove(name)
if err = tmp.Chmod(0600); err == nil {
_, err = tmp.Write(disk)
}
if err == nil {
err = tmp.Sync()
}
closeErr := tmp.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = os.Rename(name, a.runtimeAuthPath(id, key)); err != nil {
return err
}
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// Bound total work while allowing all retained runtime logs (3 x 10 MiB).
const runtimeLogLimit int64 = 64 << 20
func runtimeTokenFromStream(r io.Reader) (string, error) {
br := bufio.NewReader(io.LimitReader(r, runtimeLogLimit+1))
first, err := br.Peek(8)
if err != nil && err != io.EOF {
return "", err
}
framed := len(first) == 8 && first[0] <= 2 && first[1] == 0 && first[2] == 0 && first[3] == 0
var used int64
tail, token := "", ""
scan := func(p []byte) {
text := tail + string(p)
for {
at := strings.IndexByte(text, '\n')
if at < 0 {
break
}
if found := latestRuntimeToken(text[:at]); found != "" {
token = found
}
text = text[at+1:]
}
if len(text) > 8192 {
text = text[len(text)-8192:]
}
tail = text
}
buf := make([]byte, 32<<10)
for {
var source io.Reader = br
var frameLength int64
if framed {
var header [8]byte
n, e := io.ReadFull(br, header[:])
used += int64(n)
if e == io.EOF {
break
}
if e != nil {
return "", e
}
if header[0] > 2 || header[1] != 0 || header[2] != 0 || header[3] != 0 {
return "", errors.New("invalid logs")
}
frameLength = int64(binary.BigEndian.Uint32(header[4:]))
if frameLength > runtimeLogLimit-used {
return "", errors.New("log scan limit")
}
source = io.LimitReader(br, frameLength)
}
var read int64
for {
n, e := source.Read(buf)
used += int64(n)
read += int64(n)
if used > runtimeLogLimit {
return "", errors.New("log scan limit")
}
if n > 0 {
scan(buf[:n])
}
if e == io.EOF {
break
}
if e != nil {
return "", e
}
}
if !framed {
break
}
if read != frameLength {
return "", io.ErrUnexpectedEOF
}
}
if found := latestRuntimeToken(tail); found != "" {
token = found
}
return token, nil
}
func (a *API) proxy(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
destination := r.Header.Get("Sec-Fetch-Dest")
if r.Method == http.MethodGet && r.Header.Get("Sec-Fetch-Mode") == "navigate" && (destination == "document" || destination == "iframe") && r.Header.Get("Upgrade") == "" && a.runtimeOriginAllowed(r) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
target := r.URL.EscapedPath()
if query := withoutRuntimeToken(r.URL.RawQuery); query != "" {
target += "?" + query
}
http.Redirect(w, r, "/#runtime-renew?return="+url.QueryEscape(target), http.StatusSeeOther)
return
}
fail(w, 401, "authentication required")
return
}
a.mu.Lock()
locked := true
defer func() {
if locked {
a.mu.Unlock()
}
}()
p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2)
i, err := a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID {
fail(w, 404, "not found")
return
}
if i.Status == "error" || a.upgradeRecoveryRequired(i) {
fail(w, 409, "instance requires recovery")
return
}
if !a.runtimeOriginAllowed(r) {
fail(w, 403, "origin denied")
return
}
prefix := "/dsh/" + i.ID
if len(p) == 1 {
http.Redirect(w, r, prefix+"/", http.StatusTemporaryRedirect)
return
}
host := a.runtimeHost(r)
bridge := runtimeFreeSearchPath(strings.TrimPrefix(r.URL.Path, prefix))
if bridge {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
fail(w, 403, "origin denied")
return
}
host = "localhost"
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var c struct {
State struct {
Running bool
StartedAt string
}
NetworkSettings struct {
Networks map[string]struct{ IPAddress string }
}
}
err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &c)
if err != nil || !c.State.Running || c.State.StartedAt == "" {
fail(w, 502, "runtime unavailable")
return
}
ip := c.NetworkSettings.Networks[runtimeNetwork(i.ID)].IPAddress
if net.ParseIP(ip) == nil {
fail(w, 502, "runtime network unavailable")
return
}
target := &url.URL{Scheme: "http", Host: net.JoinHostPort(ip, "8080")}
cookies, err := a.runtimeCookie(ctx, i, c.State.StartedAt, target, host)
if err != nil {
fail(w, 502, "runtime authentication unavailable")
return
}
cancel()
a.mu.Unlock()
locked = false
proxy := runtimeReverseProxy(target, prefix, host, cookies)
a.invalidateRejectedRuntimeCookie(proxy, i, c.State.StartedAt, host, cookies)
if bridge {
// Only after panel authentication, ownership and Origin checks may the
// loopback-only plugin receive a matching local Origin through Caddy.
rewrite := proxy.Rewrite
proxy.Rewrite = func(p *httputil.ProxyRequest) {
rewrite(p)
p.Out.Header.Set("Origin", "http://localhost")
}
}
proxy.ServeHTTP(w, r)
}
// Do not replay mutations after an authentication failure. The next request
// exchanges a new cookie using the retained token, even after logs rotate.
func (a *API) invalidateRejectedRuntimeCookie(proxy *httputil.ReverseProxy, i store.Instance, started, host, cookies string) {
modify := proxy.ModifyResponse
proxy.ModifyResponse = func(resp *http.Response) error {
if resp.StatusCode == 401 || resp.StatusCode == 403 {
value, ok := runtimeAuthCache.Load(runtimeAuthKey{a, i.ID})
if ok {
auth := value.(*runtimeAuth)
auth.mu.Lock()
baseKey := i.ContainerID + "/" + started
key := baseKey + "/" + host
if entry, exists := auth.entries[key]; exists && entry.Cookies == cookies {
entry.Cookies = ""
delete(auth.entries, key)
if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil {
auth.mu.Unlock()
return errors.New("runtime authentication unavailable")
}
}
auth.mu.Unlock()
}
}
return modify(resp)
}
}
func runtimeFreeSearchPath(path string) bool {
switch path {
case "/api/dsh-free-search-settings/describe", "/api/dsh-free-search-settings/mutate",
"/api/dsh-free-search-settings/credentials-status", "/api/dsh-free-search-settings/credentials-set",
"/api/dsh-free-search-settings/credentials-unset", "/api/dsh-free-search-settings/check-update",
"/api/dsh-free-search-settings/update", "/api/dsh-free-search-settings/raw-search":
return true
}
return false
}
func exchangeRuntimeCookie(ctx context.Context, target *url.URL, host, token string) (string, error) {
if target == nil {
return "", errors.New("exchange target unavailable")
}
endpoint := *target
endpoint.Path = "/"
endpoint.RawQuery = url.Values{"token": {token}}.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil)
if err != nil {
return "", err
}
req.Host = host
client := &http.Client{Transport: runtimeTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
return "", errors.New("exchange failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
return "", errors.New("exchange rejected")
}
var cookies []string
for _, cookie := range resp.Cookies() {
if cookie.Value != "" && cookie.MaxAge >= 0 {
cookies = append(cookies, (&http.Cookie{Name: cookie.Name, Value: cookie.Value}).String())
}
}
if len(cookies) == 0 {
return "", errors.New("exchange missing cookie")
}
return strings.Join(cookies, "; "), nil
}
func withoutRuntimeToken(rawQuery string) string {
parts := strings.Split(rawQuery, "&")
kept := parts[:0]
for _, part := range parts {
key, _, _ := strings.Cut(part, "=")
decoded, err := url.QueryUnescape(key)
if err == nil && decoded == "token" {
continue
}
kept = append(kept, part)
}
return strings.Join(kept, "&")
}
func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Transport: runtimeTransport, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0),
Rewrite: func(p *httputil.ProxyRequest) {
p.SetURL(target)
p.Out.Host = host
p.Out.URL.Path = strings.TrimPrefix(p.In.URL.Path, prefix)
p.Out.URL.RawPath = ""
// DSH combo assets use an opaque ?module1,module2 query prefix.
p.Out.URL.RawQuery = withoutRuntimeToken(p.In.URL.RawQuery)
p.Out.Header.Del("Cookie")
p.Out.Header.Del("Authorization")
p.Out.Header.Del("Proxy-Authorization")
p.Out.Header.Del("X-CSRF-Token")
p.Out.Header.Set("Accept-Encoding", "identity")
p.Out.Header.Set("Cookie", cookies)
p.SetXForwarded()
p.Out.Header.Set("X-Forwarded-Prefix", prefix)
},
ModifyResponse: func(resp *http.Response) error {
resp.Header.Del("Set-Cookie")
if resp.StatusCode == http.StatusOK && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") && resp.Header.Get("Content-Encoding") == "" {
data, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
resp.Body.Close()
if err != nil || len(data) > 8<<20 {
return fmt.Errorf("runtime document unavailable")
}
data = bytes.Replace(data, []byte("<head>"), []byte(`<head><script src="/api/runtime-session.js"></script>`), 1)
resp.Body = io.NopCloser(bytes.NewReader(data))
resp.ContentLength = int64(len(data))
resp.Header.Set("Content-Length", fmt.Sprint(len(data)))
resp.Header.Del("ETag")
resp.Header.Set("Cache-Control", "no-store")
}
if location := resp.Header.Get("Location"); location != "" {
u, err := url.Parse(location)
if err != nil {
return fmt.Errorf("invalid redirect")
}
u.RawQuery = withoutRuntimeToken(u.RawQuery)
if u.Host == target.Host || u.Host == host {
u.Scheme = ""
u.Host = ""
}
if u.Host == "" && strings.HasPrefix(u.Path, "/") {
u.Path = prefix + u.Path
u.RawPath = ""
}
resp.Header.Set("Location", u.String())
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { fail(w, 502, "runtime proxy unavailable") },
}
}
+115
View File
@@ -0,0 +1,115 @@
package api
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestRuntimeExpiredNavigationBridge(t *testing.T) {
a, req := authFixture(t)
login := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
jar, _ := cookiejar.New(nil)
base, _ := url.Parse(a.origin)
cookies := login.Result().Cookies()
sid := cookieValue(cookies, "dsh_session")
csrf := cookieValue(cookies, "dsh_csrf")
for _, c := range cookies {
if c.Name == "dsh_access" {
c.Value = a.accessJWT(sid, "alice", time.Now().Add(-time.Minute))
}
}
jar.SetCookies(base, cookies)
call := func(method, path, mode, dest, origin, upgrade string) *httptest.ResponseRecorder {
u, _ := url.Parse(a.origin + path)
r := httptest.NewRequest(method, u.String(), nil)
r.Header.Set("Sec-Fetch-Mode", mode)
r.Header.Set("Sec-Fetch-Dest", dest)
r.Header.Set("Origin", origin)
r.Header.Set("Upgrade", upgrade)
for _, c := range jar.Cookies(u) {
r.AddCookie(c)
}
if _, err := r.Cookie("dsh_refresh"); err == nil {
t.Fatal("refresh leaked to runtime path")
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
return w
}
w := call("GET", "/dsh/demo?x=1&token=secret", "navigate", "document", "", "")
if w.Code != 303 || w.Header().Get("Cache-Control") != "no-store" || len(w.Result().Cookies()) != 0 {
t.Fatal(w.Code, w.Header())
}
location, _ := url.Parse(w.Header().Get("Location"))
values, _ := url.ParseQuery(location.Fragment[len("runtime-renew?"):])
if location.Path != "/" || values.Get("return") != "/dsh/demo?x=1" {
t.Fatal(location)
}
for _, tc := range []struct{ method, mode, dest, origin, upgrade string }{
{"GET", "cors", "empty", a.origin, ""},
{"GET", "navigate", "iframe", "https://evil.test", ""},
{"GET", "", "", a.origin, "websocket"},
{"GET", "navigate", "document", a.origin, "websocket"},
{"POST", "navigate", "document", a.origin, ""},
{"GET", "navigate", "document", "https://evil.test", ""},
} {
if w := call(tc.method, "/dsh/demo/", tc.mode, tc.dest, tc.origin, tc.upgrade); w.Code != 401 || w.Header().Get("Location") != "" {
t.Fatal(tc, w.Code)
}
}
refreshURL, _ := url.Parse(a.origin + "/api/refresh")
if w := call("GET", "/dsh/demo/", "navigate", "iframe", "", ""); w.Code != 303 {
t.Fatal("expired iframe did not reach renewal bridge", w.Code)
}
jar.SetCookies(base, []*http.Cookie{{Name: "dsh_access", Path: "/", MaxAge: -1}})
if w := call("GET", "/dsh/demo/", "navigate", "document", "", ""); w.Code != 303 {
t.Fatal("browser-expired cookie did not reach bridge", w.Code)
}
if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 401 {
t.Fatal("bare sid accepted", w.Code)
}
expired := jar.Cookies(refreshURL)
if w := req("POST", "/api/refresh", "", "", expired); w.Code != 403 {
t.Fatal("CSRF bypass", w.Code)
}
fresh := req("POST", "/api/refresh", "", csrf, expired)
if fresh.Code != 200 {
t.Fatal(fresh.Code, fresh.Body.String())
}
jar.SetCookies(base, fresh.Result().Cookies())
if w := call("GET", "/dsh/demo/", "navigate", "document", "", ""); w.Code != 404 {
t.Fatal("renewed navigation", w.Code)
}
if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 404 {
t.Fatal("renewed WS authentication", w.Code)
}
a.s.DeleteSession(sid)
if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 401 {
t.Fatal("revoked WS accepted", w.Code)
}
}
func TestRuntimeSessionBootstrap(t *testing.T) {
target, _ := url.Parse("http://runtime.test")
proxy := runtimeReverseProxy(target, "/dsh/demo", "panel.test", "runtime=secret")
response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/html"}, "Set-Cookie": {"secret=runtime"}, "Etag": {"old"}, "Content-Length": {"1"}, "Cache-Control": {"public"}}, Body: io.NopCloser(strings.NewReader("<html><head></head><body>runtime</body></html>"))}
if err := proxy.ModifyResponse(response); err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(response.Body)
if !strings.Contains(string(data), `<head><script src="/api/runtime-session.js"></script>`) || response.ContentLength != int64(len(data)) || response.Header.Get("Set-Cookie") != "" || response.Header.Get("ETag") != "" || response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("Content-Length") != fmt.Sprint(len(data)) {
t.Fatal(string(data), response.Header)
}
w := httptest.NewRecorder()
runtimeSessionScript(w, httptest.NewRequest("GET", "/api/runtime-session.js", nil))
if w.Code != 200 || !strings.Contains(w.Body.String(), "240000") || !strings.Contains(w.Body.String(), `"X-CSRF-Token":csrf.slice(9)`) || strings.Contains(w.Body.String(), "dsh_refresh=") {
t.Fatal(w.Code, w.Body.String())
}
}
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os/exec"
"strings"
"sync/atomic"
"testing"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func TestRuntimeFreeSearchProxy(t *testing.T) {
var forwarded atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("token") == "launch" {
http.SetCookie(w, &http.Cookie{Name: "runtime", Value: r.Host})
w.WriteHeader(303)
return
}
forwarded.Add(1)
cookie, err := r.Cookie("runtime")
if err != nil || cookie.Value != r.Host {
t.Error("Host-bound cookie mismatch", r.Host, cookie)
}
if r.Header.Get("Authorization") != "" || r.Header.Get("X-CSRF-Token") != "" || strings.Contains(r.Header.Get("Cookie"), "dsh_session") {
t.Error("panel credentials leaked")
}
if runtimeFreeSearchPath(r.URL.Path) {
remote, _, _ := net.SplitHostPort(r.RemoteAddr)
if !net.ParseIP(remote).IsLoopback() || r.Host != "localhost" || r.Header.Get("Origin") != "http://localhost" {
t.Error("loopback guard failed", r.Host, r.Header)
}
data, _ := io.ReadAll(r.Body)
if string(data) != "payload" || r.URL.RawQuery != "keep=1" {
t.Error("request changed", string(data), r.URL.RawQuery)
}
} else if r.Host != "public.example:8443" || r.Header.Get("Origin") != "https://public.example:8443" {
t.Error("ordinary resource authority changed", r.Host)
}
w.WriteHeader(204)
}))
defer upstream.Close()
old := runtimeTransport
transport := old.Clone()
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
if address != "192.0.2.10:8080" {
return nil, fmt.Errorf("not the Caddy target: %s", address)
}
return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://"))
}
runtimeTransport = transport
defer func() { runtimeTransport = old; transport.CloseIdleConnections() }()
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/json") {
fmt.Fprintf(w, `{"State":{"Running":true,"StartedAt":"1970-01-01T00:00:01Z"},"NetworkSettings":{"Networks":{%q:{"IPAddress":"192.0.2.10"}}}}`, runtimeNetwork("demo"))
} else {
io.WriteString(w, "http://localhost/?token=launch\n")
}
})
a.origin = "https://public.example:8443"
defer runtimeAuthCache.Delete(runtimeAuthKey{a, "demo"})
for _, i := range []store.Instance{{ID: "demo", UID: "owner", ContainerID: "container"}, {ID: "foreign", UID: "other"}} {
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
}
}
call := func(method, path, origin, session, site string, want int) {
t.Helper()
r := runtimeRequest(method, path, "payload", session)
r.Host = "public.example:8443"
r.Header.Set("Origin", origin)
r.Header.Set("Sec-Fetch-Site", site)
r.Header.Set("Authorization", "Bearer panel-secret")
r.Header.Set("X-CSRF-Token", "panel-secret")
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != want {
t.Fatalf("%s %s origin=%q: %d %s", method, path, origin, w.Code, w.Body.String())
}
}
const bridge = "/api/dsh-free-search-settings/"
for _, endpoint := range []string{"describe", "mutate", "credentials-status", "credentials-set", "credentials-unset", "check-update", "update", "raw-search"} {
call("POST", "/dsh/demo"+bridge+endpoint+"?keep=1&token=browser", a.origin, sid, "same-origin", 204)
}
for _, path := range []string{"/assets/client.js", bridge + "unknown", bridge + "describe/", bridge + "describe-extra", "/api/dsh-free-search-settings-evil/describe"} {
call("POST", "/dsh/demo"+path, a.origin, sid, "same-origin", 204)
}
call("POST", "/dsh/demo"+bridge+"describe?keep=1", a.origin, sid, "same-origin", 204)
count := forwarded.Load()
for _, origin := range []string{"", "null", "https://evil.example", "http://localhost", "https://public.example:8443.evil"} {
call("POST", "/dsh/demo"+bridge+"mutate", origin, sid, "", 403)
}
call("POST", "/dsh/demo"+bridge+"mutate", a.origin, sid, "cross-site", 403)
call("POST", "/dsh/demo"+bridge+"mutate", a.origin, "", "", 401)
call("POST", "/dsh/foreign"+bridge+"mutate", a.origin, sid, "", 404)
call("GET", "/dsh/demo"+bridge+"mutate", "", sid, "", 405)
call("POST", bridge+"mutate", a.origin, "", "", 403)
call("GET", bridge+"describe", "", "", "", 404)
if forwarded.Load() != count {
t.Fatal("denied request reached runtime")
}
}
func TestRuntimeFreeSearchFetchSemantics(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("Node required for Fetch API semantics")
}
script := `const assert = require("node:assert/strict"); global.window=global; global.location=new URL("https://panel.test/dsh/demo/"); global.document={baseURI:location.href,addEventListener(){}}; global.addEventListener=()=>{}; global.setInterval=()=>{}; let last; global.fetch=async (input,init)=>{last=[input,init]; return new Request(input,init)};` + runtimeSessionJS +
`(async()=>{
const path='/api/dsh-free-search-settings/mutate';
for (const input of [path,new URL(path,location)]) {
const result=await fetch(input,{method:'POST',body:'string-body',headers:{'X-Test':'yes'},credentials:'include'});
assert.equal(result.url,location.origin+'/dsh/demo'+path);
assert.equal(await result.text(),'string-body'); assert.equal(result.headers.get('X-Test'),'yes'); assert.equal(result.credentials,'include');
}
const abort=new AbortController();
const input=new Request(new URL(path,location),{method:'POST',body:'request-body',headers:{'X-Test':'request'},signal:abort.signal,credentials:'omit',redirect:'manual',cache:'no-store'});
const result=await fetch(input);
assert.equal(await result.text(),'request-body'); assert.equal(result.headers.get('X-Test'),'request'); assert.equal(result.credentials,'omit'); assert.equal(result.redirect,'manual'); assert.equal(result.cache,'no-store'); abort.abort(); assert.equal(result.signal.aborted,true);
const override=await fetch(new Request(new URL(path,location),{method:'POST',body:'old'}),{body:'new',headers:{'X-Test':'override'}});
assert.equal(await override.text(),'new'); assert.equal(override.headers.get('X-Test'),'override');
const stream = new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('stream-body'));c.close()}});
const streamed=await fetch(new Request(new URL(path,location),{method:'POST',body:stream,duplex:'half'}));
assert.equal(await streamed.text(),'stream-body');
const used=new Request(new URL(path,location),{method:'POST',body:'used'}); await used.text();
await assert.rejects(fetch(used),TypeError);
for(const path of ['/api/refresh','/api/dsh-free-search-settings/mutate/','/api/dsh-free-search-settings/mutate-extra','/dsh/demo/api/dsh-free-search-settings/mutate','https://evil.test/api/dsh-free-search-settings/mutate']) {
const input=new URL(path,location); const init={method:'POST',body:'unchanged'}; await fetch(input,init); assert.equal(last[0],input); assert.equal(last[1],init);
}
})().catch(e=>{console.error(e);process.exit(1)});`
if output, err := exec.Command(node, "-e", script).CombinedOutput(); err != nil {
t.Fatalf("%v: %s", err, output)
}
}
+195
View File
@@ -0,0 +1,195 @@
package api
import (
"bytes"
"context"
"encoding/binary"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/iotest"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func runtimeTestFrame(payload []byte) []byte {
var h [8]byte
h[0] = 1
binary.BigEndian.PutUint32(h[4:], uint32(len(payload)))
return append(h[:], payload...)
}
func TestRuntimeTokenFromStreamLargeRawAndFramed(t *testing.T) {
for _, tc := range []struct {
name string
data []byte
token string
}{
{"raw", append(bytes.Repeat([]byte("x"), 9<<20), []byte("\ndsh web: http://runtime/?token=raw-tail\n")...), "raw-tail"},
{"framed", runtimeTestFrame(append(bytes.Repeat([]byte("x"), 9<<20), []byte("\ndsh web: http://runtime/?token=framed-tail\n")...)), "framed-tail"},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := runtimeTokenFromStream(bytes.NewReader(tc.data))
if err != nil || got != tc.token {
t.Fatalf("token=%q err=%v", got, err)
}
})
}
}
func TestRuntimeTokenFromStreamSplitLatestAndTruncatedFrame(t *testing.T) {
data := []byte("dsh web: http://runtime/?token=first-token\ndsh web: http://runtime/?token=second-token\n")
got, err := runtimeTokenFromStream(iotest.OneByteReader(bytes.NewReader(data)))
if err != nil || got != "second-token" {
t.Fatalf("token=%q err=%v", got, err)
}
framed := runtimeTestFrame([]byte("dsh web: http://runtime/?token=truncated"))
framed = framed[:len(framed)-2]
if _, err := runtimeTokenFromStream(bytes.NewReader(framed)); err == nil {
t.Fatal("truncated frame accepted")
}
}
func TestRuntimeCookiePersistenceAndHostExchange(t *testing.T) {
var mu sync.Mutex
logs, exchanges := 0, 0
var seen []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.URL.Path == "/" {
exchanges++
token := r.URL.Query().Get("token")
seen = append(seen, token)
w.Header().Set("Set-Cookie", "session="+token+"-"+r.Host+"; Max-Age=60")
w.WriteHeader(http.StatusSeeOther)
return
}
if strings.HasSuffix(r.URL.Path, "/logs") {
logs++
io.WriteString(w, "dsh web: http://runtime/?token=persist-token\n")
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer upstream.Close()
t.Setenv("DOCKER_HOST", upstream.URL)
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {})
a.docker = docker.New(upstream.URL)
i := store.Instance{ID: "persist", UID: "owner", ContainerID: "container"}
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
}
target, _ := url.Parse(upstream.URL)
ctx := context.Background()
if got, err := a.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" {
t.Fatalf("first cookie=%q err=%v", got, err)
}
b := New(a.s, a.root)
b.docker = docker.New(upstream.URL)
if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" {
t.Fatalf("persisted cookie=%q err=%v", got, err)
}
if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "localhost"); err != nil || got != "session=persist-token-localhost" {
t.Fatalf("localhost cookie=%q err=%v", got, err)
}
if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" {
t.Fatalf("host cookie=%q err=%v", got, err)
}
proxy := runtimeReverseProxy(target, "/dsh/persist", "panel.test", "session=persist-token-panel.test")
b.invalidateRejectedRuntimeCookie(proxy, i, "2025-01-01T00:00:00Z", "panel.test", "session=persist-token-panel.test")
if err := proxy.ModifyResponse(&http.Response{StatusCode: 401, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("denied"))}); err != nil {
t.Fatal(err)
}
if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" {
t.Fatalf("reexchange: %q %v", got, err)
}
if _, err := b.runtimeCookie(ctx, i, "2025-01-02T00:00:00Z", target, "panel.test"); err != nil {
t.Fatalf("changed start time: %v", err)
}
mu.Lock()
defer mu.Unlock()
if logs != 2 {
t.Fatalf("log reads=%d, want 2", logs)
}
if exchanges != 4 {
t.Fatalf("exchanges=%d, want 4", exchanges)
}
for _, token := range seen {
if token != "persist-token" {
t.Fatalf("exchanged token=%q", token)
}
}
}
func TestRuntimeAuthDiskEncryptedAndTamperRejected(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {})
entry := runtimeAuthEntry{Container: "container", Started: "2025-01-01T00:00:00Z", Token: "super-secret-token", Host: "panel.test", Cookies: "session=super-secret-token"}
key := entry.Container + "/" + entry.Started
if err := a.saveRuntimeAuth("instance", key, entry); err != nil {
t.Fatal(err)
}
path := a.runtimeAuthPath("instance", key)
st, err := os.Stat(path)
if err != nil || st.Mode().Perm() != 0600 {
t.Fatalf("stat=%v mode=%v", err, st.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte(entry.Token)) || bytes.Contains(data, []byte(entry.Cookies)) {
t.Fatal("runtime token stored in plaintext")
}
data[len(data)/2] ^= 1
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
if _, ok := a.loadRuntimeAuth("instance", key); ok {
t.Fatal("tampered runtime auth accepted")
}
}
func TestRuntimeCookieSaveFailureDoesNotCache(t *testing.T) {
var mu sync.Mutex
logs := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/logs") {
mu.Lock()
logs++
mu.Unlock()
io.WriteString(w, "dsh web: http://runtime/?token=save-failure-token\n")
return
}
w.Header().Set("Set-Cookie", "session=save-failure; Max-Age=60")
w.WriteHeader(http.StatusSeeOther)
}))
defer upstream.Close()
t.Setenv("DOCKER_HOST", upstream.URL)
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {})
badRoot := filepath.Join(t.TempDir(), "not-a-directory")
a.docker = docker.New(upstream.URL)
if err := os.WriteFile(badRoot, []byte("x"), 0600); err != nil {
t.Fatal(err)
}
a.root = badRoot
i := store.Instance{ID: "save-failure", UID: "owner", ContainerID: "container"}
target, _ := url.Parse(upstream.URL)
for n := 0; n < 2; n++ {
if _, err := a.runtimeCookie(context.Background(), i, "2025-01-01T00:00:00Z", target, "panel.test"); err == nil {
t.Fatal("save failure accepted")
}
}
mu.Lock()
defer mu.Unlock()
if logs != 2 {
t.Fatalf("log reads=%d after save failures, want 2", logs)
}
}
+52
View File
@@ -0,0 +1,52 @@
package api
import "net/http"
func runtimeSessionScript(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Write([]byte(runtimeSessionJS))
}
const runtimeSessionJS = `(function () {
const prefix = location.pathname.match(/^\/dsh\/[^/]+(?=\/|$)/);
if (prefix) {
const originalFetch = window.fetch;
const bridge = /^\/api\/dsh-free-search-settings\/(describe|mutate|credentials-status|credentials-set|credentials-unset|check-update|update|raw-search)$/;
window.fetch = async function (input, init) {
let url;
try { url = new URL(input instanceof Request ? input.url : input, document.baseURI); } catch (_) {}
if (url && url.origin === location.origin && bridge.test(url.pathname)) {
url.pathname = prefix[0] + url.pathname;
if (input instanceof Request) {
const request = new Request(input, init);
return originalFetch.call(this, new Request(url, request));
}
input = url;
}
return originalFetch.call(this, input, init);
};
}
let pending;
async function renew() {
if (pending) return pending;
pending = (async function () {
const csrf = document.cookie.split("; ").find(c => c.startsWith("dsh_csrf="));
if (!csrf) return;
const response = await fetch("/api/refresh", {method:"POST", credentials:"same-origin", headers:{"X-CSRF-Token":csrf.slice(9)}});
if (response.status === 401 || response.status === 403) {
const check = await fetch("/api/me", {credentials:"same-origin"});
if (check.status === 401) location.reload();
}
})().catch(function () {}).finally(function () { pending = undefined; });
return pending;
}
setInterval(renew, 240000);
window.addEventListener("pageshow", renew);
window.addEventListener("online", renew);
document.addEventListener("visibilitychange", function () { if (!document.hidden) renew(); });
})();`
+581
View File
@@ -0,0 +1,581 @@
package api
import (
"bufio"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func runtimeTestAPI(t *testing.T, handler http.HandlerFunc) (*API, string) {
t.Helper()
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Close)
if err := s.CreateUser(store.User{ID: "owner", Username: "owner"}, "unused"); err != nil {
t.Fatal(err)
}
sid, _, err := s.Session("owner")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
a := &API{s: s, root: t.TempDir(), origin: "http://panel.test", docker: docker.New(server.URL)}
if err := a.initAuth(); err != nil {
t.Fatal(err)
}
registerTestAccess(t, a, sid)
return a, sid
}
func runtimeRequest(method, path, body, sid string) *http.Request {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Host = "panel.test"
r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid})
addTestAccess(r, sid)
return r
}
func TestRuntimeProxyRejectsErrorState(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("error-state proxy must not contact Docker")
})
if err := a.s.AddInstance(store.Instance{ID: "broken", UID: "owner", Status: "error", Created: time.Now()}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.proxy(w, runtimeRequest("GET", "/dsh/broken/", "", sid))
if w.Code != http.StatusConflict {
t.Fatalf("status = %d", w.Code)
}
}
func TestRuntimeCreateLimitAndPolicy(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("chown requires root")
}
t.Setenv("HOST_DATA_ROOT", "/host/data")
t.Setenv("PANEL_CONTAINER_ID", "panel")
imageID := "sha256:" + strings.Repeat("a", 64)
var mu sync.Mutex
var bodies []map[string]any
var calls []string
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case strings.Contains(r.URL.Path, "/images/"):
writeJSON(w, map[string]any{"Id": imageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3"}}})
case strings.HasSuffix(r.URL.Path, "/containers/create"):
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
bodies = append(bodies, body)
writeJSON(w, map[string]string{"Id": fmt.Sprintf("container%d", len(bodies))})
default:
w.WriteHeader(204)
}
})
body := fmt.Sprintf("{\"name\":\"one\",\"image_id\":%q,\"version\":\"untrusted\"}", imageID)
seedVerifiedImage(t, a, imageID, "1.2.3")
var wg sync.WaitGroup
statuses := make(chan int, 4)
for n := 0; n < 4; n++ {
wg.Add(1)
go func() {
defer wg.Done()
w := httptest.NewRecorder()
a.instances(w, runtimeRequest("POST", "/api/instances", body, sid))
statuses <- w.Code
}()
}
wg.Wait()
close(statuses)
successes, limits := 0, 0
for status := range statuses {
if status == 200 {
successes++
} else if status == 409 {
limits++
} else {
t.Errorf("status %d", status)
}
}
if successes != 2 || limits != 2 {
t.Fatalf("success=%d limit=%d", successes, limits)
}
instances, err := a.s.Instances("owner")
if err != nil || len(instances) != 2 {
t.Fatalf("instances=%v err=%v", instances, err)
}
for _, i := range instances {
if i.Version != "1.2.3" || i.ContainerID == "" || i.Status != "running" {
t.Errorf("%+v", i)
}
if _, err := os.Stat(filepath.Join(a.root, "workspaces", "owner", "instances", i.ID, "home")); err != nil {
t.Error(err)
}
}
for _, b := range bodies {
h := b["HostConfig"].(map[string]any)
mounts := h["Mounts"].([]any)
if len(mounts) != 1 {
t.Fatalf("unexpected mounts: %v", mounts)
}
mount := mounts[0].(map[string]any)
if mount["Target"] != "/workspace" || !strings.HasSuffix(mount["Source"].(string), "/home") {
t.Fatal(mount)
}
if !strings.Contains(fmt.Sprint(b["Env"]), "DSH_HOME=/workspace") {
t.Fatal("wrong data root", b["Env"])
}
if b["User"] != "1000:1000" || h["ReadonlyRootfs"] != true || h["Memory"] != float64(2<<30) || h["NanoCpus"] != float64(2e9) || h["PidsLimit"] != float64(256) {
t.Errorf("policy %v", b)
}
if h["PortBindings"] != nil || h["PublishAllPorts"] != nil {
t.Error("ports published")
}
if fmt.Sprint(h["CapDrop"]) != "[ALL]" || !strings.Contains(fmt.Sprint(h["Tmpfs"]), "exec") {
t.Error(h)
}
if strings.Contains(fmt.Sprint(b["Cmd"]), "public-url") || !strings.Contains(fmt.Sprint(b["Cmd"]), "--trusted-host panel.test") {
t.Error(b["Cmd"])
}
if !strings.Contains(fmt.Sprint(h["Mounts"]), "/host/data/workspaces/owner") {
t.Error(h["Mounts"])
}
}
if !strings.Contains(strings.Join(calls, "\n"), "/connect") {
t.Fatal("panel not connected")
}
}
func TestRuntimeLifecycleAndOwnership(t *testing.T) {
t.Setenv("PANEL_CONTAINER_ID", "")
var calls []string
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if strings.HasSuffix(r.URL.Path, "/json") {
io.WriteString(w, "{\"State\":{\"Status\":\"running\"}}")
} else if strings.HasSuffix(r.URL.Path, "/logs") {
io.WriteString(w, "dsh web: http://localhost/?token=secret-value\nAuthorization: Bearer another-secret\n")
} else {
w.WriteHeader(204)
}
})
i := store.Instance{ID: "instance", UID: "owner", ContainerID: "container"}
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
}
dir := filepath.Join(a.root, "workspaces", "owner", "instances", i.ID, "home")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
for _, action := range []string{"start", "stop", "restart"} {
w := httptest.NewRecorder()
a.instance(w, runtimeRequest("POST", "/api/instances/instance/"+action, "", sid))
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = httptest.NewRecorder()
a.instance(w, runtimeRequest("GET", "/api/instances/instance/"+action, "", sid))
if w.Code != 405 {
t.Fatal(w.Code)
}
}
w := httptest.NewRecorder()
a.instance(w, runtimeRequest("GET", "/api/instances/instance/logs", "", sid))
if strings.Contains(w.Body.String(), "secret-value") || strings.Contains(w.Body.String(), "another-secret") {
t.Fatal("credentials in logs")
}
foreign := store.Instance{ID: "foreign", UID: "someone", ContainerID: "other"}
a.s.AddInstance(foreign)
before := len(calls)
for _, path := range []string{"/api/instances/foreign/start", "/api/instances/foreign/logs"} {
w = httptest.NewRecorder()
a.instance(w, runtimeRequest("POST", path, "", sid))
if w.Code != 404 {
t.Fatal(w.Code)
}
}
w = httptest.NewRecorder()
a.proxy(w, runtimeRequest("GET", "/dsh/foreign/", "", sid))
if w.Code != 404 || len(calls) != before {
t.Fatal("owner check bypass")
}
w = httptest.NewRecorder()
a.instance(w, runtimeRequest("DELETE", "/api/instances/instance", "", sid))
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if _, err := os.Stat(dir); err != nil {
t.Fatal("data removed", err)
}
if _, err := a.s.GetInstance(i.ID); err == nil {
t.Fatal("row retained")
}
if !strings.Contains(strings.Join(calls, "\n"), "DELETE /v1.47/containers/container") {
t.Fatal(calls)
}
}
func TestRuntimeTokenOriginAndExchange(t *testing.T) {
logs := "dsh web: http://localhost/?token=old\ndsh web: http://localhost/?token=latest%2Btoken\x1b[0m\n"
if got := latestRuntimeToken(logs); got != "latest+token" {
t.Fatal(got)
}
if got := latestRuntimeToken(logs + `{"uri":"/?token=old"}`); got != "latest+token" {
t.Fatalf("proxy log replaced startup token: %q", got)
}
if strings.Contains(redactRuntimeLogs(logs), "latest") || strings.Contains(redactRuntimeLogs(logs), "old") {
t.Fatal("token not redacted")
}
a := &API{origin: "http://panel.test"}
for _, tc := range []struct {
method, origin, upgrade string
want bool
}{{"GET", "", "", true}, {"POST", "", "", false}, {"POST", "http://panel.test", "", true}, {"POST", "http://evil.test", "", false}, {"GET", "", "websocket", false}, {"GET", "http://panel.test", "websocket", true}} {
r := runtimeRequest(tc.method, "/dsh/id/", "", "")
r.Header.Set("Origin", tc.origin)
r.Header.Set("Upgrade", tc.upgrade)
if a.runtimeOriginAllowed(r) != tc.want {
t.Errorf("%+v", tc)
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Query().Get("token") != "latest+token" || r.Host != "panel.test" {
t.Error("invalid exchange")
}
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "server-secret", Path: "/"})
w.Header().Set("Location", "/")
w.WriteHeader(303)
}))
defer server.Close()
target, _ := url.Parse(server.URL)
cookie, err := exchangeRuntimeCookie(context.Background(), target, "panel.test", "latest+token")
if err != nil || cookie != "dsh_auth=server-secret" {
t.Fatalf("exchange failed: %v", err)
}
}
func TestRuntimeProxyHeadersRedirectAndSSE(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Cookie") != "dsh_auth=server-secret" || r.Header.Get("Authorization") != "" || r.Header.Get("X-CSRF-Token") != "" || r.URL.Query().Get("token") != "" {
t.Error("credential boundary violated")
}
if r.Host != "panel.test" {
t.Error(r.Host)
}
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "not-for-browser"})
if r.URL.Path == "/redirect" {
w.Header().Set("Location", "/next?token=not-for-browser")
w.WriteHeader(303)
return
}
if r.URL.Path != "/events" {
t.Error(r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, "data: first\n\n")
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
proxy := httptest.NewServer(runtimeReverseProxy(target, "/dsh/id", "panel.test", "dsh_auth=server-secret"))
defer proxy.Close()
client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
for _, path := range []string{"redirect", "events"} {
req, _ := http.NewRequest("GET", proxy.URL+"/dsh/id/"+path+"?token=browser-token", nil)
req.Header.Set("Cookie", "dsh_session=panel-secret")
req.Header.Set("Authorization", "Bearer panel-secret")
req.Header.Set("X-CSRF-Token", "panel-secret")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.Header.Get("Set-Cookie") != "" {
t.Fatal("cookie escaped")
}
if path == "redirect" {
if resp.Header.Get("Location") != "/dsh/id/next" {
t.Fatal(resp.Header.Get("Location"))
}
} else {
line, err := bufio.NewReader(resp.Body).ReadString('\n')
if err != nil || line != "data: first\n" {
t.Fatal("SSE did not flush", err)
}
}
resp.Body.Close()
}
}
func TestRuntimeProxyPreservesComboQuery(t *testing.T) {
const query = "?@deepseek-ai/dsh-client-modules/client.js,@deepseek-ai/dsh-client-ui/client.js&rev=abc"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.RawQuery != query {
t.Errorf("combo query changed: %q", r.URL.RawQuery)
}
w.Header().Set("Location", "/plugins/?"+query+"&%74oken=secret")
w.WriteHeader(http.StatusTemporaryRedirect)
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
proxy := runtimeReverseProxy(target, "/dsh/id", "panel.test", "")
for _, suffix := range []string{"", "&token=secret", "&%74oken=secret&token=second"} {
w := httptest.NewRecorder()
proxy.ServeHTTP(w, httptest.NewRequest("GET", "http://panel.test/dsh/id/plugins/?"+query+suffix, nil))
if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != "/dsh/id/plugins/?"+query {
t.Fatalf("unexpected combo redirect: %d %q", w.Code, w.Header().Get("Location"))
}
}
}
func TestRuntimeProxyWebSocketUpgrade(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/socket" || r.Header.Get("Cookie") != "dsh_auth=secret" || r.Header.Get("Authorization") != "" {
t.Error("invalid upgraded request")
}
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
defer conn.Close()
rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
rw.Flush()
data := make([]byte, 4)
if _, err := io.ReadFull(rw, data); err != nil {
t.Error(err)
return
}
rw.Write(data)
rw.Flush()
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
proxy := httptest.NewServer(runtimeReverseProxy(target, "/dsh/id", "panel.test", "dsh_auth=secret"))
defer proxy.Close()
conn, err := net.DialTimeout("tcp", strings.TrimPrefix(proxy.URL, "http://"), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(3 * time.Second))
fmt.Fprint(conn, "GET /dsh/id/socket HTTP/1.1\r\nHost: panel.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAuthorization: Bearer panel-secret\r\n\r\n")
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, nil)
if err != nil || resp.StatusCode != 101 {
t.Fatalf("upgrade: %v %v", resp, err)
}
fmt.Fprint(conn, "ping")
data := make([]byte, 4)
if _, err := io.ReadFull(reader, data); err != nil || string(data) != "ping" {
t.Fatal("upgrade tunnel failed", err)
}
}
func TestRuntimeCreateRollbackAndImageRejection(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("chown requires root")
}
t.Setenv("PANEL_CONTAINER_ID", "")
t.Setenv("HOST_DATA_ROOT", "")
imageID := "sha256:" + strings.Repeat("b", 64)
for _, role := range []string{"foreign", "runtime"} {
t.Run(role, func(t *testing.T) {
var calls []string
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case strings.Contains(r.URL.Path, "/images/"):
writeJSON(w, map[string]any{"Id": imageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": role, "dsh-mgr.version": "1"}}})
case strings.HasSuffix(r.URL.Path, "/containers/create"):
writeJSON(w, map[string]string{"Id": "container"})
case strings.HasSuffix(r.URL.Path, "/start"):
w.WriteHeader(500)
io.WriteString(w, "token=must-not-leak")
default:
w.WriteHeader(204)
}
})
seedVerifiedImage(t, a, imageID, "1")
w := httptest.NewRecorder()
a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf("{\"name\":\"test\",\"image_id\":%q}", imageID), sid))
want := 400
if role == "runtime" {
want = 502
}
if w.Code != want {
t.Fatal(w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "must-not-leak") {
t.Fatal("docker error leaked")
}
instances, err := a.s.Instances("owner")
if err != nil || len(instances) != 0 {
t.Fatal("reservation not rolled back", err)
}
if role == "foreign" && len(calls) != 1 {
t.Fatal("untrusted image used")
}
if role == "runtime" && !strings.Contains(strings.Join(calls, "\n"), "DELETE /v1.47/containers/container") {
t.Fatal("container not cleaned")
}
})
}
}
func TestRuntimeProxyMissingNetwork(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "{\"State\":{\"Running\":true},\"NetworkSettings\":{\"Networks\":null}}")
})
if err := a.s.AddInstance(store.Instance{ID: "instance", UID: "owner", ContainerID: "container"}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.proxy(w, runtimeRequest("GET", "/dsh/instance/", "", sid))
if w.Code != 502 {
t.Fatal(w.Code)
}
}
func TestRuntimeCookieCacheRestart(t *testing.T) {
exchanges, logs := 0, 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
exchanges++
token := r.URL.Query().Get("token")
if token != "first" && token != "second" {
t.Error("wrong token")
}
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: token})
w.WriteHeader(303)
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
logs++
token := "first"
if r.URL.Query().Get("since") == "2.000000000" {
token = "second"
} else if r.URL.Query().Get("since") != "1.000000000" {
t.Error("missing startup filter")
}
io.WriteString(w, "dsh web: http://localhost/?token="+token+"\n")
})
i := store.Instance{ID: "cache-test", ContainerID: "container"}
defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
for n, started := range []string{"1970-01-01T00:00:01Z", "1970-01-01T00:00:01Z", "1970-01-01T00:00:02Z"} {
cookie, err := a.runtimeCookie(context.Background(), i, started, target, "panel.test")
want := "dsh_auth=first"
if n == 2 {
want = "dsh_auth=second"
}
if err != nil || cookie != want {
t.Fatal("cookie exchange", err)
}
}
if exchanges != 2 || logs != 2 {
t.Fatalf("exchanges=%d logs=%d", exchanges, logs)
}
}
func TestRuntimeCookieCurrentStartupLargeMultiplexedLogs(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("token") != "current" {
t.Error("stale token exchanged")
}
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "current"})
w.WriteHeader(303)
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("since") != "2.123456789" {
t.Error("logs not filtered by StartedAt")
io.WriteString(w, strings.Repeat("old logs\n", 200000))
return
}
for _, payload := range []string{strings.Repeat("startup noise\n", 100000), "http://localhost/?token=current\n"} {
header := [8]byte{1}
binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
w.Write(header[:])
io.WriteString(w, payload)
}
})
i := store.Instance{ID: "large-log", ContainerID: "container"}
defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
cookie, err := a.runtimeCookie(context.Background(), i, "1970-01-01T00:00:02.123456789Z", target, "panel.test")
if err != nil || cookie != "dsh_auth=current" {
t.Fatalf("cookie=%s err=%v", cookie, err)
}
}
func TestRuntimeCookieRejectsTruncatedLogs(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "http://localhost/?token=partial\n"+strings.Repeat("x", 8<<20))
})
i := store.Instance{ID: "oversize", ContainerID: "container"}
defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
if _, err := a.runtimeCookie(context.Background(), i, "1970-01-01T00:00:01Z", nil, "panel.test"); err == nil {
t.Fatal("truncated log accepted")
}
}
func TestRuntimeCreateRejectsTrailingJSON(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("invalid request reached Docker")
w.WriteHeader(500)
})
body := fmt.Sprintf(`{"name":"test","image_id":%q} {}`, "sha256:"+strings.Repeat("a", 64))
w := httptest.NewRecorder()
a.instances(w, runtimeRequest("POST", "/api/instances", body, sid))
if w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
}
func TestRuntimeBlocksPendingUpgrade(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("pending upgrade reached Docker")
w.WriteHeader(500)
})
if err := a.s.AddInstance(store.Instance{ID: "pending", UID: "owner", ContainerID: "old"}); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT); INSERT INTO upgrade_journal VALUES('pending','snapshotting')"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ method, path string }{{"POST", "/api/instances/pending/start"}, {"POST", "/api/instances/pending/restart"}, {"DELETE", "/api/instances/pending"}, {"GET", "/dsh/pending/"}} {
w := httptest.NewRecorder()
r := runtimeRequest(tc.method, tc.path, "", sid)
if strings.HasPrefix(tc.path, "/dsh/") {
a.proxy(w, r)
} else {
a.instance(w, r)
}
if w.Code != 409 {
t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
}
}
}
+629
View File
@@ -0,0 +1,629 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/backup"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
)
const upgradeSnapshotLimit int64 = 1 << 30
func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, shared string) error {
var mounts []map[string]json.RawMessage
if err := json.Unmarshal(host["Mounts"], &mounts); err != nil {
return errors.New("unsupported runtime mounts")
}
get := func(m map[string]json.RawMessage, key string) string {
var value string
_ = json.Unmarshal(m[key], &value)
return value
}
homeIndex, sharedIndex := -1, -1
for n, m := range mounts {
target, source := get(m, "Target"), get(m, "Source")
if target != "/workspace" && target != "/data/dsh-home" && target != "/shared" {
continue
}
if get(m, "Type") != "bind" {
return errors.New("unsupported runtime mount type")
}
switch {
case source == home && (target == "/workspace" || target == "/data/dsh-home") && homeIndex == -1:
homeIndex = n
case source == shared && (target == "/workspace" || target == "/shared") && sharedIndex == -1:
sharedIndex = n
default:
return errors.New("ambiguous runtime mounts")
}
}
if homeIndex == -1 {
return errors.New("instance home mount required")
}
if recipe != "7" {
if get(mounts[homeIndex], "Target") == "/workspace" {
return errors.New("workspace home requires template 7")
}
return nil
}
for _, key := range []string{"Binds", "VolumesFrom"} {
var values []string
if raw := host[key]; len(raw) != 0 {
if json.Unmarshal(raw, &values) != nil || len(values) != 0 {
return errors.New("unsupported additional mounts")
}
}
}
var env []string
if raw := config["Env"]; len(raw) != 0 {
if err := json.Unmarshal(raw, &env); err != nil {
return err
}
}
kept := make([]string, 0, len(env)+4)
for _, entry := range env {
key, _, _ := strings.Cut(entry, "=")
switch key {
case "DSH_HOME", "HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME":
default:
kept = append(kept, entry)
}
}
kept = append(kept, "DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config")
mounts[homeIndex]["Target"], _ = json.Marshal("/workspace")
if sharedIndex != -1 {
mounts = append(mounts[:sharedIndex], mounts[sharedIndex+1:]...)
}
host["Mounts"], _ = json.Marshal(mounts)
config["Env"], _ = json.Marshal(kept)
return nil
}
func upgradeRoot(root string, i store.Instance) (*os.Root, error) {
r, err := os.OpenRoot(root)
if err != nil {
return nil, err
}
for _, part := range []string{"workspaces", i.UID, "instances", i.ID} {
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") {
r.Close()
return nil, errors.New("invalid storage path")
}
st, e := r.Lstat(part)
if e != nil || !st.IsDir() {
r.Close()
return nil, errors.New("unsafe storage path")
}
next, e := r.OpenRoot(part)
r.Close()
if e != nil {
return nil, e
}
r = next
}
return r, nil
}
func upgradeSync(r *os.Root) error {
f, err := r.Open(".")
if err != nil {
return err
}
defer f.Close()
return f.Sync()
}
func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit int64) error {
remaining, entries := limit, 100000
var links []string
var copyTree func(string, string, int) error
copyTree = func(src, dst string, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
entries--
if entries < 0 || depth > 128 {
return errors.New("snapshot entry limit")
}
st, err := r.Lstat(src)
if err != nil {
return err
}
if st.IsDir() {
if err = r.Mkdir(dst, 0700); err != nil {
return err
}
f, err := r.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0)
if err != nil {
return err
}
defer f.Close()
for {
children, err := f.ReadDir(128)
if err != nil && err != io.EOF {
return err
}
for _, child := range children {
if err := copyTree(filepath.Join(src, child.Name()), filepath.Join(dst, child.Name()), depth+1); err != nil {
return err
}
}
if err == io.EOF {
break
}
}
if err = r.Chown(dst, 1000, 1000); err != nil {
return err
}
if err = r.Chmod(dst, st.Mode().Perm()); err != nil {
return err
}
d, err := r.Open(dst)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
if st.Mode()&os.ModeSymlink != 0 && depth > 0 {
link, err := r.Readlink(src)
if err != nil {
return err
}
rel, err := filepath.Rel(source, src)
if err != nil || filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
return errors.New("unsafe snapshot link")
}
if err = r.Symlink(link, dst); err != nil {
return err
}
links = append(links, rel)
return r.Lchown(dst, 1000, 1000)
}
if !st.Mode().IsRegular() || st.Size() > remaining {
return errors.New("unsafe or oversized snapshot")
}
in, err := r.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return err
}
defer in.Close()
actual, err := in.Stat()
if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) {
return errors.New("snapshot source changed")
}
out, err := r.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600)
if err != nil {
return err
}
defer out.Close()
n, err := io.Copy(out, io.LimitReader(in, remaining+1))
if err != nil {
return err
}
remaining -= n
if remaining < 0 {
return errors.New("snapshot size limit")
}
if err = out.Chown(1000, 1000); err != nil {
return err
}
if err = out.Chmod(st.Mode().Perm()); err != nil {
return err
}
return out.Sync()
}
if err := copyTree(source, target, 0); err != nil {
return err
}
snapshot, err := r.OpenRoot(target)
if err != nil {
return err
}
defer snapshot.Close()
// Resolve only within the completed snapshot, including intermediate symlinks.
for _, link := range links {
if _, err := snapshot.Stat(link); err != nil {
return errors.New("unsafe or unresolved snapshot link")
}
}
return upgradeSync(r)
}
// Call while holding a.mu before any operation that can start or mutate a runtime.
func (a *API) upgradeRecoveryRequired(i store.Instance) bool {
var exists int
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil {
return true
}
if exists == 0 {
return false
}
var pending int
if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND phase NOT IN ('complete','rolled_back')", i.ID).Scan(&pending); err != nil {
return true
}
if pending != 0 {
a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID)
}
return pending != 0
}
func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var in struct {
ImageID string `json:"image_id"`
Password string `json:"password"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
if dec.Decode(&in) != nil || !runtimeImageID.MatchString(in.ImageID) || dec.Decode(new(any)) != io.EOF {
fail(w, 400, "invalid image_id")
return
}
a.mu.Lock()
defer a.mu.Unlock()
if a.workspaceReaders[i.ID] > 0 {
fail(w, 409, "instance files busy; retry later")
return
}
workspaceMu.Lock()
defer workspaceMu.Unlock()
current, err := a.s.GetInstance(i.ID)
if err != nil || current.UID != i.UID {
fail(w, 404, "not found")
return
}
i = current
if len(in.Password) == 0 || len(in.Password) > 72 {
fail(w, 400, "login password required for pre-upgrade backup")
return
}
var passwordHash string
if a.s.DB.QueryRow("SELECT pass_hash FROM users WHERE id=?", i.UID).Scan(&passwordHash) != nil || bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(in.Password)) != nil {
fail(w, 403, "incorrect login password")
return
}
if a.upgradeRecoveryRequired(i) || i.Status == "error" {
fail(w, 409, "instance requires recovery")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
var old struct {
Config map[string]json.RawMessage
HostConfig map[string]json.RawMessage
State struct {
Status string
Running, Paused, Restarting bool
}
NetworkSettings struct{ Networks map[string]json.RawMessage }
}
if i.ContainerID == "" || a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &old) != nil {
fail(w, 502, "container inspection failed")
return
}
if old.State.Paused || old.State.Restarting || (old.State.Status != "running" && old.State.Status != "exited" && old.State.Status != "created") {
fail(w, 409, "unsupported container state")
return
}
if old.Config == nil || old.HostConfig == nil {
fail(w, 502, "container configuration unavailable")
return
}
verified, err := a.verifiedImages(ctx)
if err != nil {
fail(w, 500, "database error")
return
}
var image struct {
ID string `json:"Id"`
Config struct{ Labels map[string]string }
}
if a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(in.ImageID)+"/json", nil, &image) != nil || image.ID != in.ImageID || image.Config.Labels["dsh-mgr.role"] != "runtime" || image.Config.Labels["dsh-mgr.recipe"] != imageRecipeRev || verified[image.ID] == "" || verified[image.ID] != image.Config.Labels["dsh-mgr.version"] {
fail(w, 400, "local runtime image required")
return
}
root, err := upgradeRoot(a.root, i)
if err != nil {
fail(w, 500, "storage unavailable")
return
}
defer root.Close()
hostRoot := os.Getenv("HOST_DATA_ROOT")
if hostRoot == "" {
hostRoot = a.root
}
homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home"))
sharedSource, sharedErr := toHostPath(a.root, hostRoot, filepath.Join(a.root, "workspaces", i.UID, "files"))
if homeErr != nil || sharedErr != nil {
fail(w, 500, "host storage unavailable")
return
}
if err = upgradeWorkspace(old.Config, old.HostConfig, image.Config.Labels["dsh-mgr.recipe"], homeSource, sharedSource); err != nil {
fail(w, 409, "incompatible runtime workspace layout")
return
}
if _, err = a.quotaSnapshot(i.UID, root, 0); err != nil {
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
op := store.ID()
snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op
newName := runtimeNetwork(i.ID) + "-upgrade-" + op
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`)
if err != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
_, err = a.s.DB.Exec("CREATE TABLE IF NOT EXISTS upgrade_state (id TEXT PRIMARY KEY, old_status TEXT NOT NULL, was_running INTEGER NOT NULL)")
if err != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
tx, err := a.s.DB.Begin()
if err != nil {
fail(w, 500, "database error")
return
}
_, err = tx.Exec("INSERT INTO upgrade_journal(id,instance_id,old_container,new_name,image_id,snapshot,failed_home,phase) VALUES(?,?,?,?,?,?,?,'snapshotting')", op, i.ID, i.ContainerID, newName, image.ID, snapshot, failedHome)
if err == nil {
_, err = tx.Exec("INSERT INTO upgrade_state VALUES(?,?,?)", op, old.State.Status, old.State.Running)
}
if err == nil {
_, err = tx.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID)
}
if err != nil {
tx.Rollback()
fail(w, 500, "database error")
return
}
if tx.Commit() != nil {
fail(w, 500, "database error")
return
}
phase := func(p string) error {
_, err := a.s.DB.Exec("UPDATE upgrade_journal SET phase=? WHERE id=?", p, op)
return err
}
newID, complete, snapReady, createAttempted := "", false, false, false
stopAttempted := false
defer func() {
if complete {
return
}
cleanup, done := context.WithTimeout(context.Background(), 30*time.Second)
defer done()
if phase("rolling_back") != nil {
return
}
if createAttempted {
id := newID
if id == "" {
id = newName
}
// A timed-out create can still have succeeded; the journaled unique name is authoritative.
_ = a.docker.Do(cleanup, "POST", "/containers/"+url.PathEscape(id)+"/stop?t=10", nil, nil)
if a.removeRuntimeContainer(cleanup, id) != nil {
return
}
}
if snapReady && createAttempted {
if phase("saving_failed_home") != nil {
return
}
if root.Rename("home", failedHome) != nil || upgradeSync(root) != nil {
return
}
if phase("restoring_home") != nil {
return
}
if root.Rename(snapshot, "home") != nil || upgradeSync(root) != nil {
return
}
}
if stopAttempted && old.State.Running {
if phase("restoring_runtime") != nil || a.docker.Do(cleanup, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil) != nil {
return
}
}
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
tx, err := a.s.DB.Begin()
if err != nil {
return
}
defer tx.Rollback()
if _, err = tx.Exec("UPDATE upgrade_journal SET phase='rolled_back' WHERE id=?", op); err != nil {
return
}
if _, err = tx.Exec("UPDATE instances SET status=? WHERE id=?", old.State.Status, i.ID); err != nil {
return
}
_ = tx.Commit()
}()
if old.State.Running {
if phase("stopping") != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
stopAttempted = true
if a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/stop?t=10", nil, nil) != nil {
fail(w, 502, "old runtime stop failed")
return
}
}
stopped, inspectErr := a.docker.Inspect(ctx, i.ContainerID)
if inspectErr != nil || stopped.State.Running || stopped.State.Paused || stopped.State.Restarting {
fail(w, 502, "old runtime did not stop")
return
}
if phase("snapshotting") != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
snapshotBytes, quotaErr := a.quotaSnapshot(i.UID, root, 0)
if quotaErr != nil {
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
if upgradeCopy(ctx, root, "home", snapshot, snapshotBytes) != nil {
fail(w, 500, "home snapshot failed")
return
}
snapReady = true
backupDir := filepath.Join(a.root, "backups", i.UID, i.ID)
storage, err := os.OpenRoot(a.root)
if err != nil {
fail(w, 500, "backup storage unavailable")
return
}
err = configCheckPath(storage, filepath.ToSlash(filepath.Join("backups", i.UID, i.ID, "archive")))
storage.Close()
if err != nil || os.MkdirAll(backupDir, 0700) != nil {
fail(w, 500, "unsafe backup storage")
return
}
entries, err := os.ReadDir(backupDir)
if err != nil || len(entries) >= 20 {
fail(w, 409, "backup retention limit reached")
return
}
backupID := store.ID()
backupPath := filepath.Join(backupDir, backupID+".dshbak")
if err = backup.CreateWithPassword(filepath.Join(root.Name(), "home"), backupPath, in.Password, backup.Metadata{DSHVersion: i.Version, CreatedAt: time.Now().UTC()}); err != nil {
fail(w, 500, "pre-upgrade backup failed")
return
}
backupStat, err := os.Stat(backupPath)
if err != nil {
fail(w, 500, "pre-upgrade backup unavailable")
return
}
if err = a.indexBackup(i, backupID, "pre-upgrade", 2, backupStat.Size()); err != nil {
os.Remove(backupPath)
fail(w, 500, "backup index unavailable")
return
}
delete(old.Config, "Hostname")
old.Config["Image"], _ = json.Marshal(image.ID)
old.Config["Cmd"], _ = json.Marshal([]string{"web", "--no-open", "--port", "3080", "--trusted-host", a.runtimeHost(r)})
old.Config["HostConfig"], _ = json.Marshal(old.HostConfig)
endpoints := map[string]any{}
for network := range old.NetworkSettings.Networks {
endpoints[network] = map[string]any{}
}
old.Config["NetworkingConfig"], _ = json.Marshal(map[string]any{"EndpointsConfig": endpoints})
if phase("creating") != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
createAttempted = true
var created struct {
ID string `json:"Id"`
}
if a.docker.Do(ctx, "POST", "/containers/create?name="+url.QueryEscape(newName), old.Config, &created) != nil || created.ID == "" {
fail(w, 502, "upgrade container creation failed")
return
}
newID = created.ID
if _, err = a.s.DB.Exec("UPDATE upgrade_journal SET new_container=?, phase='starting' WHERE id=?", newID, op); err != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
if a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(newID)+"/start", nil, nil) != nil {
fail(w, 502, "upgrade start failed")
return
}
if phase("readiness") != nil {
fail(w, 500, "upgrade journal unavailable")
return
}
if a.upgradeReady(ctx, newID, i.ID, a.runtimeHost(r)) != nil {
fail(w, 502, "upgrade readiness failed")
return
}
tx, err = a.s.DB.Begin()
if err != nil {
fail(w, 500, "database error")
return
}
defer tx.Rollback()
_, err = tx.Exec("UPDATE instances SET container_id=?,image_id=?,version=?,status='running' WHERE id=?", newID, image.ID, image.Config.Labels["dsh-mgr.version"], i.ID)
if err == nil {
_, err = tx.Exec("UPDATE upgrade_journal SET phase='committed' WHERE id=?", op)
}
if err != nil {
fail(w, 500, "database error")
return
}
if tx.Commit() != nil {
fail(w, 500, "database error")
return
}
complete = true
if a.removeRuntimeContainer(ctx, i.ContainerID) != nil || phase("complete") != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "upgrade committed; old container cleanup requires recovery")
return
}
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
i.ContainerID, i.ImageID, i.Version, i.Status = newID, image.ID, image.Config.Labels["dsh-mgr.version"], "running"
a.s.AddAudit(i.UID, "instance.upgrade", i.ID, image.ID)
writeJSON(w, i)
}
func (a *API) upgradeReady(ctx context.Context, id, instanceID, host string) error {
ctx, cancel := context.WithTimeout(ctx, 150*time.Second)
defer cancel()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
c, err := a.docker.Inspect(ctx, id)
if err != nil {
return errors.New("readiness inspection failed")
}
if !c.State.Running {
return errors.New("runtime stopped")
}
logs, err := a.docker.Logs(ctx, id)
if err == nil && latestRuntimeToken(logs) != "" && (c.State.Health == nil || c.State.Health.Status == "healthy") {
{
ip := c.NetworkSettings.Networks[runtimeNetwork(instanceID)].IPAddress
if net.ParseIP(ip) != nil {
target := &url.URL{Scheme: "http", Host: net.JoinHostPort(ip, "8080")}
if cookies, e := exchangeRuntimeCookie(ctx, target, host, latestRuntimeToken(logs)); e == nil {
req, _ := http.NewRequestWithContext(ctx, "GET", target.String()+"/", nil)
req.Host = host
req.Header.Set("Cookie", cookies)
client := &http.Client{Transport: runtimeTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
if resp, e := client.Do(req); e == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
}
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
+528
View File
@@ -0,0 +1,528 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
)
func TestUpgradeWorkspaceCompatibility(t *testing.T) {
for _, tc := range []struct {
name, recipe, target string
shared, reject bool
}{
{"legacy-to-seven", "7", "/data/dsh-home", true, false},
{"single-to-seven", "7", "/workspace", false, false},
{"migrated-to-seven", "7", "/workspace", true, false},
{"legacy-to-six", "6", "/data/dsh-home", true, false},
{"legacy-to-unknown", "", "/data/dsh-home", true, false},
{"single-to-six", "6", "/workspace", false, true},
{"single-to-unknown", "", "/workspace", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
mounts := []map[string]any{{"Type": "bind", "Source": "/instance/home", "Target": tc.target, "ReadOnly": false}}
if tc.shared {
target := "/workspace"
if tc.target == "/workspace" {
target = "/shared"
}
mounts = append(mounts, map[string]any{"Type": "bind", "Source": "/user/files", "Target": target, "ReadOnly": true})
}
raw, _ := json.Marshal(mounts)
host := map[string]json.RawMessage{"Mounts": raw}
config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes","DSH_HOME=/data/dsh-home","HOME=/data/dsh-home","XDG_DATA_HOME=/old/data","XDG_CONFIG_HOME=/old/config"]`)}
before := string(config["Env"])
err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files")
if (err != nil) != tc.reject {
t.Fatalf("error=%v", err)
}
if tc.reject || tc.recipe != "7" {
if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before {
t.Fatal("legacy or rejected config changed")
}
return
}
if !strings.Contains(string(config["Env"]), "CUSTOM=yes") || strings.Contains(string(config["Env"]), "/data/dsh-home") {
t.Fatal(string(config["Env"]))
}
var got []map[string]any
if err := json.Unmarshal(host["Mounts"], &got); err != nil {
t.Fatal(err)
}
if got[0]["Source"] != "/instance/home" || got[0]["Target"] != "/workspace" || len(got) != 1 {
t.Fatal(got)
}
first, _ := json.Marshal([]any{config, host})
if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files"); err != nil {
t.Fatal(err)
}
second, _ := json.Marshal([]any{config, host})
if string(first) != string(second) {
t.Fatal("conversion is not idempotent")
}
})
}
}
func TestUpgradeWorkspaceRejectsAmbiguousMounts(t *testing.T) {
for _, extra := range []string{
`{"Type":"bind","Source":"/other","Target":"/shared"}`,
`{"Type":"bind","Source":"/other","Target":"/workspace"}`,
`{"Type":"volume","Source":"/user/files","Target":"/workspace"}`,
`{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`,
} {
config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes"]`)}
host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/data/dsh-home"},` + extra + `]`)}
before, _ := json.Marshal([]any{config, host})
if err := upgradeWorkspace(config, host, "7", "/instance/home", "/user/files"); err == nil {
t.Fatal("accepted ambiguous mounts", extra)
}
after, _ := json.Marshal([]any{config, host})
if string(before) != string(after) {
t.Fatal("rejected config mutated")
}
}
}
func TestUpgradeLifecycle(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, mode := range []string{"success", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
t.Run(mode, func(t *testing.T) {
image := "sha256:" + strings.Repeat("b", 64)
var a *API
var home, shared string
var calls []string
var body map[string]json.RawMessage
running := mode == "running" || mode == "running-failure" || mode == "restart-failure"
stopped := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != "panel.test" {
t.Error("wrong trusted host")
}
if r.URL.Query().Get("token") == "must-not-leak" {
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"})
w.WriteHeader(303)
return
}
if r.Header.Get("Cookie") != "dsh_auth=ready" {
t.Error("missing authenticated readiness")
}
w.WriteHeader(200)
}))
defer upstream.Close()
transport := runtimeTransport
runtimeTransport = transport.Clone()
runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://"))
}
defer func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }()
handler := func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/v1.47")
calls = append(calls, r.Method+" "+p)
switch {
case p == "/containers/new/exec":
writeJSON(w, map[string]string{"Id": "reconcile"})
case p == "/exec/reconcile/json":
writeJSON(w, map[string]any{"Running": false, "ExitCode": 0})
case p == "/containers/old/json":
state := "exited"
if running {
state = "running"
}
writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/data/dsh-home"}, {"Type": "bind", "Source": shared, "Target": "/workspace"}}}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}})
case strings.HasPrefix(p, "/images/"):
role := "runtime"
if mode == "invalid-image" {
role = "builder"
}
writeJSON(w, map[string]any{"Id": image, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": role, "dsh-mgr.version": "v2", "dsh-mgr.recipe": imageRecipeRev}}})
case p == "/containers/create":
if !strings.HasPrefix(r.URL.Query().Get("name"), "dsh-inst-one-upgrade-") {
t.Error("missing unique name")
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
before, _ := a.s.GetInstance("one")
if before.ContainerID != "old" || before.Status != "error" {
t.Errorf("premature switch: %+v", before)
}
writeJSON(w, map[string]string{"Id": "new"})
case p == "/containers/old/stop":
stopped = true
w.WriteHeader(204)
case p == "/containers/old/start":
restored, _ := os.ReadFile(filepath.Join(home, ".config", "state"))
if string(restored) != "original" {
t.Error("old runtime restarted before home restore")
}
if mode == "restart-failure" {
http.Error(w, "failed", 500)
return
}
w.WriteHeader(204)
case p == "/containers/old" && r.Method == "DELETE":
current, _ := a.s.GetInstance("one")
if current.ContainerID != "new" {
t.Error("old removed before commit")
}
if mode == "delete-failure" {
http.Error(w, "failed", 500)
return
}
w.WriteHeader(204)
case p == "/containers/new/start":
if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("migrated"), 0600); err != nil {
t.Error(err)
}
if err := os.WriteFile(filepath.Join(shared, "file"), []byte("shared-new"), 0600); err != nil {
t.Error(err)
}
if mode == "start-failure" || mode == "cleanup-failure" || mode == "running-failure" || mode == "restart-failure" {
http.Error(w, "token=must-not-leak", 500)
return
}
w.WriteHeader(204)
case p == "/containers/new/json":
writeJSON(w, map[string]any{"State": map[string]any{"Running": mode != "readiness-failure"}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}})
case p == "/containers/new/logs":
if mode == "db-failure" {
_, err := a.s.DB.Exec("CREATE TRIGGER reject_switch BEFORE UPDATE OF container_id ON instances BEGIN SELECT RAISE(FAIL, 'blocked'); END")
if err != nil {
t.Error(err)
}
}
fmt.Fprint(w, "http://runtime/?token=must-not-leak\n")
case p == "/containers/new" && r.Method == "DELETE":
if mode == "cleanup-failure" {
http.Error(w, "token=must-not-leak", 500)
return
}
w.WriteHeader(204)
default:
w.WriteHeader(204)
}
}
var sid string
a, sid = runtimeTestAPI(t, handler)
hash, _ := bcrypt.GenerateFromPassword([]byte("upgrade-password"), bcrypt.MinCost)
a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash))
if _, err := a.verifiedImages(context.Background()); err != nil {
t.Fatal(err)
}
if mode != "unverified-image" {
status, version, builtImage := "success", "v2", image
switch mode {
case "failed-build":
status = "failed"
case "version-mismatch":
version = "v3"
case "image-id-mismatch":
builtImage = "sha256:" + strings.Repeat("c", 64)
}
if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,image_id,created_at,recipe_rev,platform) VALUES(?,?,?,?,?,?,?)", store.ID(), version, status, builtImage, time.Now().UTC().Format(time.RFC3339Nano), imageRecipeRev, imagePlatform); err != nil {
t.Fatal(err)
}
}
base := filepath.Join(a.root, "workspaces", "owner", "instances", "one")
home = filepath.Join(base, "home")
shared = filepath.Join(a.root, "workspaces", "owner", "files")
for _, p := range []string{filepath.Join(home, ".config"), shared} {
if err := os.MkdirAll(p, 0750); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil {
t.Fatal(err)
}
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()}
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
stale := i
stale.ContainerID = "stale"
a.upgrade(w, runtimeRequest("POST", "/api/instances/one/upgrade", fmt.Sprintf(`{"image_id":%q,"password":"upgrade-password"}`, image), sid), stale)
if strings.Contains(w.Body.String(), "must-not-leak") {
t.Fatal("leaked token")
}
got, err := a.s.GetInstance(i.ID)
if err != nil {
t.Fatal(err)
}
if mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" {
want := 400
if w.Code != want {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
if got.ContainerID != i.ContainerID || got.ImageID != i.ImageID || got.Version != i.Version || got.Status != i.Status {
t.Fatalf("rejected upgrade changed instance: %+v", got)
}
for _, call := range calls {
if !strings.HasPrefix(call, "GET ") {
t.Errorf("rejected upgrade mutated runtime: %s", call)
}
}
return
}
var phase, snapshot, failed string
if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(home, ".config", "state"))
if err != nil {
t.Fatal(err)
}
if mode == "success" || mode == "running" {
if w.Code != 200 || got.ContainerID != "new" || got.Version != "v2" || phase != "complete" || string(data) != "migrated" {
t.Fatalf("%d %s %+v phase=%s home=%s", w.Code, w.Body.String(), got, phase, data)
}
saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state"))
if err != nil || string(saved) != "original" {
t.Fatalf("snapshot %q %v", saved, err)
}
st, _ := os.Stat(filepath.Join(base, snapshot, ".config", "state"))
if st.Sys().(*syscall.Stat_t).Uid != 1000 {
t.Error("snapshot ownership")
}
} else if mode == "delete-failure" {
if w.Code != 502 || got.ContainerID != "new" || got.Status != "error" || phase != "committed" || string(data) != "migrated" {
t.Fatalf("unsafe delete failure %+v %s", got, phase)
}
} else if mode == "restart-failure" {
if w.Code < 400 || got.ContainerID != "old" || got.Status != "error" || phase != "restoring_runtime" || string(data) != "original" {
t.Fatalf("unsafe restart failure %+v %s", got, phase)
}
} else if mode == "cleanup-failure" {
if got.Status != "error" || phase != "rolling_back" || string(data) != "migrated" || !a.upgradeRecoveryRequired(got) {
t.Fatalf("unsafe cleanup %+v %s %s", got, phase, data)
}
} else {
if w.Code < 400 || got.ContainerID != "old" || got.ImageID != "old-image" || got.Status != map[bool]string{true: "running", false: "exited"}[running] || phase != "rolled_back" || string(data) != "original" {
t.Fatalf("%d %+v phase=%s home=%s", w.Code, got, phase, data)
}
failedData, err := os.ReadFile(filepath.Join(base, failed, ".config", "state"))
if err != nil || string(failedData) != "migrated" {
t.Fatalf("failed home %s %v", failedData, err)
}
}
sharedData, _ := os.ReadFile(filepath.Join(shared, "file"))
if string(sharedData) != "shared-new" {
t.Error("shared workspace rolled back")
}
if _, ok := body["Hostname"]; ok {
t.Error("hostname copied")
}
if string(body["Env"]) != `["CUSTOM=yes","DSH_HOME=/workspace","HOME=/workspace","XDG_DATA_HOME=/workspace/.local/share","XDG_CONFIG_HOME=/workspace/.config"]` || !strings.Contains(string(body["HostConfig"]), "9007199254740993") {
t.Error("raw config not preserved")
}
if strings.Contains(string(body["HostConfig"]), `"Target":"/shared"`) || strings.Contains(string(body["HostConfig"]), "/data/dsh-home") {
t.Error("workspace mounts not migrated")
}
if strings.Contains(string(body["NetworkingConfig"]), "172.18") || strings.Contains(string(body["NetworkingConfig"]), "Aliases") {
t.Error("dynamic endpoint copied")
}
if string(body["Cmd"]) != `["web","--no-open","--port","3080","--trusted-host","panel.test"]` {
t.Errorf("command not recalculated: %s", body["Cmd"])
}
has := func(want string) bool {
for _, call := range calls {
if call == want {
return true
}
}
return false
}
if has("DELETE /containers/old") != (mode == "success" || mode == "running" || mode == "delete-failure") {
t.Error("old deletion lifecycle")
}
if has("POST /containers/old/start") != (mode == "running-failure" || mode == "restart-failure") {
t.Error("old restart lifecycle")
}
if has("POST /containers/old/stop") != running {
t.Error("old stop lifecycle")
}
})
}
}
func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
for _, kind := range []string{"symlink", "fifo", "oversize", "directory-symlink"} {
t.Run(kind, func(t *testing.T) {
base := t.TempDir()
if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil {
t.Fatal(err)
}
p := filepath.Join(base, "home", "bad")
var err error
switch kind {
case "symlink":
err = os.Symlink("/etc/passwd", p)
case "fifo":
err = syscall.Mkfifo(p, 0600)
case "oversize":
err = os.WriteFile(p, []byte("12345"), 0600)
case "directory-symlink":
err = os.Symlink("/tmp", p)
}
if err != nil {
t.Fatal(err)
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if upgradeCopy(context.Background(), root, "home", "snapshot", 4) == nil {
t.Fatal("unsafe file accepted")
}
})
}
}
func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, mode := range []string{"pnpm", "escape", "chain-escape", "loop", "dangling"} {
t.Run(mode, func(t *testing.T) {
base := t.TempDir()
home := filepath.Join(base, "home")
pkg := filepath.Join(home, "node_modules/.pnpm/pkg/node_modules/pkg")
if err := os.MkdirAll(pkg, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkg, "index.js"), []byte("original"), 0640); err != nil {
t.Fatal(err)
}
link := ".pnpm/pkg/node_modules/pkg"
switch mode {
case "escape":
link = "../../outside"
case "chain-escape":
if err := os.Symlink(".", filepath.Join(home, "alias")); err != nil {
t.Fatal(err)
}
link = "../alias/../outside"
case "loop":
link = "pkg"
case "dangling":
link = "missing"
}
if err := os.Symlink(link, filepath.Join(home, "node_modules/pkg")); err != nil {
t.Fatal(err)
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024)
if mode != "pnpm" {
if err == nil {
t.Fatal("unsafe link accepted")
}
return
}
if err != nil {
t.Fatal(err)
}
got, err := root.Readlink("snapshot/node_modules/pkg")
if err != nil || got != link {
t.Fatalf("link not preserved: %q %v", got, err)
}
if err := root.Rename("home", "failed-home"); err != nil {
t.Fatal(err)
}
if err := root.Rename("snapshot", "home"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js"))
if err != nil || string(data) != "original" {
t.Fatalf("rollback link broken: %q %v", data, err)
}
})
}
}
func TestUpgradeReadinessRequiresToken(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/json") {
writeJSON(w, map[string]any{"State": map[string]bool{"Running": true}})
} else {
fmt.Fprint(w, "starting without token")
}
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
if a.upgradeReady(ctx, "new", "one", "panel.test") == nil {
t.Fatal("accepted without token")
}
}
func TestUpgradeReadinessRequiresAuthenticatedHTTP(t *testing.T) {
for _, mode := range []string{"success", "token-rejected", "missing-cookie", "unauthorized", "redirect", "unavailable"} {
t.Run(mode, func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("token") != "" {
if mode == "token-rejected" {
w.WriteHeader(401)
return
}
if mode != "missing-cookie" {
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"})
}
w.WriteHeader(303)
return
}
if r.Header.Get("Cookie") != "dsh_auth=ready" {
t.Error("cookie not sent")
}
switch mode {
case "unauthorized":
w.WriteHeader(401)
case "redirect":
w.WriteHeader(302)
case "unavailable":
w.WriteHeader(503)
default:
w.WriteHeader(200)
}
}))
defer upstream.Close()
transport := runtimeTransport
runtimeTransport = transport.Clone()
runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://"))
}
defer func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }()
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/json") {
writeJSON(w, map[string]any{"State": map[string]bool{"Running": true}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}})
} else {
fmt.Fprint(w, "http://runtime/?token=ready\n")
}
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := a.upgradeReady(ctx, "new", "one", "panel.test")
if (err == nil) != (mode == "success") {
t.Fatalf("readiness=%v", err)
}
})
}
}
+219
View File
@@ -0,0 +1,219 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
type pausedDownload struct {
*httptest.ResponseRecorder
started chan struct{}
release chan struct{}
once sync.Once
}
func (w *pausedDownload) Write(p []byte) (int, error) {
w.once.Do(func() { close(w.started) })
<-w.release
return w.ResponseRecorder.Write(p)
}
func TestSlowDownloadDoesNotHoldLifecycleLock(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") })
for _, id := range []string{"one", "two"} {
if err := a.s.AddInstance(store.Instance{ID: id, UID: "owner"}); err != nil {
t.Fatal(err)
}
dir := filepath.Join(a.root, "workspaces/owner/instances", id, "home")
if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0600); err != nil {
t.Fatal(err)
}
}
w := &pausedDownload{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
done := make(chan struct{})
go func() {
defer close(done)
a.workspace(w, runtimeRequest("GET", "/api/ws/download?instance=one&path=file.txt", "", sid))
}()
defer func() { close(w.release); <-done }()
select {
case <-w.started:
case <-time.After(3 * time.Second):
t.Fatal("download not started")
}
if !a.mu.TryLock() {
t.Fatal("slow network holds global lifecycle lock")
}
active := a.workspaceReaders["one"]
a.mu.Unlock()
if active != 1 {
t.Fatalf("lease count %d", active)
}
other := httptest.NewRecorder()
otherDone := make(chan struct{})
go func() {
defer close(otherDone)
a.workspace(other, runtimeRequest("GET", "/api/ws/tree?instance=two", "", sid))
}()
select {
case <-otherDone:
case <-time.After(3 * time.Second):
t.Fatal("other instance blocked")
}
if other.Code != 200 {
t.Fatalf("other instance: %d", other.Code)
}
proxyDone := make(chan struct{})
proxyResponse := httptest.NewRecorder()
go func() {
defer close(proxyDone)
a.proxy(proxyResponse, runtimeRequest("GET", "/dsh/missing/", "", sid))
}()
select {
case <-proxyDone:
case <-time.After(3 * time.Second):
t.Fatal("proxy entry blocked by download")
}
if proxyResponse.Code != 404 {
t.Fatalf("proxy status %d", proxyResponse.Code)
}
for _, action := range []string{"restore", "upgrade", "stop", ""} {
method := "POST"
if action == "" {
method = "DELETE"
}
response := httptest.NewRecorder()
url := "/api/instances/one"
if action != "" {
url += "/" + action
}
body := `{}`
if action == "upgrade" {
body = `{"image_id":"sha256:` + strings.Repeat("a", 64) + `"}`
}
a.instance(response, runtimeRequest(method, url, body, sid))
if response.Code != 409 {
t.Errorf("%s during download: %d %s", action, response.Code, response.Body.String())
}
}
}
func TestDirectoryEntryLimit(t *testing.T) {
dir := t.TempDir()
for i := 0; i <= maxDirectoryEntries; i++ {
f, err := os.Create(filepath.Join(dir, fmt.Sprint(i)))
if err != nil {
t.Fatal(err)
}
f.Close()
}
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if _, err = readRootDir(root, "."); !errors.Is(err, errDirectoryLimit) {
t.Fatalf("limit: %v", err)
}
if err = os.Remove(filepath.Join(dir, fmt.Sprint(maxDirectoryEntries))); err != nil {
t.Fatal(err)
}
entries, err := readRootDir(root, ".")
if err != nil || len(entries) != maxDirectoryEntries {
t.Fatalf("boundary: %d %v", len(entries), err)
}
}
type pausedUpload struct {
started chan struct{}
release chan struct{}
once sync.Once
}
func (r *pausedUpload) Read(p []byte) (int, error) {
r.once.Do(func() { close(r.started) })
<-r.release
return 0, io.EOF
}
func (r *pausedUpload) Close() error { return nil }
func TestSlowUploadDoesNotHoldLifecycleLock(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") })
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 1<<30); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(a.root, "workspaces/owner/instances/one/home"), 0750); err != nil {
t.Fatal(err)
}
body := &pausedUpload{started: make(chan struct{}), release: make(chan struct{})}
r := runtimeRequest("POST", "/api/ws/upload?instance=one", "", sid)
r.Body = body
r.Header.Set("Content-Type", "multipart/form-data; boundary=test")
done := make(chan struct{})
go func() { defer close(done); a.workspace(httptest.NewRecorder(), r) }()
defer func() { close(body.release); <-done }()
select {
case <-body.started:
case <-time.After(3 * time.Second):
t.Fatal("upload not reading body")
}
if !a.mu.TryLock() {
t.Fatal("slow upload holds global lock")
}
a.mu.Unlock()
if !workspaceMu.TryLock() {
t.Fatal("slow upload holds workspace write lock")
}
workspaceMu.Unlock()
}
func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") })
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(a.root, "workspaces/owner/instances/one/home"), 0750); err != nil {
t.Fatal(err)
}
w := &pausedDownload{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
done := make(chan struct{})
go func() {
defer close(done)
a.workspace(w, runtimeRequest("GET", "/api/ws/tree?instance=one&path=missing", "", sid))
}()
defer func() { close(w.release); <-done }()
select {
case <-w.started:
case <-time.After(3 * time.Second):
t.Fatal("response not started")
}
if !workspaceMu.TryLock() {
t.Fatal("error response holds workspace lock")
}
workspaceMu.Unlock()
if !a.mu.TryLock() {
t.Fatal("error response holds lifecycle lock")
}
leases := len(a.workspaceReaders)
a.mu.Unlock()
if leases != 0 {
t.Fatal("response retains lease")
}
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"git.misaka.ren/m1saka/dsh-web/internal/store"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWorkspaceInstanceScope(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") })
for _, x := range []struct{ id, uid string }{{"one", "owner"}, {"two", "owner"}, {"foreign", "someone"}} {
if err := a.s.AddInstance(store.Instance{ID: x.id, UID: x.uid}); err != nil {
t.Fatal(err)
}
dir := filepath.Join(a.root, "workspaces", x.uid, "instances", x.id, "home")
if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, x.id+".txt"), []byte(x.id), 0600); err != nil {
t.Fatal(err)
}
}
for _, tc := range []struct {
query string
status int
contains string
}{
{"instance=one", 200, "one.txt"}, {"instance=two", 200, "two.txt"},
{"instance=foreign", 404, ""}, {"instance=missing", 404, ""},
{"instance=one&path=../two", 400, ""},
} {
w := httptest.NewRecorder()
a.workspace(w, runtimeRequest("GET", "/api/ws/tree?"+tc.query, "", sid))
if w.Code != tc.status || tc.contains != "" && !strings.Contains(w.Body.String(), tc.contains) {
t.Fatalf("%s: %d %s", tc.query, w.Code, w.Body.String())
}
}
w := httptest.NewRecorder()
a.workspace(w, runtimeRequest("POST", "/api/ws/mkdir?instance=two", `{"path":"created"}`, sid))
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if _, err := os.Stat(filepath.Join(a.root, "workspaces/owner/instances/two/home/created")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(a.root, "workspaces/owner/instances/one/home/created")); !os.IsNotExist(err) {
t.Fatal("cross instance write")
}
dir := filepath.Join(a.root, "workspaces/owner/instances/one/home")
if err := os.Symlink(t.TempDir(), filepath.Join(dir, "escape")); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
a.workspace(w, runtimeRequest("GET", "/api/ws/tree?instance=one&path=escape", "", sid))
if w.Code != 404 {
t.Fatal("symlink traversal allowed", w.Code)
}
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='two'"); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
a.workspace(w, runtimeRequest("POST", "/api/ws/mkdir?instance=two", "{\"path\":\"blocked\"}", sid))
if w.Code != 409 {
t.Fatal("recovery write permitted", w.Code)
}
}