196 lines
6.7 KiB
Go
196 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|