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