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