1072 lines
32 KiB
Go
1072 lines
32 KiB
Go
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))
|
|
decoder.DisallowUnknownFields()
|
|
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.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) {
|
|
// The instance row is visible to its owner while creating, so hold the
|
|
// instance lock for the whole creation: a concurrent DELETE or file lease
|
|
// then waits instead of interleaving with container setup. Concurrent
|
|
// creations of different instances remain independent.
|
|
inst := a.instanceLocks.Acquire(i.ID)
|
|
defer inst.Release()
|
|
if _, err := a.s.GetInstance(i.ID); err != nil {
|
|
return i, errors.New("instance reservation unavailable")
|
|
}
|
|
networkCreated, networkAttempted, containerAttempted, complete := false, false, false, false
|
|
defer func() {
|
|
if complete {
|
|
return
|
|
}
|
|
cleanup, done := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer done()
|
|
if networkAttempted && !networkCreated {
|
|
if err := a.recoverRuntimeNetwork(cleanup, i.ID); err != nil {
|
|
a.s.SetInstance(i.ID, "error")
|
|
return
|
|
}
|
|
}
|
|
if containerAttempted && i.ContainerID == "" {
|
|
if err := a.recoverRuntimeContainer(cleanup, &i); err != nil {
|
|
a.s.SetInstance(i.ID, "error")
|
|
return
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if err := a.removeInstanceData(i); err != nil {
|
|
a.s.SetInstance(i.ID, "error")
|
|
return
|
|
}
|
|
a.s.DeleteInstance(i.ID)
|
|
_, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID)
|
|
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID)
|
|
}()
|
|
admissionMu.RLock()
|
|
defer admissionMu.RUnlock()
|
|
buildQueue.Lock()
|
|
policyErr := a.ensureBuilds()
|
|
buildQueue.Unlock()
|
|
if policyErr != nil {
|
|
return i, policyErr
|
|
}
|
|
policy, policyErr := a.evaluateImage(ctx, i.ImageID)
|
|
if policyErr != nil {
|
|
return i, policyErr
|
|
}
|
|
if !policy.Selectable || policy.Version != i.Version {
|
|
return i, errors.New("image is not admitted for creation")
|
|
}
|
|
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, "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.setRuntimeCreatePending(i.ID, "network_pending", true); err != nil {
|
|
return i, err
|
|
}
|
|
networkAttempted = true
|
|
var createdNetwork struct {
|
|
ID string `json:"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}}, &createdNetwork); err != nil {
|
|
if dockerCreateRejected(err) {
|
|
if clearErr := a.setRuntimeCreatePending(i.ID, "network_pending", false); clearErr == nil {
|
|
networkAttempted = false
|
|
}
|
|
}
|
|
return i, errors.New("network creation failed")
|
|
}
|
|
networkCreated = true
|
|
if createdNetwork.ID != "" {
|
|
if _, err := a.s.DB.ExecContext(ctx, "INSERT INTO runtime_networks(instance_id,network_id) VALUES(?,?)", i.ID, createdNetwork.ID); err != nil {
|
|
return i, err
|
|
}
|
|
}
|
|
if err := a.setRuntimeCreatePending(i.ID, "network_pending", false); err != nil {
|
|
return i, err
|
|
}
|
|
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.setRuntimeCreatePending(i.ID, "container_pending", true); err != nil {
|
|
return i, err
|
|
}
|
|
containerAttempted = true
|
|
if err := a.docker.Do(ctx, "POST", "/containers/create?name="+network, body, &created); err != nil || created.ID == "" {
|
|
if dockerCreateRejected(err) {
|
|
if clearErr := a.setRuntimeCreatePending(i.ID, "container_pending", false); clearErr == nil {
|
|
containerAttempted = false
|
|
}
|
|
}
|
|
return i, errors.New("container creation failed")
|
|
}
|
|
i.ContainerID = created.ID
|
|
if err := a.setRuntimeCreatePending(i.ID, "container_pending", false); err != nil {
|
|
return i, err
|
|
}
|
|
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.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=? AND container_pending=0 AND network_pending=0", i.ID)
|
|
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 dockerCreateRejected(err error) bool {
|
|
var e *docker.APIError
|
|
return errors.As(err, &e) && e.StatusCode >= 400 && e.StatusCode < 500 && e.StatusCode != 408 && e.StatusCode != 409
|
|
}
|
|
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 {
|
|
return a.recoverRuntimeNetwork(ctx, id)
|
|
}
|
|
|
|
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" {
|
|
inst := a.instanceLocks.Acquire(p[0])
|
|
defer inst.Release()
|
|
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
|
|
}
|
|
a.readersMu.Lock()
|
|
readers := a.workspaceReaders[i.ID]
|
|
a.readersMu.Unlock()
|
|
if readers > 0 {
|
|
fail(w, 409, "instance files busy; retry later")
|
|
return
|
|
}
|
|
if i.ContainerID == "" {
|
|
if err := a.recoverRuntimeContainer(ctx, &i); err != nil {
|
|
fail(w, 502, "container recovery failed")
|
|
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.removeInstanceData(i); err != nil {
|
|
a.s.SetInstance(i.ID, "error")
|
|
fail(w, 500, "instance data deletion failed; retry deletion")
|
|
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.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID)
|
|
a.s.AddAudit(u.ID, "instance.delete", i.ID, "instance data deleted")
|
|
// The instance row is gone and its resources were just cleaned, so
|
|
// any pending flags cannot resolve later: drop the row unconditionally.
|
|
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID)
|
|
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
|
|
}
|
|
inst := a.instanceLocks.Acquire(p[0])
|
|
defer inst.Release()
|
|
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
|
|
}
|
|
a.readersMu.Lock()
|
|
readers := a.workspaceReaders[i.ID]
|
|
a.readersMu.Unlock()
|
|
if readers > 0 {
|
|
fail(w, 409, "instance files busy; retry later")
|
|
return
|
|
}
|
|
admissionMu.RLock()
|
|
defer admissionMu.RUnlock()
|
|
if action != "stop" {
|
|
buildQueue.Lock()
|
|
e := a.ensureBuilds()
|
|
buildQueue.Unlock()
|
|
if e != nil {
|
|
fail(w, 500, "database error")
|
|
return
|
|
}
|
|
var blocked int
|
|
e = a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked)
|
|
if e != nil {
|
|
fail(w, 500, "database error")
|
|
return
|
|
}
|
|
if blocked > 0 {
|
|
fail(w, 409, "image is blocked; start/restart denied")
|
|
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
|
|
}
|
|
p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2)
|
|
if p[0] == "" {
|
|
fail(w, 404, "not found")
|
|
return
|
|
}
|
|
inst := a.instanceLocks.Acquire(p[0])
|
|
locked := inst
|
|
defer func() {
|
|
if locked != nil {
|
|
locked.Release()
|
|
}
|
|
}()
|
|
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()
|
|
locked.Release()
|
|
locked = nil
|
|
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") },
|
|
}
|
|
}
|