From e6441bdf534e5b4b9fcb9e125462a2a0f14b857d Mon Sep 17 00:00:00 2001 From: elecbug Date: Fri, 2 Oct 2026 13:24:53 +0900 Subject: [PATCH 1/9] feat: add user management and runtime configuration features - Implement user synchronization and credential management in user_test.go. - Refactor App struct to streamline configuration and runtime paths. - Enhance configuration loading to handle missing auth files gracefully. - Improve disk management functions for user and shared volumes. - Introduce tests for disk operations and user cleanup processes. - Build runtime images with embedded assets for improved deployment. - Create a manager server to handle user requests and configurations. - Embed static assets for the authentication server. --- README.md | 1 + cfg/config.yml | 5 +- doc/USAGE.md | 95 +++++++-- shell/build_ctl.sh | 23 +-- src/cmd/auth/main.go | 82 +------- src/cmd/ctl/main.go | 22 ++- src/cmd/manager/main.go | 136 +------------ src/docker/assets.go | 9 + src/docker/auth.Dockerfile | 23 +-- src/docker/manager.Dockerfile | 22 +-- src/docker/user.Dockerfile | 8 +- src/internal/auth/handler/app.go | 2 + src/internal/auth/handler/assets_test.go | 27 +++ src/internal/auth/handler/credentials_test.go | 63 ++++++ src/internal/auth/handler/handle_login.go | 4 + src/internal/auth/handler/handle_root.go | 21 +- src/internal/auth/handler/handle_signup.go | 2 + src/internal/auth/server.go | 87 ++++++++ src/internal/common/config/validate.go | 28 +-- src/internal/common/config/validate_test.go | 21 ++ src/internal/common/convert/convert.go | 16 -- src/internal/common/system_api/linux.go | 70 ++++--- src/internal/common/system_api/linux_test.go | 61 ++++++ src/internal/common/user/user.go | 62 ++++-- src/internal/common/user/user_test.go | 78 ++++++++ src/internal/ctl/app/app.go | 24 +-- src/internal/ctl/app/config.go | 25 ++- src/internal/ctl/app/disk.go | 44 ++--- src/internal/ctl/app/disk_test.go | 82 ++++++++ src/internal/ctl/app/docker_build.go | 187 ++++++------------ src/internal/ctl/app/runtime_test.go | 131 ++++++++++++ src/internal/ctl/app/service.go | 8 +- src/internal/ctl/app/spec.go | 56 +----- src/internal/ctl/log/log.go | 2 +- src/internal/manager/server.go | 130 ++++++++++++ src/static/assets.go | 9 + 36 files changed, 1057 insertions(+), 609 deletions(-) create mode 100644 src/docker/assets.go create mode 100644 src/internal/auth/handler/assets_test.go create mode 100644 src/internal/auth/handler/credentials_test.go create mode 100644 src/internal/auth/server.go create mode 100644 src/internal/common/config/validate_test.go create mode 100644 src/internal/common/system_api/linux_test.go create mode 100644 src/internal/common/user/user_test.go create mode 100644 src/internal/ctl/app/disk_test.go create mode 100644 src/internal/ctl/app/runtime_test.go create mode 100644 src/internal/manager/server.go create mode 100644 src/static/assets.go diff --git a/README.md b/README.md index 734f6f1..9261430 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Linuxus enables instructors to provide per-user Linux environments without requi 🌐 Web-based shell access (no SSH required) 📁 Per-user persistent storage 🔒 Resource and permission control +📦 Single binary deployment with root-level data and volumes
⚙️ Fully configurable via YAML --- diff --git a/cfg/config.yml b/cfg/config.yml index 87b3922..8e4b07e 100644 --- a/cfg/config.yml +++ b/cfg/config.yml @@ -1,3 +1,4 @@ +# Relative host paths are resolved from the directory containing linuxusctl. user_service: container: name_prefix: linuxus_user_ @@ -58,6 +59,8 @@ manager_service: name: linuxus_manager network: linuxus_manager_net subnet: "10.11.0.0/24" + # Legacy Manager mount paths, retained for config compatibility. + # Docker receives the host paths from volumes.host directly. homes_dir: /vol/homes share_dir: /vol/share readonly_dir: /vol/readonly @@ -78,4 +81,4 @@ volumes: container: share: /home/share readonly: /home/readonly - disk_limit: 1G \ No newline at end of file + disk_limit: 1G diff --git a/doc/USAGE.md b/doc/USAGE.md index 93e2ea2..99f42c9 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -11,7 +11,7 @@ cd linuxus ## 1. Install Dependencies -### Go +### Go (build machine only) ```bash sudo snap install go --classic @@ -27,12 +27,17 @@ sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin d ## 2. Build Controller -Build the control CLI: +Build the single Linux executable (Go 1.26.1 or newer): ```bash ./shell/build_ctl.sh ``` +The script can be invoked from any working directory. It builds a static Linux +binary for the current architecture; set `GOARCH=arm64` to cross-compile. +`linuxusctl` includes the Auth and Manager service modes, Dockerfiles, user +startup script, and web assets. No Go compiler or source tree is required at runtime. + Generated executable: ```bash @@ -53,10 +58,49 @@ After enabling completion, you can use `TAB` to automatically complete commands ## 3. Run Service -> [!IMPORTANT] -> `linuxusctl` is a project-local control binary. -> Do not move `linuxusctl` outside the Linuxus project root. -> It depends on `./src`, `./cfg`, and `./volume` relative to the project root. +Copy `linuxusctl` and `cfg/config.yml` into a deployment directory: + +```text +linuxus/ +├── linuxusctl +├── cfg/ +│ └── config.yml +├── data/ +│ └── AUTH_LIST +└── volumes/ +``` + +`data/` and `volumes/` are initialized when starting a fresh deployment. All +relative host paths in the YAML are resolved against the directory containing +the executable, even when invoked from another working directory. Absolute +paths remain unchanged. When invoking a symlink to `linuxusctl`, the real +executable's directory is used. + +For example, prepare a separate deployment without copying `src/`: + +```bash +mkdir -p deploy/cfg +cp linuxusctl deploy/ +cp cfg/config.yml deploy/cfg/ +sudo ./deploy/linuxusctl up +``` + +The runtime host needs Linux, a local Docker daemon, `mkfs.ext4` (e2fsprogs), +and `losetup` (util-linux). Disk operations require root privileges. Images use +the executable's architecture. The first image build needs access to the base +images and Ubuntu/Debian package repositories; it does not download Go modules. + +Before starting, set the two session secrets in `cfg/config.yml` to your own +values. `up` creates a missing auth file without overwriting existing accounts, +prepares shared disks and registered users' disks, and starts the services. +A fresh auth file has no accounts; `admin_id` designates an account's privileges +and does not create the account automatically. + +Host directories can also point to an external storage location through absolute +YAML paths or root-level `data/` and `volumes/` symlinks. Manager passes the +resolved host paths to Docker; it does not need its own copies of the volumes. +The legacy `manager_service.container.{homes_dir,share_dir,readonly_dir}` fields +are retained for configuration compatibility but are no longer mounted into Manager. ### 3.1 Start / Manage Services @@ -83,8 +127,8 @@ After enabling completion, you can use `TAB` to automatically complete commands ### 3.2 Example Usage ```bash -./linuxusctl up # Build and start -./linuxusctl restart # Restart +sudo ./linuxusctl up # Assemble images, prepare disks and start +sudo ./linuxusctl restart # Restart ./linuxusctl ps network # Show network status of linuxus service ``` @@ -127,13 +171,13 @@ A **Signup** link will appear on the login page. After signup, the host must initialize user environments: ```bash -./linuxusctl ensure-disk +sudo ./linuxusctl ensure-disk --user ``` or initialize all missed user environments: ```bash -./linuxusctl ensure-disk --all +sudo ./linuxusctl ensure-disk --all ``` This step: @@ -161,18 +205,47 @@ manager_service: ``` volumes/ ├── homes/ +│ ├── alice.img # ext4 disk image +│ └── alice/ # mounted home +├── share.img ├── share/ +├── readonly.img └── readonly/ ``` --- +Containers may be removed and recreated while these disk images retain data. +After a host reboot, run `sudo ./linuxusctl up` to remount them before using the +service. `down` removes containers and networks but preserves disks. +`clean-volume` removes data; it stops on unmount or loop-device errors rather +than continuing deletion. Cleaning all volumes leaves unrelated files in the +volume root untouched. + +### Migrating an existing source-relative deployment + +Existing `src/data` and `src/volumes` are not moved automatically. Before changing +paths, stop the old services and back up `AUTH_LIST` and all disk images. + +To keep existing storage in place, configure its absolute host paths in the new +YAML, or link the deployment's `data/` and `volumes/` to those locations. Keep the +storage directories even if you remove the rest of the source checkout. + +To relocate storage completely, stop the services, unmount the old user/shared +disks, and detach their loop devices before moving the directories. Copy the +`AUTH_LIST` and `.img` files into the new root's `data/` and `volumes/`, preserving +ownership and permissions. Then run `sudo ./linuxusctl up` to attach and mount +the existing images at the new paths. Do not use `clean-volume` for migration; +it deletes the images and their contents. + +--- + ## 6. Directory Permissions ### 👤 User (`homes/`) * Private -* Mounted to `/home/` +* Mounted to `/home/` inside each user container (default: `/home/user`) ### 📂 Share diff --git a/shell/build_ctl.sh b/shell/build_ctl.sh index 923264e..a43bb8e 100755 --- a/shell/build_ctl.sh +++ b/shell/build_ctl.sh @@ -1,19 +1,12 @@ #!/bin/bash +set -euo pipefail -set -e +BUILD_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$BUILD_DIR/.." && pwd)" +TARGET_ARCH="${GOARCH:-$(go env GOARCH)}" -CURRENT_DIR="$(pwd)" -BUILD_DIR="$(dirname "$0")" -REPO_DIR="$(dirname "$BUILD_DIR")" -SRC_DIR="$REPO_DIR/src" +cd "$REPO_DIR/src" +# One static Linux binary provides the CLI and both container services. +CGO_ENABLED=0 GOOS=linux GOARCH="$TARGET_ARCH" go build -trimpath -o "$REPO_DIR/linuxusctl" ./cmd/ctl -cd "$SRC_DIR" - -SOURCE="cmd/ctl/main.go" -OUTPUT="linuxusctl" - -go build -o "../$OUTPUT" "$SOURCE" - -echo "[+] Built $OUTPUT successfully -> $REPO_DIR/$OUTPUT" - -cd "$CURRENT_DIR" \ No newline at end of file +echo "[+] Built linuxusctl -> $REPO_DIR/linuxusctl (linux/$TARGET_ARCH)" diff --git a/src/cmd/auth/main.go b/src/cmd/auth/main.go index 1d7ba9a..af74937 100644 --- a/src/cmd/auth/main.go +++ b/src/cmd/auth/main.go @@ -1,89 +1,13 @@ package main import ( - "encoding/json" - "fmt" "log" - "os" - "strings" - "time" - "github.com/elecbug/linuxus/src/internal/auth/handler" - "github.com/elecbug/linuxus/src/internal/common/config" - "github.com/elecbug/linuxus/src/internal/common/user" + "github.com/elecbug/linuxus/src/internal/auth" ) -// main loads configuration, registers routes, and starts the auth server. func main() { - config, err := parseConfig() - if err != nil { - log.Fatalf("failed to parse config: %v", err) + if err := auth.Run(); err != nil { + log.Fatal(err) } - - app := handler.NewApp(config) - app.RegisterRoutes() - - if err := app.Start(":8080"); err != nil { - log.Fatalf("failed to start server: %v", err) - } -} - -// parseConfig loads all runtime settings from environment variables and auth list data. -func parseConfig() (*handler.AppConfig, error) { - var err error - - env := os.Getenv("ENV") - if env == "" { - return nil, fmt.Errorf("ENV environment variable is required") - } - - var cfg config.Config - - err = json.Unmarshal([]byte(env), &cfg) - if err != nil { - return nil, fmt.Errorf("failed to parse ENV variable as JSON: %v", err) - } - - users, err := user.LoadUsers(cfg.AuthService.Mounts.ContainerAuthListPath) - if err != nil { - return nil, fmt.Errorf("failed to load users: %v", err) - } - - timeout, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout) - if err != nil { - return nil, fmt.Errorf("failed to parse manager connection timeout: %v", err) - } - - return &handler.AppConfig{ - Users: users, - AuthListFile: cfg.AuthService.Mounts.ContainerAuthListPath, - SessionKey: []byte(cfg.AuthService.Security.SessionSecret), - LoginPath: cfg.AuthService.ServiceURL.Login, - LogoutPath: cfg.AuthService.ServiceURL.Logout, - ServicePath: cfg.AuthService.ServiceURL.Service, - TerminalPath: cfg.AuthService.ServiceURL.Terminal, - SignupPath: cfg.AuthService.ServiceURL.Signup, - UserContainerNamePrefix: cfg.UserService.Container.NamePrefix, - TrustedProxies: trustProxiesToSlice(cfg.AuthService.Security.TrustedProxies), - ManagerBaseURL: fmt.Sprintf("http://%s:5959", cfg.ManagerService.Container.Name), - ManagerTimeout: timeout, - ManagerSessionSecret: cfg.ManagerService.Security.SessionSecret, - AllowSignup: cfg.AuthService.AllowSignup, - }, nil -} - -// trustProxiesToSlice parses a comma-separated trusted proxy list into CIDR strings. -func trustProxiesToSlice(trustedProxies string) []string { - var trustedProxyCIDRs []string - - if tp := trustedProxies; tp != "" { - for _, cidr := range strings.Split(tp, ",") { - cidr = strings.TrimSpace(cidr) - if cidr != "" { - trustedProxyCIDRs = append(trustedProxyCIDRs, cidr) - } - } - } - - return trustedProxyCIDRs } diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index fa332c9..e92f87c 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -6,10 +6,12 @@ import ( "os" "path/filepath" + "github.com/elecbug/linuxus/src/internal/auth" "github.com/elecbug/linuxus/src/internal/common/config" "github.com/elecbug/linuxus/src/internal/ctl/app" "github.com/elecbug/linuxus/src/internal/ctl/cli" "github.com/elecbug/linuxus/src/internal/ctl/log" + "github.com/elecbug/linuxus/src/internal/manager" ) // main executes the CLI entrypoint and prints user-friendly errors. @@ -43,9 +45,14 @@ type Options struct { // run initializes the application and executes selected runtime operations. func run() error { - currentDir, err := os.Getwd() - if err != nil { - return err + // Containers run the same binary without host-side configuration files. + if len(os.Args) == 2 { + switch os.Args[1] { + case "serve-auth": + return auth.Run() + case "serve-manager": + return manager.Run() + } } execPath, err := os.Executable() @@ -58,9 +65,8 @@ func run() error { return err } - repoDir := filepath.Dir(execPath) - sourceDir := filepath.Join(repoDir, "src") - configFile := filepath.Join(repoDir, "cfg", "config.yml") + runtimeRoot := filepath.Dir(execPath) + configFile := filepath.Join(runtimeRoot, "cfg", "config.yml") opt, err := parseArgs(os.Args[0], os.Args[1:]) if err != nil { @@ -72,11 +78,13 @@ func run() error { return nil } - a, err := app.CreateApp(currentDir, execPath, repoDir, sourceDir, configFile) + a, err := app.CreateApp(execPath, runtimeRoot, configFile) if err != nil { return err } + defer a.Close() + if err := a.LoadConfig(); err != nil { return err } diff --git a/src/cmd/manager/main.go b/src/cmd/manager/main.go index 90104a6..e370c17 100644 --- a/src/cmd/manager/main.go +++ b/src/cmd/manager/main.go @@ -1,143 +1,13 @@ package main import ( - "encoding/json" - "fmt" "log" - "os" - "strconv" - "time" - ctl_config "github.com/elecbug/linuxus/src/internal/common/config" - "github.com/elecbug/linuxus/src/internal/common/convert" - "github.com/elecbug/linuxus/src/internal/manager/config" - "github.com/elecbug/linuxus/src/internal/manager/handler" + "github.com/elecbug/linuxus/src/internal/manager" ) -// main loads environment configuration and starts the manager server. func main() { - cfg, err := parseConfigFromEnv() - if err != nil { - log.Fatalf("failed to parse config: %v", err) + if err := manager.Run(); err != nil { + log.Fatal(err) } - - s, err := handler.NewServer(cfg) - if err != nil { - log.Fatalf("failed to create server: %v", err) - } - - s.RegisterRoutes() - s.Start() -} - -// parseConfigFromEnv reads required and optional manager settings from environment variables. -func parseConfigFromEnv() (*config.Config, error) { - var err error - - env := os.Getenv("ENV") - if env == "" { - return nil, fmt.Errorf("ENV environment variable is required") - } - - var cfg ctl_config.Config - - err = json.Unmarshal([]byte(env), &cfg) - if err != nil { - return nil, fmt.Errorf("failed to parse ENV variable as JSON: %v", err) - } - - userImage := os.Getenv("USER_IMAGE") - if userImage == "" { - return nil, fmt.Errorf("USER_IMAGE environment variable is required") - } - - managerWaitTime, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout) - if err != nil { - return nil, fmt.Errorf("invalid ManagerService.AuthService.ConnectionTimeout: %v", err) - } - - containerTimeout, err := time.ParseDuration(cfg.ManagerService.UserManagement.CleanupTimeout) - if err != nil { - return nil, fmt.Errorf("invalid ManagerService.UserManagement.CleanupTimeout: %v", err) - } - - userCPUStr := fmt.Sprintf("%v", cfg.UserService.Limits.User.CPU) - userCPU, err := convert.NanoCPUsFromString(userCPUStr) - if err != nil { - return nil, fmt.Errorf("invalid UserService.Limits.User.CPU: %v", err) - } - - adminCPUStr := fmt.Sprintf("%v", cfg.UserService.Limits.Admin.CPU) - adminCPU, err := convert.NanoCPUsFromString(adminCPUStr) - if err != nil { - return nil, fmt.Errorf("invalid UserService.Limits.Admin.CPU: %v", err) - } - - userMemBytes, err := convert.BytesFromString(cfg.UserService.Limits.User.Memory) - if err != nil { - return nil, fmt.Errorf("invalid UserService.Limits.User.Memory: %v", err) - } - - adminMemBytes, err := convert.BytesFromString(cfg.UserService.Limits.Admin.Memory) - if err != nil { - return nil, fmt.Errorf("invalid UserService.Limits.Admin.Memory: %v", err) - } - - return &config.Config{ - ListenAddr: ":5959", - UserImage: userImage, - UserContainerNamePrefix: cfg.UserService.Container.NamePrefix, - NetworkPrefix: cfg.UserService.Container.NetworkNamePrefix, - BaseIP: cfg.UserService.Container.BaseSubnet16, - AuthContainerName: cfg.AuthService.Container.Name, - AdminUserID: cfg.ManagerService.AdminID, - ManagerSessionSecret: cfg.ManagerService.Security.SessionSecret, - - RuntimeUser: fmt.Sprintf("%d:%d", cfg.UserService.Runtime.UID, cfg.UserService.Runtime.GID), - ContainerRuntimeUser: cfg.UserService.Runtime.LinuxUsername, - ContainerHostname: cfg.UserService.Runtime.LinuxHostname, - WorkingDir: "/home/" + cfg.UserService.Runtime.LinuxUsername, - Timezone: cfg.UserService.Runtime.Timezone, - ReadOnlyRootFS: true, - ManagerWaitTime: managerWaitTime, - ContainerTimeout: containerTimeout, - - HostHomesDir: cfg.Volumes.Host.Homes, - HostShareDir: cfg.Volumes.Host.Share, - HostReadonlyDir: cfg.Volumes.Host.Readonly, - ContainerShareDir: cfg.Volumes.Container.Share, - ContainerReadonlyDir: cfg.Volumes.Container.Readonly, - - UserLimits: config.ResourceLimits{ - NanoCPUs: userCPU, - MemoryBytes: userMemBytes, - PidsLimit: int64(cfg.UserService.Limits.User.PID), - NofileSoft: int64(cfg.UserService.Limits.User.Ulimits.Nofile.Soft), - NofileHard: int64(cfg.UserService.Limits.User.Ulimits.Nofile.Hard), - }, - AdminLimits: config.ResourceLimits{ - NanoCPUs: adminCPU, - MemoryBytes: adminMemBytes, - PidsLimit: int64(cfg.UserService.Limits.Admin.PID), - NofileSoft: int64(cfg.UserService.Limits.Admin.Ulimits.Nofile.Soft), - NofileHard: int64(cfg.UserService.Limits.Admin.Ulimits.Nofile.Hard), - }, - - ShareDiskLimit: cfg.Volumes.DiskLimit, - UserDiskLimit: cfg.UserService.Limits.User.Disk, - AdminDiskLimit: cfg.UserService.Limits.Admin.Disk, - }, nil -} - -// envInt64 parses an int64 from an environment variable, returning zero if unset and an error if invalid. -func envInt64(key string) (int64, error) { - s := os.Getenv(key) - if s == "" { - return 0, nil - } - v, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid %s: %w", key, err) - } - return v, nil } diff --git a/src/docker/assets.go b/src/docker/assets.go new file mode 100644 index 0000000..94f8767 --- /dev/null +++ b/src/docker/assets.go @@ -0,0 +1,9 @@ +// Package docker contains the Docker resources shipped with linuxusctl. +package docker + +import "embed" + +// Files contains only runtime image definitions and the user startup script. +// +//go:embed *.Dockerfile start.sh +var Files embed.FS diff --git a/src/docker/auth.Dockerfile b/src/docker/auth.Dockerfile index 141fe6e..3f2088b 100644 --- a/src/docker/auth.Dockerfile +++ b/src/docker/auth.Dockerfile @@ -1,26 +1,9 @@ -FROM golang:1.26 AS builder - -WORKDIR /app - -COPY go.mod ./ -COPY go.sum ./ -RUN go mod download - -COPY cmd ./cmd -COPY internal ./internal -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o auth-server cmd/auth/main.go - FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/* - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +RUN apt-get update && apt-get install -y --no-install-recommends tzdata ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app - -COPY --from=builder /app/auth-server /app/auth-server -COPY static ./static +COPY linuxusctl /app/linuxusctl EXPOSE 8080 - -CMD ["/app/auth-server"] \ No newline at end of file +CMD ["/app/linuxusctl", "serve-auth"] diff --git a/src/docker/manager.Dockerfile b/src/docker/manager.Dockerfile index 2dbd83d..8888a1c 100644 --- a/src/docker/manager.Dockerfile +++ b/src/docker/manager.Dockerfile @@ -1,21 +1,9 @@ -FROM golang:1.26 AS builder +FROM debian:bookworm-slim -WORKDIR /app - -COPY go.mod ./ -COPY go.sum ./ - -RUN go mod download - -COPY cmd ./cmd -COPY internal ./internal - -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o linuxus-manager cmd/manager/main.go - -FROM ubuntu:22.04 +RUN apt-get update && apt-get install -y --no-install-recommends tzdata ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app +COPY linuxusctl /app/linuxusctl -COPY --from=builder /app/linuxus-manager /app/linuxus-manager - -CMD ["/app/linuxus-manager"] \ No newline at end of file +EXPOSE 5959 +CMD ["/app/linuxusctl", "serve-manager"] diff --git a/src/docker/user.Dockerfile b/src/docker/user.Dockerfile index 5fdbdb7..af681ac 100644 --- a/src/docker/user.Dockerfile +++ b/src/docker/user.Dockerfile @@ -12,17 +12,17 @@ RUN rm -rf /var/lib/apt/lists/* RUN locale-gen en_US.UTF-8 ARG CONTAINER_RUNTIME_USER=linuxus +ARG CONTAINER_UID=1000 +ARG CONTAINER_GID=1000 ENV LANG=en_US.UTF-8 ENV LANGUAGE=en_US:en ENV LC_ALL=en_US.UTF-8 -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - # Create a fixed runtime user at build time -RUN groupadd -g 1000 $CONTAINER_RUNTIME_USER && useradd -m -u 1000 -g 1000 -s /bin/bash $CONTAINER_RUNTIME_USER +RUN groupadd -g "$CONTAINER_GID" "$CONTAINER_RUNTIME_USER" && useradd -m -u "$CONTAINER_UID" -g "$CONTAINER_GID" -s /bin/bash "$CONTAINER_RUNTIME_USER" -COPY docker/start.sh /start.sh +COPY start.sh /start.sh RUN chmod +x /start.sh CMD ["/start.sh"] \ No newline at end of file diff --git a/src/internal/auth/handler/app.go b/src/internal/auth/handler/app.go index a824840..537fb78 100644 --- a/src/internal/auth/handler/app.go +++ b/src/internal/auth/handler/app.go @@ -18,6 +18,8 @@ import ( type App struct { // users maps user IDs to bcrypt password hashes. users map[string]string + // usersMu protects credential reloads, lookups and signup writes. + usersMu sync.Mutex // authListFile is the path to the auth list file in container. authListFile string // sessionKey is used to sign session cookie payloads. diff --git a/src/internal/auth/handler/assets_test.go b/src/internal/auth/handler/assets_test.go new file mode 100644 index 0000000..722a13c --- /dev/null +++ b/src/internal/auth/handler/assets_test.go @@ -0,0 +1,27 @@ +package handler + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/elecbug/linuxus/src/static" +) + +func TestStaticAssetsWithoutSourceDirectory(t *testing.T) { + t.Chdir(t.TempDir()) + a := &App{mux: http.NewServeMux()} + a.handleFavicon() + want, err := static.Files.ReadFile("favicon.png") + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/favicon.ico", "/static/favicon.png"} { + rr := httptest.NewRecorder() + a.mux.ServeHTTP(rr, httptest.NewRequest("GET", path, nil)) + if rr.Code != http.StatusOK || !bytes.Equal(rr.Body.Bytes(), want) { + t.Fatalf("asset %s not served: %d", path, rr.Code) + } + } +} diff --git a/src/internal/auth/handler/credentials_test.go b/src/internal/auth/handler/credentials_test.go new file mode 100644 index 0000000..9a23452 --- /dev/null +++ b/src/internal/auth/handler/credentials_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func TestConcurrentSignupAndLoginReloads(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := user.EnsureFile(path); err != nil { + t.Fatal(err) + } + a := NewApp(&AppConfig{ + Users: map[string]string{}, AuthListFile: path, SessionKey: []byte("test-secret"), + LoginPath: "login", LogoutPath: "logout", ServicePath: "service", TerminalPath: "terminal", SignupPath: "signup", AllowSignup: true, + }) + defer a.Stop() + a.RegisterRoutes() + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + form := url.Values{"id": {fmt.Sprintf("user%d", i)}, "password": {"password"}, "confirm_password": {"password"}} + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + a.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusSeeOther { + t.Errorf("signup failed: %d", rr.Code) + } + }(i) + go func() { + defer wg.Done() + for j := 0; j < 4; j++ { + form := url.Values{"id": {"unknown"}, "password": {"wrong"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + a.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("login failed: %d", rr.Code) + } + } + }() + } + wg.Wait() + loaded, err := user.LoadUsers(path) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 4 { + t.Fatalf("lost signups: %d", len(loaded)) + } +} diff --git a/src/internal/auth/handler/handle_login.go b/src/internal/auth/handler/handle_login.go index 88cb6c3..2e31c2c 100644 --- a/src/internal/auth/handler/handle_login.go +++ b/src/internal/auth/handler/handle_login.go @@ -20,7 +20,9 @@ var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17 // handleLogin serves the login page and processes login submissions. func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { + a.usersMu.Lock() err := user.SyncUsers(a.users, a.authListFile) + a.usersMu.Unlock() if err != nil { a.renderError(w, "Failed to load user data", http.StatusInternalServerError) return @@ -49,7 +51,9 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { } // 2) Look up user + a.usersMu.Lock() hash, ok := a.users[id] + a.usersMu.Unlock() if !ok { hash = string(dummyHash) } diff --git a/src/internal/auth/handler/handle_root.go b/src/internal/auth/handler/handle_root.go index 9837e9e..d0da6cb 100644 --- a/src/internal/auth/handler/handle_root.go +++ b/src/internal/auth/handler/handle_root.go @@ -1,10 +1,11 @@ package handler import ( - "log" + "bytes" "net/http" - "os" - "path/filepath" + "time" + + "github.com/elecbug/linuxus/src/static" ) // handleRoot redirects authenticated users to service and others to login. @@ -16,17 +17,11 @@ func (a *App) handleRoot(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/"+a.loginPath, http.StatusSeeOther) } -// handleFavicon serves a blank 1x1 pixel favicon to prevent 404 errors in logs. +// handleFavicon serves embedded assets, independent of the working directory. func (a *App) handleFavicon() { - staticDir := "static" - if exe, err := os.Executable(); err == nil { - staticDir = filepath.Join(filepath.Dir(exe), "static") - } else { - log.Printf("warning: could not determine executable path, serving static from relative path: %v", err) - } - - a.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) + a.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files)))) a.mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, filepath.Join(staticDir, "favicon.png")) + data, _ := static.Files.ReadFile("favicon.png") + http.ServeContent(w, r, "favicon.png", time.Time{}, bytes.NewReader(data)) }) } diff --git a/src/internal/auth/handler/handle_signup.go b/src/internal/auth/handler/handle_signup.go index 45eca8b..9308c77 100644 --- a/src/internal/auth/handler/handle_signup.go +++ b/src/internal/auth/handler/handle_signup.go @@ -12,6 +12,8 @@ import ( // handleSignup processes GET and POST requests to the signup endpoint for user registration. func (a *App) handleSignup(w http.ResponseWriter, r *http.Request) { + a.usersMu.Lock() + defer a.usersMu.Unlock() err := user.SyncUsers(a.users, a.authListFile) if err != nil { a.renderError(w, "Failed to load user data", http.StatusInternalServerError) diff --git a/src/internal/auth/server.go b/src/internal/auth/server.go new file mode 100644 index 0000000..1c49a6d --- /dev/null +++ b/src/internal/auth/server.go @@ -0,0 +1,87 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/elecbug/linuxus/src/internal/auth/handler" + "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/user" +) + +// Run loads configuration, registers routes, and starts the auth server. +func Run() error { + config, err := parseConfig() + if err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + app := handler.NewApp(config) + defer app.Stop() + app.RegisterRoutes() + + return app.Start(":8080") +} + +// parseConfig loads all runtime settings from environment variables and auth list data. +func parseConfig() (*handler.AppConfig, error) { + var err error + + env := os.Getenv("ENV") + if env == "" { + return nil, fmt.Errorf("ENV environment variable is required") + } + + var cfg config.Config + + err = json.Unmarshal([]byte(env), &cfg) + if err != nil { + return nil, fmt.Errorf("failed to parse ENV variable as JSON: %v", err) + } + + users, err := user.LoadUsers(cfg.AuthService.Mounts.ContainerAuthListPath) + if err != nil { + return nil, fmt.Errorf("failed to load users: %v", err) + } + + timeout, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout) + if err != nil { + return nil, fmt.Errorf("failed to parse manager connection timeout: %v", err) + } + + return &handler.AppConfig{ + Users: users, + AuthListFile: cfg.AuthService.Mounts.ContainerAuthListPath, + SessionKey: []byte(cfg.AuthService.Security.SessionSecret), + LoginPath: cfg.AuthService.ServiceURL.Login, + LogoutPath: cfg.AuthService.ServiceURL.Logout, + ServicePath: cfg.AuthService.ServiceURL.Service, + TerminalPath: cfg.AuthService.ServiceURL.Terminal, + SignupPath: cfg.AuthService.ServiceURL.Signup, + UserContainerNamePrefix: cfg.UserService.Container.NamePrefix, + TrustedProxies: trustProxiesToSlice(cfg.AuthService.Security.TrustedProxies), + ManagerBaseURL: fmt.Sprintf("http://%s:5959", cfg.ManagerService.Container.Name), + ManagerTimeout: timeout, + ManagerSessionSecret: cfg.ManagerService.Security.SessionSecret, + AllowSignup: cfg.AuthService.AllowSignup, + }, nil +} + +// trustProxiesToSlice parses a comma-separated trusted proxy list into CIDR strings. +func trustProxiesToSlice(trustedProxies string) []string { + var trustedProxyCIDRs []string + + if tp := trustedProxies; tp != "" { + for _, cidr := range strings.Split(tp, ",") { + cidr = strings.TrimSpace(cidr) + if cidr != "" { + trustedProxyCIDRs = append(trustedProxyCIDRs, cidr) + } + } + } + + return trustedProxyCIDRs +} diff --git a/src/internal/common/config/validate.go b/src/internal/common/config/validate.go index b3c3cc8..8f2b50b 100644 --- a/src/internal/common/config/validate.go +++ b/src/internal/common/config/validate.go @@ -33,12 +33,12 @@ func ValidateConfig(cfg *Config) error { errMsgs = append(errMsgs, "user_service.container.base_subnet_16 must be a valid /16 subnet (x.x.0.0)") } - if cfg.UserService.Runtime.UID == 0 { - errMsgs = append(errMsgs, "user_service.runtime.uid is required and must be non-zero") + if cfg.UserService.Runtime.UID <= 0 { + errMsgs = append(errMsgs, "user_service.runtime.uid must be greater than zero") } - if cfg.UserService.Runtime.GID == 0 { - errMsgs = append(errMsgs, "user_service.runtime.gid is required and must be non-zero") + if cfg.UserService.Runtime.GID <= 0 { + errMsgs = append(errMsgs, "user_service.runtime.gid must be greater than zero") } if cfg.UserService.Runtime.LinuxUsername == "" { @@ -244,21 +244,13 @@ func ValidateConfig(cfg *Config) error { return nil } -// UsablePath checks if the given path is usable (exists or can be created). +// UsablePath validates a path without creating or removing directories. func UsablePath(path string) error { - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - if err := os.MkdirAll(path, 0755); err != nil { - return fmt.Errorf("path does not exist and cannot be created: %s", path) - } else { - // Clean up the created directory if it was just for validation - defer os.RemoveAll(path) - } - return nil - } - return err + _, err := os.Stat(path) + if os.IsNotExist(err) { + return nil } - return nil + return err } // validateLimits checks if at least one limit value is non-zero. @@ -301,7 +293,7 @@ func validateLimits(l Limits) error { } if len(errMsgs) > 0 { - return fmt.Errorf(strings.Join(errMsgs, "; ")) + return fmt.Errorf("%s", strings.Join(errMsgs, "; ")) } return nil diff --git a/src/internal/common/config/validate_test.go b/src/internal/common/config/validate_test.go new file mode 100644 index 0000000..c5ec9ad --- /dev/null +++ b/src/internal/common/config/validate_test.go @@ -0,0 +1,21 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUsablePathDoesNotCreateParentDirectories(t *testing.T) { + root := t.TempDir() + if err := UsablePath(filepath.Join(root, "data", "AUTH_LIST")); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("validation created files: %v", entries) + } +} diff --git a/src/internal/common/convert/convert.go b/src/internal/common/convert/convert.go index 56768d5..ad377e9 100644 --- a/src/internal/common/convert/convert.go +++ b/src/internal/common/convert/convert.go @@ -2,7 +2,6 @@ package convert import ( "fmt" - "path/filepath" "strconv" "strings" @@ -46,21 +45,6 @@ func FormatUserName(containerNamePrefix, authContainerName, managerContainerName return "-" } -// PathToAbs resolves a path relative to the configured source directory. -func PathToAbs(path string) string { - if path == "" { - return path - } - if filepath.IsAbs(path) { - return path - } - absPath, err := filepath.Abs(path) - if err != nil { - return path - } - return filepath.Clean(absPath) -} - // NanoCPUsFromString converts a CPU value to Docker NanoCPUs. func NanoCPUsFromString(v string) (int64, error) { f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) diff --git a/src/internal/common/system_api/linux.go b/src/internal/common/system_api/linux.go index d6d4c09..dd960ac 100644 --- a/src/internal/common/system_api/linux.go +++ b/src/internal/common/system_api/linux.go @@ -1,6 +1,7 @@ package system_api import ( + "encoding/json" "fmt" "io" "os" @@ -190,50 +191,55 @@ func (LinuxSystemAPI) Unmount(target string) error { return nil } -// FindLoopDevicesForImages returns a list of loop devices currently attached to image files under the specified directory. -func (LinuxSystemAPI) FindLoopDevicesForImages(dir string) ([]string, error) { - out, err := exec.Command("losetup", "-a").Output() +// FindLoopDevicesForImages accepts either an exact image path or a directory. +func (LinuxSystemAPI) FindLoopDevicesForImages(path string) ([]string, error) { + out, err := exec.Command("losetup", "--json", "--output", "NAME,BACK-FILE").Output() if err != nil { - return nil, fmt.Errorf("losetup -a failed: %w", err) + return nil, fmt.Errorf("list loop devices: %w", err) } + return loopDevicesForPath(out, path) +} - absDir, err := filepath.Abs(dir) +func loopDevicesForPath(data []byte, path string) ([]string, error) { + absPath, err := canonicalLoopPath(path) if err != nil { return nil, err } - + var listing struct { + Devices []struct { + Name string `json:"name"` + BackFile string `json:"back-file"` + } `json:"loopdevices"` + } + if err := json.Unmarshal(data, &listing); err != nil { + return nil, fmt.Errorf("parse loop devices: %w", err) + } var devices []string - - lines := strings.Split(string(out), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - continue - } - - dev := strings.TrimSpace(parts[0]) - - start := strings.LastIndex(line, "(") - end := strings.LastIndex(line, ")") - if start == -1 || end == -1 || end <= start+1 { + for _, dev := range listing.Devices { + if dev.BackFile == "" || dev.Name == "" { continue } - - imgPath := line[start+1 : end] - absImgPath, err := filepath.Abs(imgPath) + image, err := canonicalLoopPath(dev.BackFile) if err != nil { - continue + return nil, err } - - if strings.HasPrefix(absImgPath, absDir+string(os.PathSeparator)) { - devices = append(devices, dev) + if image == absPath || strings.HasPrefix(image, absPath+string(os.PathSeparator)) { + devices = append(devices, dev.Name) } } - return devices, nil } + +// losetup reports canonical backing paths, including when the deployment uses +// a volumes symlink to storage outside the deployment directory. +func canonicalLoopPath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(abs) + if os.IsNotExist(err) { + return abs, nil + } + return resolved, err +} diff --git a/src/internal/common/system_api/linux_test.go b/src/internal/common/system_api/linux_test.go new file mode 100644 index 0000000..e524d3f --- /dev/null +++ b/src/internal/common/system_api/linux_test.go @@ -0,0 +1,61 @@ +package system_api + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestLoopDevicesMatchExactImageOrDirectory(t *testing.T) { + data := []byte(`{"loopdevices":[ + {"name":"/dev/loop1","back-file":"/volumes/homes/alice.img"}, + {"name":"/dev/loop2","back-file":"/volumes/homes/bob.img"}, + {"name":"/dev/loop3","back-file":"/volumes/homes-other/alice.img"}, + {"name":"/dev/loop4","back-file":"/volumes/disk (copy).img"} + ]}`) + for _, tc := range []struct { + path string + want []string + }{ + {"/volumes/homes/alice.img", []string{"/dev/loop1"}}, + {"/volumes/homes", []string{"/dev/loop1", "/dev/loop2"}}, + {"/volumes/disk (copy).img", []string{"/dev/loop4"}}, + {"/volumes/homes/alice", nil}, + } { + got, err := loopDevicesForPath(data, tc.path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("%s: %v, want %v", tc.path, got, tc.want) + } + } +} + +func TestLoopDevicesResolveVolumeSymlink(t *testing.T) { + root := t.TempDir() + storage := t.TempDir() + image := filepath.Join(storage, "alice.img") + if err := os.WriteFile(image, nil, 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "volumes") + if err := os.Symlink(storage, alias); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]any{"loopdevices": []map[string]string{{"name": "/dev/loop9", "back-file": image}}}) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{alias, filepath.Join(alias, "alice.img")} { + got, err := loopDevicesForPath(data, path) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, []string{"/dev/loop9"}) { + t.Fatalf("symlink %s: %v", path, got) + } + } +} diff --git a/src/internal/common/user/user.go b/src/internal/common/user/user.go index aacd274..cb94479 100644 --- a/src/internal/common/user/user.go +++ b/src/internal/common/user/user.go @@ -4,9 +4,12 @@ import ( "bufio" "fmt" "os" + "path/filepath" "strings" "golang.org/x/crypto/bcrypt" + + "github.com/elecbug/linuxus/src/internal/common/ruleset" ) // LoadUsers reads user credentials from the auth list file. @@ -34,7 +37,7 @@ func LoadUsers(path string) (map[string]string, error) { id := strings.TrimSpace(parts[0]) hash := strings.TrimSpace(parts[1]) - if id == "" || hash == "" { + if !ruleset.AllowedUserID(id) || hash == "" { return nil, fmt.Errorf("invalid line in auths file: %s", line) } @@ -54,7 +57,7 @@ func AddUser(path string, users map[string]string, id, password string) error { return fmt.Errorf("user '%s' already exists", id) } - if id == "" || password == "" { + if !ruleset.AllowedUserID(id) || password == "" { return fmt.Errorf("invalid user ID or password") } @@ -63,17 +66,33 @@ func AddUser(path string, users map[string]string, id, password string) error { return fmt.Errorf("failed to hash password: %v", err) } - users[id] = string(hash) - - file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600) + if err := EnsureFile(path); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_APPEND|os.O_RDWR, 0600) if err != nil { return fmt.Errorf("failed to open auth file: %v", err) } defer file.Close() - if _, err := file.WriteString(fmt.Sprintf("%s:%s\n", id, string(hash))); err != nil { + prefix := "" + info, err := file.Stat() + if err != nil { + return err + } + if info.Size() > 0 { + last := make([]byte, 1) + if _, err := file.ReadAt(last, info.Size()-1); err != nil { + return err + } + if last[0] != '\n' { + prefix = "\n" + } + } + if _, err := file.WriteString(fmt.Sprintf("%s%s:%s\n", prefix, id, string(hash))); err != nil { return fmt.Errorf("failed to write to auth file: %v", err) } + users[id] = string(hash) return nil } @@ -84,8 +103,6 @@ func RemoveUser(path string, users map[string]string, id string) error { return fmt.Errorf("user '%s' does not exist", id) } - delete(users, id) - file, err := os.OpenFile(path, os.O_RDWR, 0600) if err != nil { return fmt.Errorf("failed to open auth file: %v", err) @@ -135,6 +152,7 @@ func RemoveUser(path string, users map[string]string, id string) error { if err := writer.Flush(); err != nil { return fmt.Errorf("failed to flush auth file: %v", err) } + delete(users, id) return nil } @@ -146,16 +164,9 @@ func SyncUsers(users map[string]string, authListPath string) error { return fmt.Errorf("failed to sync users: %v", err) } - if len(loadedUsers) > len(users) { - for id, hash := range loadedUsers { - users[id] = hash - } - } else if len(loadedUsers) < len(users) { - for id := range users { - if _, exists := loadedUsers[id]; !exists { - delete(users, id) - } - } + clear(users) + for id, hash := range loadedUsers { + users[id] = hash } return nil @@ -166,3 +177,18 @@ func ExistsUser(users map[string]string, id string) bool { _, ok := users[id] return ok } + +// EnsureFile initializes a new deployment without truncating existing credentials. +func EnsureFile(path string) error { + if path == "" { + return fmt.Errorf("auth list path is empty") + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("create auth directory: %w", err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("initialize auth list: %w", err) + } + return f.Close() +} diff --git a/src/internal/common/user/user_test.go b/src/internal/common/user/user_test.go new file mode 100644 index 0000000..055a6a6 --- /dev/null +++ b/src/internal/common/user/user_test.go @@ -0,0 +1,78 @@ +package user + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestSyncUsersReplacesChangedCredentialsWithSameCount(t *testing.T) { + p := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(p, []byte("alice:new-hash\ncarol:carol-hash\n"), 0600); err != nil { + t.Fatal(err) + } + users := map[string]string{"alice": "old-hash", "bob": "bob-hash"} + if err := SyncUsers(users, p); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(users, map[string]string{"alice": "new-hash", "carol": "carol-hash"}) { + t.Fatal(users) + } +} + +func TestInitializeAuthFileAndAppendWithoutTrailingNewline(t *testing.T) { + p := filepath.Join(t.TempDir(), "data", "AUTH_LIST") + if err := EnsureFile(p); err != nil { + t.Fatal(err) + } + info, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("mode=%v", info.Mode()) + } + if err := os.WriteFile(p, []byte("alice:existing-hash"), 0600); err != nil { + t.Fatal(err) + } + if err := EnsureFile(p); err != nil { + t.Fatal(err) + } + users, err := LoadUsers(p) + if err != nil { + t.Fatal(err) + } + if err := AddUser(p, users, "bob", "password"); err != nil { + t.Fatal(err) + } + loaded, err := LoadUsers(p) + if err != nil { + t.Fatal(err) + } + if loaded["alice"] != "existing-hash" || len(loaded) != 2 { + t.Fatal("existing credentials were lost") + } + if err := bcrypt.CompareHashAndPassword([]byte(loaded["bob"]), []byte("password")); err != nil { + t.Fatal(err) + } +} + +func TestFailedCredentialWritesLeaveMemoryUnchanged(t *testing.T) { + p := t.TempDir() // A directory cannot be opened as an auth file. + users := map[string]string{"alice": "hash"} + if err := AddUser(p, users, "bob", "password"); err == nil { + t.Fatal("expected write failure") + } + if err := RemoveUser(p, users, "alice"); err == nil { + t.Fatal("expected write failure") + } + if !reflect.DeepEqual(users, map[string]string{"alice": "hash"}) { + t.Fatal("memory diverged from file") + } + if err := AddUser(filepath.Join(p, "AUTH_LIST"), users, "../outside", "password"); err == nil { + t.Fatal("accepted path traversal ID") + } +} diff --git a/src/internal/ctl/app/app.go b/src/internal/ctl/app/app.go index 193066c..303b293 100644 --- a/src/internal/ctl/app/app.go +++ b/src/internal/ctl/app/app.go @@ -18,14 +18,10 @@ type App struct { // systemAPI abstracts OS-specific operations for better testability and error handling. systemAPI system_api.API - // currentDir is the directory where the CLI command was executed. - currentDir string // execPath is the absolute executable path for the running binary. execPath string - // repoDir is the repository root resolved from the executable location. - repoDir string - // sourceDir points to the repository source directory. - sourceDir string + // runtimeRoot is the deployment directory containing linuxusctl. + runtimeRoot string // configFile points to the runtime configuration file. configFile string @@ -33,12 +29,10 @@ type App struct { Config config.Config // UserIDs stores raw user IDs parsed from auth list. UserIDs map[string]string - // seen tracks deduplication of user IDs while parsing auth data. - seen map[string]struct{} } // CreateApp creates an App instance and initializes the Docker client. -func CreateApp(currentDir, execDir, repoDir, sourceDir, configFile string) (*App, error) { +func CreateApp(execPath, runtimeRoot, configFile string) (*App, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("failed to create Docker client: %w", err) @@ -48,14 +42,16 @@ func CreateApp(currentDir, execDir, repoDir, sourceDir, configFile string) (*App dockerClient: cli, context: context.Background(), systemAPI: system_api.NewSystemAPI(), - currentDir: currentDir, - execPath: execDir, - repoDir: repoDir, - sourceDir: sourceDir, + execPath: execPath, + runtimeRoot: runtimeRoot, configFile: configFile, - seen: make(map[string]struct{}), UserIDs: nil, } return app, nil } + +// Close releases the Docker client resources. +func (a *App) Close() error { + return a.dockerClient.Close() +} diff --git a/src/internal/ctl/app/config.go b/src/internal/ctl/app/config.go index 0758094..4dcaf28 100644 --- a/src/internal/ctl/app/config.go +++ b/src/internal/ctl/app/config.go @@ -3,8 +3,8 @@ package app import ( "fmt" "os" + "path/filepath" - "github.com/elecbug/linuxus/src/internal/common/convert" "github.com/elecbug/linuxus/src/internal/common/user" "gopkg.in/yaml.v3" ) @@ -22,6 +22,10 @@ func (a *App) LoadConfig() error { a.normalizeConfigPaths() a.UserIDs, err = user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + if os.IsNotExist(err) { + a.UserIDs = make(map[string]string) + return nil + } if err != nil { return fmt.Errorf("failed to load user IDs from auth list: %w", err) } @@ -29,12 +33,17 @@ func (a *App) LoadConfig() error { return nil } -// normalizeConfigPaths resolves source-relative paths to absolute paths. +// normalizeConfigPaths resolves host paths relative to the deployment directory. func (a *App) normalizeConfigPaths() { - a.Config.AuthService.Mounts.HostAuthListPath = convert.PathToAbs(a.Config.AuthService.Mounts.HostAuthListPath) - - a.Config.Volumes.Host.Homes = convert.PathToAbs(a.Config.Volumes.Host.Homes) - a.Config.Volumes.Host.Share = convert.PathToAbs(a.Config.Volumes.Host.Share) - a.Config.Volumes.Host.Readonly = convert.PathToAbs(a.Config.Volumes.Host.Readonly) - a.Config.Volumes.Host.Volumes = convert.PathToAbs(a.Config.Volumes.Host.Volumes) + resolve := func(path string) string { + if path == "" || filepath.IsAbs(path) { + return path + } + return filepath.Join(a.runtimeRoot, path) + } + a.Config.AuthService.Mounts.HostAuthListPath = resolve(a.Config.AuthService.Mounts.HostAuthListPath) + a.Config.Volumes.Host.Homes = resolve(a.Config.Volumes.Host.Homes) + a.Config.Volumes.Host.Share = resolve(a.Config.Volumes.Host.Share) + a.Config.Volumes.Host.Readonly = resolve(a.Config.Volumes.Host.Readonly) + a.Config.Volumes.Host.Volumes = resolve(a.Config.Volumes.Host.Volumes) } diff --git a/src/internal/ctl/app/disk.go b/src/internal/ctl/app/disk.go index fde3ed6..5a3edcc 100644 --- a/src/internal/ctl/app/disk.go +++ b/src/internal/ctl/app/disk.go @@ -2,13 +2,13 @@ package app import ( "fmt" - "io/fs" "os" "path/filepath" "sort" "strings" "github.com/elecbug/linuxus/src/internal/common/convert" + "github.com/elecbug/linuxus/src/internal/common/ruleset" "github.com/elecbug/linuxus/src/internal/common/user" "github.com/elecbug/linuxus/src/internal/ctl/log" ) @@ -42,16 +42,14 @@ func (a *App) cleanVolumesAll() error { for _, dir := range homeMounts { err = a.umountDisk(dir) if err != nil { - log.Log(log.ERROR_PREFIX, "Failed to unmount home disk at %s: %v", dir, err) - continue + return fmt.Errorf("unmount home disk %s: %w", dir, err) } } for _, mountPoint := range []string{a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly} { err = a.umountDisk(mountPoint) if err != nil { - log.Log(log.ERROR_PREFIX, "Failed to unmount shared disk at %s: %v", mountPoint, err) - continue + return fmt.Errorf("unmount shared disk %s: %w", mountPoint, err) } } @@ -69,7 +67,7 @@ func (a *App) cleanVolumesAll() error { } } for _, mountPoint := range []string{a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly} { - devs, err := a.findLoopDevicesForImages(filepath.Dir(mountPoint)) + devs, err := a.findLoopDevicesForImages(mountPoint + ".img") if err != nil { return err } @@ -85,8 +83,7 @@ func (a *App) cleanVolumesAll() error { log.Log(log.DETAIL_PREFIX, "Detaching loop device: %s", dev) err = a.detachLoopDevice(dev) if err != nil { - log.Log(log.ERROR_PREFIX, "Failed to detach loop device %s: %v", dev, err) - continue + return fmt.Errorf("detach loop device %s: %w", dev, err) } } @@ -99,8 +96,10 @@ func (a *App) cleanVolumesAll() error { if err := a.systemAPI.RemoveAll(a.Config.Volumes.Host.Readonly); err != nil { return fmt.Errorf("failed to remove readonly dir: %w", err) } - if err := a.systemAPI.RemoveAll(a.Config.Volumes.Host.Volumes); err != nil { - return fmt.Errorf("failed to remove volumes dir: %w", err) + for _, mountPoint := range []string{a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly} { + if err := a.systemAPI.Remove(mountPoint + ".img"); err != nil { + return fmt.Errorf("remove shared disk image: %w", err) + } } log.Log(log.DETAIL_PREFIX, "Volume clean completed.") @@ -109,16 +108,19 @@ func (a *App) cleanVolumesAll() error { // cleanVolumeUser unmounts and removes the specified user's disk and home directory. func (a *App) cleanVolumeUser(userID string) error { + if !ruleset.AllowedUserID(userID) { + return fmt.Errorf("invalid user ID: %q", userID) + } log.Log(log.RUN_PREFIX, "Cleaning volume for user: %s...", userID) if err := a.umountDisk(filepath.Join(a.Config.Volumes.Host.Homes, userID)); err != nil { - log.Log(log.ERROR_PREFIX, "Failed to unmount home disk for user %s: %v", userID, err) + return fmt.Errorf("unmount home disk for %s: %w", userID, err) } userHome := filepath.Join(a.Config.Volumes.Host.Homes, userID) userImg := filepath.Join(a.Config.Volumes.Host.Homes, userID+".img") - homeDev, err := a.findLoopDevicesForImages(userHome) + homeDev, err := a.findLoopDevicesForImages(userImg) if err != nil { return fmt.Errorf("failed to find loop devices for user %s: %w", userID, err) } @@ -127,8 +129,7 @@ func (a *App) cleanVolumeUser(userID string) error { log.Log(log.DETAIL_PREFIX, "Detaching loop device: %s", dev) err = a.detachLoopDevice(dev) if err != nil { - log.Log(log.ERROR_PREFIX, "Failed to detach loop device %s: %v", dev, err) - continue + return fmt.Errorf("detach loop device %s: %w", dev, err) } } @@ -158,7 +159,7 @@ func (a *App) ensureDiskAll() error { return err } - for userID, _ := range a.UserIDs { + for userID := range a.UserIDs { if err := a.createUserDisk(userID, a.Config.ManagerService.AdminID == userID); err != nil { return err } @@ -274,6 +275,9 @@ func (a *App) createSharedDisk(path string) error { // createUserDisk creates and mounts a per-user loopback disk. func (a *App) createUserDisk(userID string, isAdmin bool) error { + if !ruleset.AllowedUserID(userID) { + return fmt.Errorf("invalid user ID: %q", userID) + } sizeStr := a.Config.UserService.Limits.User.Disk if isAdmin { sizeStr = a.Config.UserService.Limits.Admin.Disk @@ -368,22 +372,18 @@ func (a *App) listMountedDirsDeepestFirst(root string) ([]string, error) { err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { - return nil + return walkErr } - if path == root { - return nil - } - if !d.IsDir() { + if path == root || !d.IsDir() { return nil } mounted, err := a.systemAPI.IsMountPoint(path) if err != nil { - return nil + return err } if mounted { dirs = append(dirs, path) - return fs.SkipDir } return nil }) diff --git a/src/internal/ctl/app/disk_test.go b/src/internal/ctl/app/disk_test.go new file mode 100644 index 0000000..849777d --- /dev/null +++ b/src/internal/ctl/app/disk_test.go @@ -0,0 +1,82 @@ +package app + +import ( + "errors" + "reflect" + "testing" + + "github.com/elecbug/linuxus/src/internal/common/system_api" +) + +type cleanupAPI struct { + system_api.API + events []string + unmountErr, detachErr error +} + +func (f *cleanupAPI) IsMountPoint(p string) (bool, error) { return true, nil } +func (f *cleanupAPI) Unmount(p string) error { + f.events = append(f.events, "unmount:"+p) + return f.unmountErr +} +func (f *cleanupAPI) FindLoopDevicesForImages(p string) ([]string, error) { + f.events = append(f.events, "find:"+p) + return []string{"/dev/loop42"}, nil +} +func (f *cleanupAPI) DetachLoopDevice(p string) error { + f.events = append(f.events, "detach:"+p) + return f.detachErr +} +func (f *cleanupAPI) RemoveAll(p string) error { + f.events = append(f.events, "remove-dir:"+p) + return nil +} +func (f *cleanupAPI) Remove(p string) error { + f.events = append(f.events, "remove-file:"+p) + return nil +} + +func TestUserDiskCleanupStopsBeforeDeletingAttachedData(t *testing.T) { + for _, step := range []string{"unmount", "detach", "success"} { + t.Run(step, func(t *testing.T) { + api := &cleanupAPI{} + failure := errors.New("busy") + if step == "unmount" { + api.unmountErr = failure + } + if step == "detach" { + api.detachErr = failure + } + a := &App{systemAPI: api} + a.Config.Volumes.Host.Homes = "/deployment/volumes/homes" + err := a.cleanVolumeUser("alice") + want := []string{"unmount:/deployment/volumes/homes/alice"} + if step != "unmount" { + want = append(want, "find:/deployment/volumes/homes/alice.img", "detach:/dev/loop42") + } + if step == "success" { + want = append(want, "remove-dir:/deployment/volumes/homes/alice", "remove-file:/deployment/volumes/homes/alice.img") + if err != nil { + t.Fatal(err) + } + } else if !errors.Is(err, failure) { + t.Fatalf("lost cleanup failure: %v", err) + } + if !reflect.DeepEqual(api.events, want) { + t.Fatalf("operations=%v, want %v", api.events, want) + } + }) + } +} + +func TestDiskOperationsRejectInvalidUserPaths(t *testing.T) { + a := &App{} + for _, id := range []string{"", "..", "../outside", "/absolute", "a/b"} { + if err := a.cleanVolumeUser(id); err == nil { + t.Fatalf("accepted cleanup ID %q", id) + } + if err := a.createUserDisk(id, false); err == nil { + t.Fatalf("accepted disk ID %q", id) + } + } +} diff --git a/src/internal/ctl/app/docker_build.go b/src/internal/ctl/app/docker_build.go index f2bceb1..3f99f2c 100644 --- a/src/internal/ctl/app/docker_build.go +++ b/src/internal/ctl/app/docker_build.go @@ -6,67 +6,50 @@ import ( "fmt" "io" "os" - "path" - "path/filepath" + "runtime" + "strconv" "github.com/docker/docker/api/types/build" + assets "github.com/elecbug/linuxus/src/docker" "github.com/elecbug/linuxus/src/internal/ctl/log" ) -// buildRuntimeImages builds all runtime images required by services. +// buildRuntimeImages assembles runtime images from the executable and embedded assets. func (a *App) buildRuntimeImages() error { - log.Log(log.DETAIL_PREFIX, "Building runtime images...") - + if runtime.GOOS != "linux" { + return fmt.Errorf("runtime images require a Linux linuxusctl binary") + } if a.dockerClient == nil { return fmt.Errorf("Docker client is not initialized") } - - if err := a.buildImage(path.Join(a.sourceDir, "docker/auth.Dockerfile"), a.authImageName(), nil); err != nil { + log.Log(log.DETAIL_PREFIX, "Building runtime images...") + if err := a.buildImage("auth.Dockerfile", a.authImageName(), nil); err != nil { return fmt.Errorf("failed to build auth image: %w", err) } - - if err := a.buildImage(path.Join(a.sourceDir, "docker/manager.Dockerfile"), a.managerImageName(), nil); err != nil { + if err := a.buildImage("manager.Dockerfile", a.managerImageName(), nil); err != nil { return fmt.Errorf("failed to build manager image: %w", err) } - - if err := a.buildImage(path.Join(a.sourceDir, "docker/user.Dockerfile"), a.userImageName(), map[string]*string{ + uid := strconv.Itoa(a.Config.UserService.Runtime.UID) + gid := strconv.Itoa(a.Config.UserService.Runtime.GID) + if err := a.buildImage("user.Dockerfile", a.userImageName(), map[string]*string{ "CONTAINER_RUNTIME_USER": &a.Config.UserService.Runtime.LinuxUsername, + "CONTAINER_UID": &uid, + "CONTAINER_GID": &gid, }); err != nil { return fmt.Errorf("failed to build user image: %w", err) } - return nil } -// buildImage builds a Docker image using the given Dockerfile. -func (a *App) buildImage(dockerfilePath string, tag string, buildArgs map[string]*string) error { - if dockerfilePath == "" { - return fmt.Errorf("Dockerfile path is empty") - } - - if tag == "" { - return fmt.Errorf("image tag is empty") - } - - if _, err := os.Stat(dockerfilePath); err != nil { - return fmt.Errorf("Dockerfile not found: %s: %w", dockerfilePath, err) - } - - contextDir := a.sourceDir - - relDockerfile, err := filepath.Rel(contextDir, dockerfilePath) - if err != nil { - return fmt.Errorf("failed to get relative Dockerfile path: %w", err) - } - - buildCtx, err := tarBuildContext(contextDir) +func (a *App) buildImage(dockerfile, tag string, buildArgs map[string]*string) error { + buildCtx, err := tarRuntimeContext(dockerfile, a.execPath) if err != nil { return fmt.Errorf("failed to create Docker build context: %w", err) } - resp, err := a.dockerClient.ImageBuild(a.context, buildCtx, build.ImageBuildOptions{ Tags: []string{tag}, - Dockerfile: filepath.ToSlash(relDockerfile), + Dockerfile: "Dockerfile", + Platform: "linux/" + runtime.GOARCH, Remove: true, BuildArgs: buildArgs, }) @@ -74,109 +57,65 @@ func (a *App) buildImage(dockerfilePath string, tag string, buildArgs map[string return fmt.Errorf("failed to build image %s: %w", tag, err) } defer resp.Body.Close() - - if err := log.DockerBuildLog(log.DETAIL_PREFIX, resp.Body, tag); err != nil { - return fmt.Errorf("failed to process Docker build log for image %s: %w", tag, err) - } - - return nil + return log.DockerBuildLog(log.DETAIL_PREFIX, resp.Body, tag) } -// tarBuildContext creates an in-memory tar archive for Docker build context. -func tarBuildContext(dir string) (io.Reader, error) { +// tarRuntimeContext uses an explicit allowlist so credentials, source and disks +// cannot enter a Docker build context. The running binary also serves auth/manager. +func tarRuntimeContext(dockerfile, executable string) (io.Reader, error) { + switch dockerfile { + case "auth.Dockerfile", "manager.Dockerfile", "user.Dockerfile": + default: + return nil, fmt.Errorf("unknown runtime Dockerfile: %s", dockerfile) + } buf := new(bytes.Buffer) tw := tar.NewWriter(buf) - - visited := make(map[string]bool) - - err := filepath.Walk(dir, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - - relPath, err := filepath.Rel(dir, path) - if err != nil { + add := func(name string, mode int64, data []byte) error { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(data))}); err != nil { return err } - if relPath == "." { - return nil - } - - if info.Mode()&os.ModeSymlink != 0 { - target, err := filepath.EvalSymlinks(path) - if err != nil { - return err - } - - if visited[target] { - return nil - } - visited[target] = true - - targetInfo, err := os.Stat(target) - if err != nil { - return err - } - - if targetInfo.IsDir() { - return filepath.Walk(target, func(p string, ti os.FileInfo, err error) error { - if err != nil { - return err - } - - relSub, err := filepath.Rel(target, p) - if err != nil { - return err - } - - newPath := filepath.Join(relPath, relSub) - return addFileToTar(tw, p, newPath, ti) - }) - } - - return addFileToTar(tw, target, relPath, targetInfo) - } - - return addFileToTar(tw, path, relPath, info) - }) - + _, err := tw.Write(data) + return err + } + definition, err := assets.Files.ReadFile(dockerfile) if err != nil { - _ = tw.Close() return nil, err } - - if err := tw.Close(); err != nil { + if err := add("Dockerfile", 0644, definition); err != nil { return nil, err } - - return buf, nil -} - -// addFileToTar adds a file or directory to the tar archive. -func addFileToTar(tw *tar.Writer, realPath, tarPath string, info os.FileInfo) error { - header, err := tar.FileInfoHeader(info, "") - if err != nil { - return err - } - header.Name = filepath.ToSlash(tarPath) - - if err := tw.WriteHeader(header); err != nil { - return err - } - - if info.Mode().IsRegular() { - f, err := os.Open(realPath) + if dockerfile == "user.Dockerfile" { + script, err := assets.Files.ReadFile("start.sh") if err != nil { - return err + return nil, err } - defer f.Close() - - if _, err := io.Copy(tw, f); err != nil { - return err + if err := add("start.sh", 0755, script); err != nil { + return nil, err + } + } else { + binary, err := os.Open(executable) + if err != nil { + return nil, err + } + defer binary.Close() + info, err := binary.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("executable is not a regular file: %s", executable) + } + if err := tw.WriteHeader(&tar.Header{Name: "linuxusctl", Mode: 0755, Size: info.Size()}); err != nil { + return nil, err + } + if _, err := io.Copy(tw, binary); err != nil { + return nil, err } } - - return nil + if err := tw.Close(); err != nil { + return nil, err + } + return buf, nil } // authImageName returns the auth runtime image tag. diff --git a/src/internal/ctl/app/runtime_test.go b/src/internal/ctl/app/runtime_test.go new file mode 100644 index 0000000..aca38ba --- /dev/null +++ b/src/internal/ctl/app/runtime_test.go @@ -0,0 +1,131 @@ +package app + +import ( + "archive/tar" + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/elecbug/linuxus/src/internal/common/config" +) + +func TestDeploymentPathsIndependentOfWorkingDirectory(t *testing.T) { + root := t.TempDir() + t.Chdir(t.TempDir()) + a := &App{runtimeRoot: root, configFile: filepath.Join(root, "config.yml")} + if err := os.WriteFile(a.configFile, []byte(`auth_service: + mounts: + host_auth_list_path: data/AUTH_LIST + container_auth_list_path: /data/AUTH_LIST +volumes: + host: + volumes: volumes + homes: volumes/homes + share: /external/share + readonly: volumes/readonly +`), 0600); err != nil { + t.Fatal(err) + } + if err := a.LoadConfig(); err != nil { + t.Fatal(err) + } + if len(a.UserIDs) != 0 { + t.Fatal("fresh deployment has users") + } + if a.Config.Volumes.Host.Homes != filepath.Join(root, "volumes/homes") { + t.Fatal(a.Config.Volumes.Host.Homes) + } + if a.Config.Volumes.Host.Share != "/external/share" { + t.Fatal("absolute path changed") + } + if a.Config.AuthService.Mounts.HostAuthListPath != filepath.Join(root, "data/AUTH_LIST") { + t.Fatal("auth path is not deployment-relative") + } + if _, err := os.Stat(filepath.Join(root, "data")); !os.IsNotExist(err) { + t.Fatal("loading configuration changed disk state") + } + + authSpec, err := a.buildAuthRuntimeSpec() + if err != nil { + t.Fatal(err) + } + wantBind := filepath.Join(root, "data/AUTH_LIST") + ":/data/AUTH_LIST:rw" + if !reflect.DeepEqual(authSpec.Volumes, []string{wantBind}) { + t.Fatal(authSpec.Volumes) + } + managerSpec, err := a.buildManagerRuntimeSpec() + if err != nil { + t.Fatal(err) + } + var transported config.Config + for _, env := range managerSpec.Environment { + if strings.HasPrefix(env, "ENV=") { + if err := json.Unmarshal([]byte(strings.TrimPrefix(env, "ENV=")), &transported); err != nil { + t.Fatal(err) + } + } + } + if transported.Volumes.Host.Homes != a.Config.Volumes.Host.Homes { + t.Fatal("manager lost host mount source") + } + if managerSpec.Privileged { + t.Fatal("manager should only need the Docker socket") + } +} + +func TestRuntimeBuildContextsContainOnlyDeploymentAssets(t *testing.T) { + root := t.TempDir() + executable := filepath.Join(root, "linuxusctl") + binary := []byte("test executable") + for name, data := range map[string][]byte{"linuxusctl": binary, "AUTH_LIST": []byte("private"), "disk.img": []byte("private disk")} { + if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil { + t.Fatal(err) + } + } + t.Chdir(t.TempDir()) + for _, name := range []string{"auth", "manager", "user"} { + t.Run(name, func(t *testing.T) { + reader, err := tarRuntimeContext(name+".Dockerfile", executable) + if err != nil { + t.Fatal(err) + } + entries := map[string][]byte{} + tr := tar.NewReader(reader) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + entries[header.Name] = data + if header.Name == "linuxusctl" && header.Mode != 0755 { + t.Fatal("binary is not executable") + } + } + if len(entries) != 2 { + t.Fatalf("unexpected context: %v", entries) + } + if bytes.Contains(entries["Dockerfile"], []byte("go build")) { + t.Fatal("runtime still builds source") + } + if name == "user" { + if len(entries["start.sh"]) == 0 { + t.Fatal("startup script missing") + } + } else if !bytes.Equal(entries["linuxusctl"], binary) { + t.Fatal("binary missing or changed") + } + }) + } +} diff --git a/src/internal/ctl/app/service.go b/src/internal/ctl/app/service.go index 6da97ef..4512301 100644 --- a/src/internal/ctl/app/service.go +++ b/src/internal/ctl/app/service.go @@ -20,6 +20,12 @@ func (a *App) ServiceUp(params *cli.Parameters) error { if err := a.buildRuntimeImages(); err != nil { return err } + if err := user.EnsureFile(a.Config.AuthService.Mounts.HostAuthListPath); err != nil { + return err + } + if err := a.ensureDiskAll(); err != nil { + return err + } if err := a.ensureRuntimeNetworks(); err != nil { return err } @@ -221,7 +227,7 @@ func (a *App) ServiceAddUser(params *cli.Parameters) error { return fmt.Errorf("failed to add user: %w", err) } - if err := a.createUserDisk(userID, a.Config.ManagerService.AdminID == userID); err != nil { + if err := a.ensureDiskUser(userID); err != nil { return fmt.Errorf("failed to create user disk: %w", err) } diff --git a/src/internal/ctl/app/spec.go b/src/internal/ctl/app/spec.go index c629f92..0dec8f1 100644 --- a/src/internal/ctl/app/spec.go +++ b/src/internal/ctl/app/spec.go @@ -20,19 +20,6 @@ func (a *App) buildAuthRuntimeSpec() (spec.RuntimeContainerSpec, error) { Environment: []string{ "ENV=" + string(env), "TZ=" + a.Config.AuthService.Runtime.Timezone, - // "AUTH_LIST=" + a.Config.AuthService.Mounts.ContainerAuthListPath, - // "SESSION_SECRET=" + a.Config.AuthService.Security.SessionSecret, - // "LOGIN_PATH=" + a.Config.AuthService.ServiceURL.Login, - // "LOGOUT_PATH=" + a.Config.AuthService.ServiceURL.Logout, - // "SERVICE_PATH=" + a.Config.AuthService.ServiceURL.Service, - // "TERMINAL_PATH=" + a.Config.AuthService.ServiceURL.Terminal, - // "SIGNUP_PATH=" + a.Config.AuthService.ServiceURL.Signup, - // "USER_CONTAINER_NAME_PREFIX=" + a.Config.UserService.Container.NamePrefix, - // "TRUSTED_PROXIES=" + a.Config.AuthService.Security.TrustedProxies, - // "MANAGER_BASE_URL=" + fmt.Sprintf("http://%s:5959", a.Config.ManagerService.Container.Name), - // "MANAGER_TIMEOUT=" + a.Config.ManagerService.AuthService.ConnectionTimeout, - // "MANAGER_SESSION_SECRET=" + a.Config.ManagerService.Security.SessionSecret, - // "ALLOW_SIGNUP=" + fmt.Sprintf("%v", a.Config.AuthService.AllowSignup), }, Volumes: []string{ fmt.Sprintf("%s:%s:rw", a.Config.AuthService.Mounts.HostAuthListPath, a.Config.AuthService.Mounts.ContainerAuthListPath), @@ -61,55 +48,14 @@ func (a *App) buildManagerRuntimeSpec() (spec.RuntimeContainerSpec, error) { Environment: []string{ "ENV=" + string(env), "USER_IMAGE=" + a.userImageName(), - // "USER_TZ=" + a.Config.UserService.Runtime.Timezone, - // "USER_CONTAINER_NAME_PREFIX=" + a.Config.UserService.Container.NamePrefix, - // "NETWORK_PREFIX=" + a.Config.UserService.Container.NetworkNamePrefix, - // "BASE_IP=" + a.Config.UserService.Container.BaseSubnet16, - // "AUTH_CONTAINER_NAME=" + a.Config.AuthService.Container.Name, - // "MANAGER_CONTAINER_NAME=" + a.Config.ManagerService.Container.Name, - // "ADMIN_USER_ID=" + a.Config.ManagerService.AdminID, - - // "RUNTIME_USER=" + fmt.Sprintf("%d:%d", a.Config.UserService.Runtime.UID, a.Config.UserService.Runtime.GID), - // "CONTAINER_RUNTIME_USER=" + a.Config.UserService.Runtime.LinuxUsername, - // "CONTAINER_HOSTNAME=" + a.Config.UserService.Runtime.LinuxHostname, - // "WORKING_DIR=" + "/home/" + a.Config.UserService.Runtime.LinuxUsername, - - // "HOST_HOMES_DIR=" + a.Config.Volumes.Host.Homes, - // "HOST_SHARE_DIR=" + a.Config.Volumes.Host.Share, - // "HOST_READONLY_DIR=" + a.Config.Volumes.Host.Readonly, - // "CONTAINER_SHARE_DIR=" + a.Config.Volumes.Container.Share, - // "CONTAINER_READONLY_DIR=" + a.Config.Volumes.Container.Readonly, - - // "CONTAINER_USER_TIMEOUT=" + a.Config.ManagerService.UserManagement.CleanupTimeout, - // "MANAGER_WAIT_TIME=" + a.Config.ManagerService.AuthService.ConnectionTimeout, - // "MANAGER_SESSION_SECRET=" + a.Config.ManagerService.Security.SessionSecret, - // "LISTEN_ADDR=:5959", - - // "USER_NANO_CPUS=" + fmt.Sprintf("%d", userNanoCPUs), - // "USER_MEMORY_BYTES=" + fmt.Sprintf("%d", userMemBytes), - // "USER_PIDS_LIMIT=" + fmt.Sprintf("%d", a.Config.UserService.Limits.User.PID), - // "USER_NOFILE_SOFT=" + fmt.Sprintf("%d", a.Config.UserService.Limits.User.Ulimits.Nofile.Soft), - // "USER_NOFILE_HARD=" + fmt.Sprintf("%d", a.Config.UserService.Limits.User.Ulimits.Nofile.Hard), - // "ADMIN_NANO_CPUS=" + fmt.Sprintf("%d", adminNanoCPUs), - // "ADMIN_MEMORY_BYTES=" + fmt.Sprintf("%d", adminMemBytes), - // "ADMIN_PIDS_LIMIT=" + fmt.Sprintf("%d", a.Config.UserService.Limits.Admin.PID), - // "ADMIN_NOFILE_SOFT=" + fmt.Sprintf("%d", a.Config.UserService.Limits.Admin.Ulimits.Nofile.Soft), - // "ADMIN_NOFILE_HARD=" + fmt.Sprintf("%d", a.Config.UserService.Limits.Admin.Ulimits.Nofile.Hard), - - // "SHARE_DISK_LIMIT=" + a.Config.Volumes.DiskLimit, - // "USER_DISK_LIMIT=" + a.Config.UserService.Limits.User.Disk, - // "ADMIN_DISK_LIMIT=" + a.Config.UserService.Limits.Admin.Disk, }, Volumes: []string{ - fmt.Sprintf("%s:%s", a.Config.Volumes.Host.Homes, a.Config.ManagerService.Container.HomesDir), - fmt.Sprintf("%s:%s", a.Config.Volumes.Host.Share, a.Config.ManagerService.Container.ShareDir), - fmt.Sprintf("%s:%s", a.Config.Volumes.Host.Readonly, a.Config.ManagerService.Container.ReadonlyDir), "/var/run/docker.sock:/var/run/docker.sock:rw", }, Restart: "unless-stopped", Networks: []string{ a.Config.ManagerService.Container.Network, }, - Privileged: true, + Privileged: false, }, nil } diff --git a/src/internal/ctl/log/log.go b/src/internal/ctl/log/log.go index 7dcbfd4..54e7255 100644 --- a/src/internal/ctl/log/log.go +++ b/src/internal/ctl/log/log.go @@ -90,7 +90,7 @@ func DockerBuildLog(level LogLevel, r io.Reader, imageName string) error { if msg.Error != "" { Log(ERROR_PREFIX, "%s", msg.Error) - return fmt.Errorf(msg.Error) + return fmt.Errorf("%s", msg.Error) } text := strings.TrimSpace(msg.Stream) diff --git a/src/internal/manager/server.go b/src/internal/manager/server.go new file mode 100644 index 0000000..235b0da --- /dev/null +++ b/src/internal/manager/server.go @@ -0,0 +1,130 @@ +package manager + +import ( + "encoding/json" + "fmt" + "os" + "time" + + ctl_config "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/convert" + "github.com/elecbug/linuxus/src/internal/manager/config" + "github.com/elecbug/linuxus/src/internal/manager/handler" +) + +// Run loads environment configuration and starts the manager server. +func Run() error { + cfg, err := parseConfigFromEnv() + if err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + s, err := handler.NewServer(cfg) + if err != nil { + return fmt.Errorf("failed to create server: %w", err) + } + + defer s.Close() + s.RegisterRoutes() + s.Start() + return nil +} + +// parseConfigFromEnv reads required and optional manager settings from environment variables. +func parseConfigFromEnv() (*config.Config, error) { + var err error + + env := os.Getenv("ENV") + if env == "" { + return nil, fmt.Errorf("ENV environment variable is required") + } + + var cfg ctl_config.Config + + err = json.Unmarshal([]byte(env), &cfg) + if err != nil { + return nil, fmt.Errorf("failed to parse ENV variable as JSON: %v", err) + } + + userImage := os.Getenv("USER_IMAGE") + if userImage == "" { + return nil, fmt.Errorf("USER_IMAGE environment variable is required") + } + + managerWaitTime, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout) + if err != nil { + return nil, fmt.Errorf("invalid ManagerService.AuthService.ConnectionTimeout: %v", err) + } + + containerTimeout, err := time.ParseDuration(cfg.ManagerService.UserManagement.CleanupTimeout) + if err != nil { + return nil, fmt.Errorf("invalid ManagerService.UserManagement.CleanupTimeout: %v", err) + } + + userCPUStr := fmt.Sprintf("%v", cfg.UserService.Limits.User.CPU) + userCPU, err := convert.NanoCPUsFromString(userCPUStr) + if err != nil { + return nil, fmt.Errorf("invalid UserService.Limits.User.CPU: %v", err) + } + + adminCPUStr := fmt.Sprintf("%v", cfg.UserService.Limits.Admin.CPU) + adminCPU, err := convert.NanoCPUsFromString(adminCPUStr) + if err != nil { + return nil, fmt.Errorf("invalid UserService.Limits.Admin.CPU: %v", err) + } + + userMemBytes, err := convert.BytesFromString(cfg.UserService.Limits.User.Memory) + if err != nil { + return nil, fmt.Errorf("invalid UserService.Limits.User.Memory: %v", err) + } + + adminMemBytes, err := convert.BytesFromString(cfg.UserService.Limits.Admin.Memory) + if err != nil { + return nil, fmt.Errorf("invalid UserService.Limits.Admin.Memory: %v", err) + } + + return &config.Config{ + ListenAddr: ":5959", + UserImage: userImage, + UserContainerNamePrefix: cfg.UserService.Container.NamePrefix, + NetworkPrefix: cfg.UserService.Container.NetworkNamePrefix, + BaseIP: cfg.UserService.Container.BaseSubnet16, + AuthContainerName: cfg.AuthService.Container.Name, + AdminUserID: cfg.ManagerService.AdminID, + ManagerSessionSecret: cfg.ManagerService.Security.SessionSecret, + + RuntimeUser: fmt.Sprintf("%d:%d", cfg.UserService.Runtime.UID, cfg.UserService.Runtime.GID), + ContainerRuntimeUser: cfg.UserService.Runtime.LinuxUsername, + ContainerHostname: cfg.UserService.Runtime.LinuxHostname, + WorkingDir: "/home/" + cfg.UserService.Runtime.LinuxUsername, + Timezone: cfg.UserService.Runtime.Timezone, + ReadOnlyRootFS: true, + ManagerWaitTime: managerWaitTime, + ContainerTimeout: containerTimeout, + + HostHomesDir: cfg.Volumes.Host.Homes, + HostShareDir: cfg.Volumes.Host.Share, + HostReadonlyDir: cfg.Volumes.Host.Readonly, + ContainerShareDir: cfg.Volumes.Container.Share, + ContainerReadonlyDir: cfg.Volumes.Container.Readonly, + + UserLimits: config.ResourceLimits{ + NanoCPUs: userCPU, + MemoryBytes: userMemBytes, + PidsLimit: int64(cfg.UserService.Limits.User.PID), + NofileSoft: int64(cfg.UserService.Limits.User.Ulimits.Nofile.Soft), + NofileHard: int64(cfg.UserService.Limits.User.Ulimits.Nofile.Hard), + }, + AdminLimits: config.ResourceLimits{ + NanoCPUs: adminCPU, + MemoryBytes: adminMemBytes, + PidsLimit: int64(cfg.UserService.Limits.Admin.PID), + NofileSoft: int64(cfg.UserService.Limits.Admin.Ulimits.Nofile.Soft), + NofileHard: int64(cfg.UserService.Limits.Admin.Ulimits.Nofile.Hard), + }, + + ShareDiskLimit: cfg.Volumes.DiskLimit, + UserDiskLimit: cfg.UserService.Limits.User.Disk, + AdminDiskLimit: cfg.UserService.Limits.Admin.Disk, + }, nil +} diff --git a/src/static/assets.go b/src/static/assets.go new file mode 100644 index 0000000..a9ad3d5 --- /dev/null +++ b/src/static/assets.go @@ -0,0 +1,9 @@ +// Package static contains the web assets shipped with the application. +package static + +import "embed" + +// Files contains the authentication server's static assets. +// +//go:embed favicon.png +var Files embed.FS From 16ae919cdcec8441c34acc89e63542d40a238ffe Mon Sep 17 00:00:00 2001 From: elecbug Date: Fri, 2 Oct 2026 14:04:41 +0900 Subject: [PATCH 2/9] feat: implement disk service auto-ensure feature with comprehensive tests - Added disk service tests to validate user preparation and lifecycle management. - Enhanced App struct to support automatic disk service management based on configuration. - Updated service and spec handling to incorporate disk service socket and auto-ensure logic. - Introduced new configuration options for disk service socket and auto-ensure behavior. - Refactored manager handler to ensure disk preparation during user runtime setup. - Added tests for auto-ensure functionality and configuration validation from environment variables. --- cfg/config.yml | 2 + doc/USAGE.md | 29 +- src/cmd/ctl/main.go | 5 + src/internal/auth/handler/manager_client.go | 3 + .../auth/handler/manager_client_test.go | 26 ++ src/internal/common/config/config.go | 3 + src/internal/common/diskservice/client.go | 53 +++ src/internal/ctl/app/disk.go | 21 ++ src/internal/ctl/app/disk_service.go | 275 ++++++++++++++ src/internal/ctl/app/disk_service_test.go | 352 ++++++++++++++++++ src/internal/ctl/app/disk_test.go | 2 + src/internal/ctl/app/runtime_test.go | 27 ++ src/internal/ctl/app/service.go | 11 +- src/internal/ctl/app/spec.go | 10 +- src/internal/manager/config/config.go | 2 + src/internal/manager/handler/handle_up.go | 38 +- .../manager/handler/handle_up_test.go | 241 ++++++++++++ src/internal/manager/handler/server.go | 13 +- src/internal/manager/server.go | 7 + src/internal/manager/server_test.go | 51 +++ 20 files changed, 1148 insertions(+), 23 deletions(-) create mode 100644 src/internal/auth/handler/manager_client_test.go create mode 100644 src/internal/common/diskservice/client.go create mode 100644 src/internal/ctl/app/disk_service.go create mode 100644 src/internal/ctl/app/disk_service_test.go create mode 100644 src/internal/manager/handler/handle_up_test.go create mode 100644 src/internal/manager/server_test.go diff --git a/cfg/config.yml b/cfg/config.yml index 8e4b07e..a0ea02f 100644 --- a/cfg/config.yml +++ b/cfg/config.yml @@ -73,6 +73,8 @@ manager_service: admin_id: alpha volumes: + # Prepare new users' disks automatically on first shell access. + auto-ensure: true host: volumes: volumes homes: volumes/homes diff --git a/doc/USAGE.md b/doc/USAGE.md index 99f42c9..2da2714 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -161,14 +161,37 @@ A **Signup** link will appear on the login page. 3. Enters ID and password 4. Account is registered -> [!IMPORTANT] -> Newly registered users are **not immediately usable** +With `volumes.auto-ensure: true`, the first shell access prepares the newly registered user's disk automatically. With the setting omitted or false, initialize new users manually as shown below. --- ### 4.3 Activate User Environment -After signup, the host must initialize user environments: +Enable automatic disk activation in `cfg/config.yml`: + +```yaml +volumes: + auto-ensure: true +``` + +Apply the setting with `sudo ./linuxusctl restart`. `up` starts the private host +`linuxusctl serve-disks` process; `down` stops it. Manager requests disk preparation +over a Unix socket before creating or restarting a user container. Disk errors +prevent the shell from starting, and existing images are reused rather than +formatted again. Manual `ensure-disk` remains available. + +The host process runs with the mounting privileges of `sudo linuxusctl up`. +Its socket has mode 0600 in a mode-0700 `.disk-service` directory beside `AUTH_LIST`. +Only Manager receives that directory as a read-only mount. There is no TCP +listener. Requests accept a registered user ID only; paths and disk limits come +from the deployment configuration. Manager's container capabilities are unchanged. +The default service log is `data/.disk-service/service.log`. Run `sudo ./linuxusctl up` +after a host reboot to restore mounts and the host process; Docker container +restart policies alone do not restart this process. As with the existing deployment, +Docker must run locally on the Linux host. + +When `auto-ensure` is omitted or false, users registered after startup need manual +activation: ```bash sudo ./linuxusctl ensure-disk --user diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index e92f87c..0535951 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -35,6 +35,7 @@ const ( ADD_USER REMOVE_USER HELP + SERVE_DISKS ) // Options encapsulates the selected operations and their parameters. @@ -94,6 +95,8 @@ func run() error { } switch opt.Option { + case SERVE_DISKS: + return a.ServeDisks() case UP: if err := a.ServiceUp(opt.Params); err != nil { return err @@ -166,6 +169,8 @@ func parseArgs(bin string, args []string) (Options, error) { result.Option = REMOVE_USER case "help": result.Option = HELP + case "serve-disks": + result.Option = SERVE_DISKS default: return result, fmt.Errorf("invalid parameter: '%s'\n\n%s", args[0], usageText(bin, true, true, false)) } diff --git a/src/internal/auth/handler/manager_client.go b/src/internal/auth/handler/manager_client.go index 7d87868..99e9ee7 100644 --- a/src/internal/auth/handler/manager_client.go +++ b/src/internal/auth/handler/manager_client.go @@ -39,6 +39,9 @@ func (a *App) ensureUserContainerReady(ctx context.Context, userID string) (stri return "", fmt.Errorf("failed to build manager request: %w", err) } req.Header.Set("Content-Type", "application/json") + if a.managerSessionSecret != "" { + req.Header.Set("X-Manager-Session-Secret", a.managerSessionSecret) + } resp, err := a.managerClient.Do(req) if err != nil { diff --git a/src/internal/auth/handler/manager_client_test.go b/src/internal/auth/handler/manager_client_test.go new file mode 100644 index 0000000..0a3be79 --- /dev/null +++ b/src/internal/auth/handler/manager_client_test.go @@ -0,0 +1,26 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestUserUpSendsManagerSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/user/up" || r.Header.Get("X-Manager-Session-Secret") != "test-secret" { + t.Error("missing manager authentication") + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"container_name":"user_alice"}`)) + })) + defer server.Close() + a := &App{managerBaseURL: server.URL, managerClient: server.Client(), managerSessionSecret: "test-secret"} + name, err := a.ensureUserContainerReady(context.Background(), "alice") + if err != nil || name != "user_alice" { + t.Fatalf("container=%s error=%v", name, err) + } +} diff --git a/src/internal/common/config/config.go b/src/internal/common/config/config.go index 7d36c05..9823a47 100644 --- a/src/internal/common/config/config.go +++ b/src/internal/common/config/config.go @@ -128,6 +128,9 @@ type Config struct { // Volumes configures host/container volume paths and default disk size. Volumes struct { + // AutoEnsure prepares user disks on demand through the host disk service. + AutoEnsure bool `yaml:"auto-ensure"` + // Host contains host-side directories. Host struct { // Volumes is the root host directory for managed volume data. diff --git a/src/internal/common/diskservice/client.go b/src/internal/common/diskservice/client.go new file mode 100644 index 0000000..6e952c3 --- /dev/null +++ b/src/internal/common/diskservice/client.go @@ -0,0 +1,53 @@ +// Package diskservice connects the manager to the host's private disk service. +package diskservice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/elecbug/linuxus/src/internal/common/packet" +) + +const ContainerDir = "/run/linuxus-disks" +const ContainerSocket = ContainerDir + "/disk.sock" + +// NewClient only dials a local Unix socket and ignores proxy settings. +func NewClient(socket string, timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + }} +} + +func Ensure(ctx context.Context, client *http.Client, userID string) error { + if client == nil { + return fmt.Errorf("disk service is not configured") + } + body, err := json.Marshal(packet.UserUpRequest{UserID: userID}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://disk-service/ensure-disk", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("contact host disk service: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("host disk service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(message))) + } + return nil +} diff --git a/src/internal/ctl/app/disk.go b/src/internal/ctl/app/disk.go index 5a3edcc..1068770 100644 --- a/src/internal/ctl/app/disk.go +++ b/src/internal/ctl/app/disk.go @@ -31,10 +31,16 @@ func (a *App) cleanVolumesAll() error { if err := a.removeManagedContainers(); err != nil { return err } + if err := a.stopDiskService(); err != nil { + return err + } if err := a.removeManagedNetworks(); err != nil { return err } + return a.withDiskLock(a.cleanVolumesAllUnlocked) +} +func (a *App) cleanVolumesAllUnlocked() error { homeMounts, err := a.listMountedDirsDeepestFirst(a.Config.Volumes.Host.Homes) if err != nil { return err @@ -111,6 +117,10 @@ func (a *App) cleanVolumeUser(userID string) error { if !ruleset.AllowedUserID(userID) { return fmt.Errorf("invalid user ID: %q", userID) } + return a.withDiskLock(func() error { return a.cleanVolumeUserUnlocked(userID) }) +} + +func (a *App) cleanVolumeUserUnlocked(userID string) error { log.Log(log.RUN_PREFIX, "Cleaning volume for user: %s...", userID) if err := a.umountDisk(filepath.Join(a.Config.Volumes.Host.Homes, userID)); err != nil { @@ -148,6 +158,10 @@ func (a *App) cleanVolumeUser(userID string) error { // ensureDiskAll creates and mounts disks for all users and shared volumes. func (a *App) ensureDiskAll() error { + return a.withDiskLock(a.ensureDiskAllUnlocked) +} + +func (a *App) ensureDiskAllUnlocked() error { if err := a.systemAPI.MkdirAll(a.Config.Volumes.Host.Homes, 0755); err != nil { return err } @@ -170,6 +184,13 @@ func (a *App) ensureDiskAll() error { // ensureDiskUser creates and mounts disks for the specified user. func (a *App) ensureDiskUser(userID string) error { + if !ruleset.AllowedUserID(userID) { + return fmt.Errorf("invalid user ID") + } + return a.withDiskLock(func() error { return a.ensureDiskUserUnlocked(userID) }) +} + +func (a *App) ensureDiskUserUnlocked(userID string) error { if !user.ExistsUser(a.UserIDs, userID) { return fmt.Errorf("user ID not found in auth list: %s", userID) } diff --git a/src/internal/ctl/app/disk_service.go b/src/internal/ctl/app/disk_service.go new file mode 100644 index 0000000..de12f1d --- /dev/null +++ b/src/internal/ctl/app/disk_service.go @@ -0,0 +1,275 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/elecbug/linuxus/src/internal/common/diskservice" + "github.com/elecbug/linuxus/src/internal/common/packet" + "github.com/elecbug/linuxus/src/internal/common/ruleset" + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func (a *App) diskServiceDir() string { + return filepath.Join(filepath.Dir(a.Config.AuthService.Mounts.HostAuthListPath), ".disk-service") +} +func (a *App) diskSocket() string { return filepath.Join(a.diskServiceDir(), "disk.sock") } + +func (a *App) withDiskLock(action func() error) error { + if err := os.MkdirAll(a.diskServiceDir(), 0700); err != nil { + return err + } + file, err := os.OpenFile(filepath.Join(a.diskServiceDir(), "ensure.lock"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer file.Close() + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + return action() +} + +// startDiskService uses the same host privileges as the invoking up command. +// It does not grant the Manager any additional Docker or system capabilities. +func (a *App) startDiskService() error { + if os.Geteuid() != 0 { + return fmt.Errorf("auto-ensure requires root; run linuxusctl up with sudo") + } + if err := a.stopDiskService(); err != nil { + return err + } + if err := os.MkdirAll(a.diskServiceDir(), 0700); err != nil { + return err + } + output, err := os.OpenFile(filepath.Join(a.diskServiceDir(), "service.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + defer output.Close() + cmd := exec.Command(a.execPath, "serve-disks") + cmd.Stdout = output + cmd.Stderr = output + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("start host disk service: %w", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + client := diskservice.NewClient(a.diskSocket(), time.Second) + defer client.CloseIdleConnections() + deadline := time.NewTimer(10 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case err := <-done: + return fmt.Errorf("disk service exited before startup (%v); see %s", err, output.Name()) + case <-deadline.C: + _ = cmd.Process.Kill() + <-done + return fmt.Errorf("disk service startup timed out; see %s", output.Name()) + case <-ticker.C: + resp, err := client.Get("http://disk-service/healthz") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +func (a *App) stopDiskService() error { + info, err := os.Lstat(a.diskSocket()) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("disk service socket path is not a socket") + } + client := diskservice.NewClient(a.diskSocket(), 5*time.Second) + defer client.CloseIdleConnections() + resp, err := client.Post("http://disk-service/shutdown", "application/json", nil) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if errors.Is(err, syscall.ECONNREFUSED) { + // A draining service has closed its listener but still owns the lock. + // Only remove an abandoned socket after the process releases that lock. + lock, lockErr := os.OpenFile(filepath.Join(a.diskServiceDir(), "service.lock"), os.O_CREATE|os.O_RDWR, 0600) + if lockErr != nil { + return lockErr + } + defer lock.Close() + lockErr = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if lockErr == nil { + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + if err := os.Remove(a.diskSocket()); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if !errors.Is(lockErr, syscall.EWOULDBLOCK) { + return lockErr + } + } else if err != nil { + return fmt.Errorf("stop disk service: %w", err) + } else { + resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("disk service refused shutdown: %d", resp.StatusCode) + } + } + deadline := time.NewTimer(35 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-deadline.C: + return fmt.Errorf("disk service shutdown timed out") + case <-ticker.C: + if _, err := os.Lstat(a.diskSocket()); os.IsNotExist(err) { + return nil + } + } + } +} + +// ServeDisks is the private host mode launched by up when auto-ensure is true. +// It exposes no TCP listener, command execution, path selection or deletion API. +func (a *App) ServeDisks() error { + if !a.Config.Volumes.AutoEnsure { + return fmt.Errorf("volumes.auto-ensure is disabled") + } + if os.Geteuid() != 0 { + return fmt.Errorf("host disk service requires root for mounting disks") + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return a.serveDisks(ctx) +} + +func (a *App) serveDisks(ctx context.Context) error { + if err := os.MkdirAll(a.diskServiceDir(), 0700); err != nil { + return err + } + if err := os.Chmod(a.diskServiceDir(), 0700); err != nil { + return err + } + lock, err := os.OpenFile(filepath.Join(a.diskServiceDir(), "service.lock"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fmt.Errorf("disk service is already running: %w", err) + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + if info, err := os.Lstat(a.diskSocket()); err == nil { + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("disk service socket path is not a socket") + } + if err := os.Remove(a.diskSocket()); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: a.diskSocket(), Net: "unix"}) + if err != nil { + return err + } + listener.SetUnlinkOnClose(false) + defer listener.Close() + defer os.Remove(a.diskSocket()) + if err := os.Chmod(a.diskSocket(), 0600); err != nil { + return err + } + stopping := make(chan struct{}) + var once sync.Once + shutdown := func() { once.Do(func() { close(stopping) }) } + server := &http.Server{ + Handler: a.diskServiceHandler(shutdown), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + } + finished := make(chan error, 1) + go func() { + select { + case <-ctx.Done(): + case <-stopping: + } + // Never exit halfway through formatting or mounting a disk. The caller + // has its own bounded shutdown wait and can retry if preparation is slow. + finished <- server.Shutdown(context.Background()) + }() + err = server.Serve(listener) + shutdown() + shutdownErr := <-finished + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return shutdownErr +} + +func (a *App) diskServiceHandler(shutdown func()) http.Handler { + mux := http.NewServeMux() + var mu sync.Mutex + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("POST /shutdown", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + shutdown() + }) + mux.HandleFunc("POST /ensure-disk", func(w http.ResponseWriter, r *http.Request) { + var request packet.UserUpRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&request); err != nil || !ruleset.AllowedUserID(request.UserID) { + http.Error(w, "invalid user ID", http.StatusBadRequest) + return + } + mu.Lock() + defer mu.Unlock() + status := http.StatusInternalServerError + err := a.withDiskLock(func() error { + if err := r.Context().Err(); err != nil { + return err + } + // Read registration under the same lock as preparation and cleanup. + users, err := user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + if err != nil { + return fmt.Errorf("cannot load registered users") + } + if !user.ExistsUser(users, request.UserID) { + status = http.StatusForbidden + return fmt.Errorf("user is not registered") + } + a.UserIDs = users + return a.ensureDiskUserUnlocked(request.UserID) + }) + if err != nil { + http.Error(w, fmt.Sprintf("ensure disk: %v", err), status) + return + } + w.WriteHeader(http.StatusOK) + }) + return mux +} diff --git a/src/internal/ctl/app/disk_service_test.go b/src/internal/ctl/app/disk_service_test.go new file mode 100644 index 0000000..5283e1b --- /dev/null +++ b/src/internal/ctl/app/disk_service_test.go @@ -0,0 +1,352 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/elecbug/linuxus/src/internal/common/convert" + "github.com/elecbug/linuxus/src/internal/common/diskservice" + "github.com/elecbug/linuxus/src/internal/common/packet" + "github.com/elecbug/linuxus/src/internal/common/system_api" +) + +type mountedDiskAPI struct { + system_api.API + mu sync.Mutex + checked []string +} + +func (f *mountedDiskAPI) MkdirAll(string, os.FileMode) error { return nil } +func (f *mountedDiskAPI) IsMountPoint(path string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.checked = append(f.checked, path) + return true, nil +} + +func TestDiskServiceOnlyPreparesRegisteredUsers(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:hash\n"), 0600); err != nil { + t.Fatal(err) + } + api := &mountedDiskAPI{} + a := &App{systemAPI: api} + a.Config.AuthService.Mounts.HostAuthListPath = path + a.Config.Volumes.Host.Homes = filepath.Join(dir, "homes") + a.Config.Volumes.Host.Share = filepath.Join(dir, "share") + a.Config.Volumes.Host.Readonly = filepath.Join(dir, "readonly") + a.Config.Volumes.DiskLimit = "8M" + a.Config.UserService.Limits.User.Disk = "8M" + socket := filepath.Join(dir, "test.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + server := &http.Server{Handler: a.diskServiceHandler(func() {})} + defer server.Close() + go server.Serve(listener) + client := diskservice.NewClient(socket, time.Second) + defer client.CloseIdleConnections() + for _, id := range []string{"../outside", "unknown"} { + if err := diskservice.Ensure(context.Background(), client, id); err == nil { + t.Fatalf("accepted %q", id) + } + } + api.mu.Lock() + count := len(api.checked) + api.mu.Unlock() + if count != 0 { + t.Fatal("invalid users reached disk operations") + } + if err := diskservice.Ensure(context.Background(), client, "alice"); err != nil { + t.Fatal(err) + } + api.mu.Lock() + count = len(api.checked) + api.mu.Unlock() + if count != 3 { + t.Fatalf("expected share, readonly and home checks, got %d", count) + } +} + +type recordingDiskAPI struct { + system_api.API + images map[string]int64 + formatted map[string]int + mounted map[string]bool + failMount string + detached int +} + +func (f *recordingDiskAPI) MkdirAll(string, os.FileMode) error { return nil } +func (f *recordingDiskAPI) Chown(string, int, int) error { return nil } +func (f *recordingDiskAPI) Chmod(string, os.FileMode) error { return nil } +func (f *recordingDiskAPI) IsMountPoint(path string) (bool, error) { return f.mounted[path], nil } +func (f *recordingDiskAPI) Exists(path string) (bool, error) { _, ok := f.images[path]; return ok, nil } +func (f *recordingDiskAPI) CreateEmptyFile(path string, size int64) error { + f.images[path] = size + return nil +} +func (f *recordingDiskAPI) FormatExt4(path string) error { f.formatted[path]++; return nil } +func (f *recordingDiskAPI) AttachLoopDevice(string) (string, error) { return "/dev/fake-loop", nil } +func (f *recordingDiskAPI) DetachLoopDevice(string) error { f.detached++; return nil } +func (f *recordingDiskAPI) Mount(source, target string) error { + if target == f.failMount { + return fmt.Errorf("mount failed") + } + f.mounted[target] = true + return nil +} + +func TestDiskServiceCreatesReusesAndReloadsUsers(t *testing.T) { + dir := t.TempDir() + api := &recordingDiskAPI{images: map[string]int64{}, formatted: map[string]int{}, mounted: map[string]bool{}} + a := &App{systemAPI: api} + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(dir, "AUTH_LIST") + a.Config.Volumes.Host.Homes = filepath.Join(dir, "homes") + a.Config.Volumes.Host.Share = filepath.Join(dir, "share") + a.Config.Volumes.Host.Readonly = filepath.Join(dir, "readonly") + a.Config.Volumes.DiskLimit = "8M" + a.Config.UserService.Limits.User.Disk = "16M" + a.Config.UserService.Limits.Admin.Disk = "32M" + a.Config.ManagerService.AdminID = "admin" + handler := a.diskServiceHandler(func() {}) + writeUsers := func(users string) { + t.Helper() + if err := os.WriteFile(a.Config.AuthService.Mounts.HostAuthListPath, []byte(users), 0600); err != nil { + t.Fatal(err) + } + } + ensure := func(id string, wantStatus int) { + t.Helper() + body, _ := json.Marshal(packet.UserUpRequest{UserID: id}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("POST", "/ensure-disk", bytes.NewReader(body))) + if w.Code != wantStatus { + t.Fatalf("ensure %s: status=%d body=%s", id, w.Code, w.Body.String()) + } + } + writeUsers("alice:hash\n") + ensure("alice", 200) + if len(api.images) != 3 || len(api.mounted) != 3 { + t.Fatalf("missing disks: %+v", api) + } + ensure("alice", 200) + // A host reboot loses mounts, but must never reformat existing disk images. + api.mounted = map[string]bool{} + ensure("alice", 200) + writeUsers("alice:hash\nadmin:hash\n") + ensure("admin", 200) + for path, size := range map[string]string{ + a.Config.Volumes.Host.Share + ".img": "8M", + a.Config.Volumes.Host.Readonly + ".img": "8M", + filepath.Join(a.Config.Volumes.Host.Homes, "alice.img"): "16M", + filepath.Join(a.Config.Volumes.Host.Homes, "admin.img"): "32M", + } { + want, err := convert.BytesFromString(size) + if err != nil { + t.Fatal(err) + } + if api.images[path] != want || api.formatted[path] != 1 { + t.Fatalf("wrong size or reformatted %s: %+v", path, api) + } + } + writeUsers("admin:hash\n") + ensure("alice", 403) + writeUsers("admin:hash\nbob:hash\n") + api.failMount = filepath.Join(a.Config.Volumes.Host.Homes, "bob") + ensure("bob", 500) + if api.detached != 1 { + t.Fatal("failed mount leaked a loop device") + } + api.failMount = "" + ensure("bob", 200) + if api.formatted[filepath.Join(a.Config.Volumes.Host.Homes, "bob.img")] != 1 { + t.Fatal("retry reformatted disk") + } +} + +func TestDiskServiceLifecycle(t *testing.T) { + // Keep the Unix socket path below the operating system's length limit. + dir, err := os.MkdirTemp("", "linuxus-service-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + a := &App{} + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(dir, "AUTH_LIST") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- a.serveDisks(ctx) }() + client := diskservice.NewClient(a.diskSocket(), time.Second) + defer client.CloseIdleConnections() + deadline := time.Now().Add(3 * time.Second) + for { + resp, err := client.Get("http://disk-service/healthz") + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + break + } + } + if time.Now().After(deadline) { + t.Fatal("disk service did not become healthy") + } + time.Sleep(10 * time.Millisecond) + } + for path, mode := range map[string]os.FileMode{a.diskServiceDir(): 0700, a.diskSocket(): 0600} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != mode { + t.Fatalf("%s mode=%v", path, info.Mode()) + } + } + // A duplicate process must not steal or unlink the live socket. + if err := a.serveDisks(ctx); err == nil { + t.Fatal("duplicate service accepted") + } + if err := a.stopDiskService(); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("disk service did not stop") + } + if _, err := os.Lstat(a.diskSocket()); !os.IsNotExist(err) { + t.Fatalf("socket remains: %v", err) + } + if err := a.stopDiskService(); err != nil { + t.Fatalf("repeated shutdown: %v", err) + } + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: a.diskSocket(), Net: "unix"}) + if err != nil { + t.Fatal(err) + } + listener.SetUnlinkOnClose(false) + listener.Close() + if err := a.stopDiskService(); err != nil { + t.Fatalf("stale socket: %v", err) + } + if err := os.WriteFile(a.diskSocket(), []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + if err := a.stopDiskService(); err == nil { + t.Fatal("accepted regular file at socket path") + } + if data, err := os.ReadFile(a.diskSocket()); err != nil || string(data) != "keep" { + t.Fatal("removed non-socket file") + } +} + +type blockingDiskAPI struct { + mountedDiskAPI + once sync.Once + entered, release chan struct{} +} + +func (f *blockingDiskAPI) IsMountPoint(path string) (bool, error) { + f.once.Do(func() { close(f.entered); <-f.release }) + return f.mountedDiskAPI.IsMountPoint(path) +} + +func TestDiskServiceShutdownWaitsForActivePreparation(t *testing.T) { + dir, err := os.MkdirTemp("", "linuxus-drain-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + api := &blockingDiskAPI{entered: make(chan struct{}), release: make(chan struct{})} + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(api.release) }) } + defer release() + a := &App{systemAPI: api} + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(dir, "AUTH_LIST") + a.Config.Volumes.Host.Homes = filepath.Join(dir, "homes") + a.Config.Volumes.Host.Share = filepath.Join(dir, "share") + a.Config.Volumes.Host.Readonly = filepath.Join(dir, "readonly") + a.Config.Volumes.DiskLimit = "8M" + a.Config.UserService.Limits.User.Disk = "8M" + if err := os.WriteFile(a.Config.AuthService.Mounts.HostAuthListPath, []byte("alice:hash\n"), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- a.serveDisks(ctx) }() + client := diskservice.NewClient(a.diskSocket(), 5*time.Second) + defer client.CloseIdleConnections() + deadline := time.Now().Add(3 * time.Second) + for { + resp, err := client.Get("http://disk-service/healthz") + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + break + } + } + if time.Now().After(deadline) { + t.Fatal("service startup timed out") + } + time.Sleep(10 * time.Millisecond) + } + prepared := make(chan error, 1) + go func() { prepared <- diskservice.Ensure(context.Background(), client, "alice") }() + select { + case <-api.entered: + case <-time.After(3 * time.Second): + t.Fatal("preparation did not start") + } + cancel() + // Wait until the listener closes while the preparation request is draining. + deadline = time.Now().Add(3 * time.Second) + for { + resp, err := client.Get("http://disk-service/healthz") + if err != nil { + break + } + resp.Body.Close() + if time.Now().After(deadline) { + t.Fatal("listener did not close") + } + time.Sleep(10 * time.Millisecond) + } + stopped := make(chan error, 1) + go func() { stopped <- a.stopDiskService() }() + select { + case err := <-stopped: + t.Fatalf("stop returned before preparation finished: %v", err) + case <-time.After(50 * time.Millisecond): + } + if _, err := os.Stat(a.diskSocket()); err != nil { + t.Fatalf("draining socket removed: %v", err) + } + release() + for name, result := range map[string]<-chan error{"prepare": prepared, "serve": done, "stop": stopped} { + select { + case err := <-result: + if err != nil { + t.Fatalf("%s: %v", name, err) + } + case <-time.After(3 * time.Second): + t.Fatalf("%s timed out", name) + } + } +} diff --git a/src/internal/ctl/app/disk_test.go b/src/internal/ctl/app/disk_test.go index 849777d..85ff8b7 100644 --- a/src/internal/ctl/app/disk_test.go +++ b/src/internal/ctl/app/disk_test.go @@ -2,6 +2,7 @@ package app import ( "errors" + "path/filepath" "reflect" "testing" @@ -48,6 +49,7 @@ func TestUserDiskCleanupStopsBeforeDeletingAttachedData(t *testing.T) { api.detachErr = failure } a := &App{systemAPI: api} + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(t.TempDir(), "AUTH_LIST") a.Config.Volumes.Host.Homes = "/deployment/volumes/homes" err := a.cleanVolumeUser("alice") want := []string{"unmount:/deployment/volumes/homes/alice"} diff --git a/src/internal/ctl/app/runtime_test.go b/src/internal/ctl/app/runtime_test.go index aca38ba..fe9d0c2 100644 --- a/src/internal/ctl/app/runtime_test.go +++ b/src/internal/ctl/app/runtime_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/diskservice" ) func TestDeploymentPathsIndependentOfWorkingDirectory(t *testing.T) { @@ -129,3 +130,29 @@ func TestRuntimeBuildContextsContainOnlyDeploymentAssets(t *testing.T) { }) } } + +func TestAutoEnsureManagerSpec(t *testing.T) { + for _, enabled := range []bool{false, true} { + a := &App{} + a.Config.Volumes.AutoEnsure = enabled + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(t.TempDir(), "AUTH_LIST") + spec, err := a.buildManagerRuntimeSpec() + if err != nil { + t.Fatal(err) + } + want := []string{"/var/run/docker.sock:/var/run/docker.sock:rw"} + if enabled { + want = append(want, a.diskServiceDir()+":"+diskservice.ContainerDir+":ro") + } + if !reflect.DeepEqual(spec.Volumes, want) || spec.Privileged { + t.Fatalf("invalid manager spec: %+v", spec) + } + var cfg config.Config + if err := json.Unmarshal([]byte(strings.TrimPrefix(spec.Environment[0], "ENV=")), &cfg); err != nil { + t.Fatal(err) + } + if cfg.Volumes.AutoEnsure != enabled { + t.Fatal("auto-ensure was lost in transport") + } + } +} diff --git a/src/internal/ctl/app/service.go b/src/internal/ctl/app/service.go index 4512301..ea451ac 100644 --- a/src/internal/ctl/app/service.go +++ b/src/internal/ctl/app/service.go @@ -26,6 +26,13 @@ func (a *App) ServiceUp(params *cli.Parameters) error { if err := a.ensureDiskAll(); err != nil { return err } + if a.Config.Volumes.AutoEnsure { + if err := a.startDiskService(); err != nil { + return err + } + } else if err := a.stopDiskService(); err != nil { + return err + } if err := a.ensureRuntimeNetworks(); err != nil { return err } @@ -51,10 +58,12 @@ func (a *App) ServiceDown(params *cli.Parameters) error { if err := a.removeManagedContainers(); err != nil { return err } + if err := a.stopDiskService(); err != nil { + return err + } if err := a.removeManagedNetworks(); err != nil { return err } - log.Log(log.DETAIL_PREFIX, "Runtime services stopped.") return nil } diff --git a/src/internal/ctl/app/spec.go b/src/internal/ctl/app/spec.go index 0dec8f1..42aaca0 100644 --- a/src/internal/ctl/app/spec.go +++ b/src/internal/ctl/app/spec.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "github.com/elecbug/linuxus/src/internal/common/diskservice" "github.com/elecbug/linuxus/src/internal/ctl/spec" ) @@ -41,6 +42,10 @@ func (a *App) buildManagerRuntimeSpec() (spec.RuntimeContainerSpec, error) { if err != nil { return spec.RuntimeContainerSpec{}, fmt.Errorf("failed to marshal config to JSON: %w", err) } + volumes := []string{"/var/run/docker.sock:/var/run/docker.sock:rw"} + if a.Config.Volumes.AutoEnsure { + volumes = append(volumes, a.diskServiceDir()+":"+diskservice.ContainerDir+":ro") + } return spec.RuntimeContainerSpec{ Image: a.managerImageName(), @@ -48,10 +53,9 @@ func (a *App) buildManagerRuntimeSpec() (spec.RuntimeContainerSpec, error) { Environment: []string{ "ENV=" + string(env), "USER_IMAGE=" + a.userImageName(), + "DISK_SERVICE_SOCKET=" + diskservice.ContainerSocket, }, - Volumes: []string{ - "/var/run/docker.sock:/var/run/docker.sock:rw", - }, + Volumes: volumes, Restart: "unless-stopped", Networks: []string{ a.Config.ManagerService.Container.Network, diff --git a/src/internal/manager/config/config.go b/src/internal/manager/config/config.go index d39d7ef..89055bb 100644 --- a/src/internal/manager/config/config.go +++ b/src/internal/manager/config/config.go @@ -18,6 +18,8 @@ type ResourceLimits struct { // Config contains all runtime settings for the manager service. type Config struct { + AutoEnsure bool + DiskServiceSocket string // ListenAddr is the HTTP server bind address. ListenAddr string // UserImage is the Docker image used for user runtimes. diff --git a/src/internal/manager/handler/handle_up.go b/src/internal/manager/handler/handle_up.go index 039ffaa..07f1739 100644 --- a/src/internal/manager/handler/handle_up.go +++ b/src/internal/manager/handler/handle_up.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto/subtle" "encoding/json" "fmt" "log" @@ -13,13 +14,14 @@ import ( "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/common/diskservice" "github.com/elecbug/linuxus/src/internal/common/http_helper" "github.com/elecbug/linuxus/src/internal/common/packet" "github.com/elecbug/linuxus/src/internal/common/ruleset" "github.com/elecbug/linuxus/src/internal/common/subnet" - "github.com/elecbug/linuxus/src/internal/manager/config" ) // HandleUserUp handles user runtime preparation requests. @@ -32,6 +34,12 @@ func (s *Server) HandleUserUp(w http.ResponseWriter, r *http.Request) { return } + if s.cfg.ManagerSessionSecret != "" && subtle.ConstantTimeCompare( + []byte(r.Header.Get("X-Manager-Session-Secret")), []byte(s.cfg.ManagerSessionSecret), + ) != 1 { + http_helper.WriteJSONViaHTTP(w, http.StatusUnauthorized, packet.UserUpResponse{OK: false, Message: "unauthorized"}) + return + } var req packet.UserUpRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http_helper.WriteJSONViaHTTP(w, http.StatusBadRequest, packet.UserUpResponse{ @@ -81,6 +89,11 @@ func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (*pa return nil, fmt.Errorf("user_id contains invalid characters") } + s.prepareMu.Lock() + defer s.prepareMu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } containerName := s.cfg.UserContainerNamePrefix + userID if _, err := s.docker.ImageInspect(ctx, s.cfg.UserImage, client.ImageInspectWithRawResponse(nil)); err != nil { @@ -92,6 +105,11 @@ func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (*pa return nil, err } + if s.cfg.AutoEnsure && !running { + if err := diskservice.Ensure(ctx, s.diskClient, userID); err != nil { + return nil, fmt.Errorf("disk preparation failed: %w", err) + } + } if exists { networkName, subnet, err := s.ensureExistingContainerNetworkAndAuth(ctx, containerName) if err != nil { @@ -252,10 +270,11 @@ func (s *Server) createUserContainer(ctx context.Context, containerName, userID, } hostCfg := &container.HostConfig{ - Binds: []string{ - fmt.Sprintf("%s:/home/%s:rw", homeDir, s.cfg.ContainerRuntimeUser), - fmt.Sprintf("%s:%s:rw", s.cfg.HostShareDir, s.cfg.ContainerShareDir), - getReadonlyBind(userID, s.cfg), + // Missing sources must fail instead of creating an unlimited home directory. + Mounts: []mount.Mount{ + {Type: mount.TypeBind, Source: homeDir, Target: "/home/" + s.cfg.ContainerRuntimeUser}, + {Type: mount.TypeBind, Source: s.cfg.HostShareDir, Target: s.cfg.ContainerShareDir}, + {Type: mount.TypeBind, Source: s.cfg.HostReadonlyDir, Target: s.cfg.ContainerReadonlyDir, ReadOnly: userID != s.cfg.AdminUserID}, }, Tmpfs: map[string]string{ "/tmp": "rw,noexec,nosuid,nodev,size=64m", @@ -310,15 +329,6 @@ func (s *Server) createUserContainer(ctx context.Context, containerName, userID, return nil } -// getReadonlyBind returns readonly/shared bind mode based on user privilege. -func getReadonlyBind(userID string, cfg *config.Config) string { - if userID == cfg.AdminUserID { - return fmt.Sprintf("%s:%s:rw", cfg.HostReadonlyDir, cfg.ContainerReadonlyDir) - } else { - return fmt.Sprintf("%s:%s:ro", cfg.HostReadonlyDir, cfg.ContainerReadonlyDir) - } -} - // waitForContainerIP polls until a container obtains an IPv4 on the target network. func (s *Server) waitForContainerIP(ctx context.Context, containerName, networkName string) (string, error) { ticker := time.NewTicker(200 * time.Millisecond) diff --git a/src/internal/manager/handler/handle_up_test.go b/src/internal/manager/handler/handle_up_test.go new file mode 100644 index 0000000..b912233 --- /dev/null +++ b/src/internal/manager/handler/handle_up_test.go @@ -0,0 +1,241 @@ +package handler + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/common/diskservice" + "github.com/elecbug/linuxus/src/internal/common/packet" + "github.com/elecbug/linuxus/src/internal/manager/config" +) + +type runtimeDocker struct { + mu sync.Mutex + exists, running bool + mutations []string + hostConfig container.HostConfig + beforeMutation func() +} + +func (d *runtimeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + defer d.mu.Unlock() + path := strings.TrimPrefix(r.URL.Path, "/v1.47") + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + if d.beforeMutation != nil { + d.beforeMutation() + } + d.mutations = append(d.mutations, path) + } + switch { + case path == "/images/test-user/json": + json.NewEncoder(w).Encode(map[string]any{"Id": "image-id"}) + case path == "/containers/user_alice/json": + if !d.exists { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "No such container"}) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "Id": "user-id", "State": map[string]bool{"Running": d.running}, + "NetworkSettings": map[string]any{"Networks": map[string]any{"net_alice": map[string]string{"IPAddress": "10.10.0.2"}}}, + }) + case path == "/networks": + w.Write([]byte("[]")) + case path == "/networks/create": + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"Id":"network-id"}`)) + case path == "/networks/net_alice": + w.Write([]byte(`{"IPAM":{"Config":[{"Subnet":"10.10.0.0/24"}]},"Containers":{"auth-id":{"Name":"auth"}}}`)) + case path == "/containers/create": + var body struct{ HostConfig container.HostConfig } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), 400) + return + } + d.hostConfig = body.HostConfig + d.exists = true + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"Id":"user-id"}`)) + case path == "/containers/user-id/start" || path == "/containers/user_alice/start": + d.running = true + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected Docker API: "+r.Method+" "+path, http.StatusInternalServerError) + } +} + +func runtimeTestServer(t *testing.T, d *runtimeDocker, diskHandler http.HandlerFunc) *Server { + t.Helper() + dockerHTTP := httptest.NewServer(d) + t.Cleanup(dockerHTTP.Close) + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(dockerHTTP.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(dockerHTTP.Client())) + if err != nil { + t.Fatal(err) + } + dir, err := os.MkdirTemp("", "linuxus-manager-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + socket := filepath.Join(dir, "disk.sock") + if diskHandler != nil { + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + httpServer := &http.Server{Handler: diskHandler} + t.Cleanup(func() { httpServer.Close() }) + go httpServer.Serve(listener) + } + s := &Server{docker: cli, diskClient: diskservice.NewClient(socket, time.Second), cfg: &config.Config{ + AutoEnsure: true, UserImage: "test-user", UserContainerNamePrefix: "user_", NetworkPrefix: "net_", + BaseIP: "10.10.0.0", AuthContainerName: "auth", AdminUserID: "admin", ManagerWaitTime: time.Second, + HostHomesDir: "/volumes/homes", HostShareDir: "/volumes/share", HostReadonlyDir: "/volumes/readonly", + ContainerRuntimeUser: "user", ContainerShareDir: "/home/share", ContainerReadonlyDir: "/home/readonly", + }} + t.Cleanup(func() { s.Close() }) + return s +} + +func TestAutoEnsureRuntime(t *testing.T) { + for _, tc := range []struct { + name string + exists, running, enabled bool + diskStatus, wantCalls, wantMutations int + wantError bool + }{ + {"new", false, false, true, 200, 1, 3, false}, + {"stopped", true, false, true, 200, 1, 1, false}, + {"running", true, true, true, 500, 0, 0, false}, + {"disabled", false, false, false, 500, 0, 3, false}, + {"disk_failure", false, false, true, 500, 1, 0, true}, + {"stopped_disk_failure", true, false, true, 500, 1, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + d := &runtimeDocker{exists: tc.exists, running: tc.running, beforeMutation: func() { + if tc.enabled && calls.Load() == 0 { + t.Error("Docker mutated before disk preparation") + } + }} + s := runtimeTestServer(t, d, func(w http.ResponseWriter, r *http.Request) { + var request packet.UserUpRequest + if r.Method != "POST" || r.URL.Path != "/ensure-disk" { + t.Error("invalid disk request") + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.UserID != "alice" { + t.Error("invalid user") + } + calls.Add(1) + w.WriteHeader(tc.diskStatus) + }) + s.cfg.AutoEnsure = tc.enabled + resp, err := s.ensureUserRuntimeReady(context.Background(), "alice") + if (err != nil) != tc.wantError { + t.Fatalf("response=%v error=%v", resp, err) + } + if !tc.wantError && (resp == nil || !resp.OK) { + t.Fatalf("not ready: %v", resp) + } + if got := int(calls.Load()); got != tc.wantCalls { + t.Fatalf("disk calls=%d, want %d", got, tc.wantCalls) + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != tc.wantMutations { + t.Fatalf("Docker mutations: %v", d.mutations) + } + if !tc.exists && !tc.wantError { + if len(d.hostConfig.Binds) != 0 || len(d.hostConfig.Mounts) != 3 { + t.Fatalf("unsafe binds: %+v", d.hostConfig) + } + for _, m := range d.hostConfig.Mounts { + if m.Type != mount.TypeBind || (m.BindOptions != nil && m.BindOptions.CreateMountpoint) { + t.Fatalf("mount creates missing source: %+v", m) + } + } + if !d.hostConfig.Mounts[2].ReadOnly { + t.Fatal("regular user can write readonly share") + } + } + }) + } +} + +func TestAutoEnsureUnavailableAndTimeoutPreventStart(t *testing.T) { + for _, timeout := range []bool{false, true} { + t.Run(map[bool]string{false: "unavailable", true: "timeout"}[timeout], func(t *testing.T) { + d := &runtimeDocker{} + var handler http.HandlerFunc + if timeout { + handler = func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() } + } + s := runtimeTestServer(t, d, handler) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + if _, err := s.ensureUserRuntimeReady(ctx, "alice"); err == nil { + t.Fatal("expected disk service error") + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != 0 { + t.Fatalf("Docker changed after disk failure: %v", d.mutations) + } + }) + } +} + +func TestConcurrentUserUpPreparesOnce(t *testing.T) { + var calls atomic.Int32 + d := &runtimeDocker{} + s := runtimeTestServer(t, d, func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(200) }) + var wg sync.WaitGroup + for range 6 { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := s.ensureUserRuntimeReady(ctx, "alice"); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if calls.Load() != 1 { + t.Fatalf("prepared disk %d times", calls.Load()) + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != 3 { + t.Fatalf("created duplicate runtime: %v", d.mutations) + } +} + +func TestUserUpRequiresSharedSecret(t *testing.T) { + s := &Server{cfg: &config.Config{ManagerSessionSecret: "expected"}} + for _, secret := range []string{"", "incorrect"} { + r := httptest.NewRequest("POST", "/user/up", strings.NewReader(`{"user_id":"alice"}`)) + r.Header.Set("X-Manager-Session-Secret", secret) + w := httptest.NewRecorder() + s.HandleUserUp(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status=%d", w.Code) + } + } +} diff --git a/src/internal/manager/handler/server.go b/src/internal/manager/handler/server.go index 30bb17f..b0218c8 100644 --- a/src/internal/manager/handler/server.go +++ b/src/internal/manager/handler/server.go @@ -13,6 +13,7 @@ import ( "time" "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/common/diskservice" "github.com/elecbug/linuxus/src/internal/common/http_helper" "github.com/elecbug/linuxus/src/internal/manager/config" ) @@ -26,6 +27,10 @@ type Server struct { // cfg is the active runtime configuration. cfg *config.Config + // prepareMu serializes disk preparation and runtime allocation. + prepareMu sync.Mutex + diskClient *http.Client + // mu protects runtimes map access. mu sync.Mutex // runtimes tracks active user runtimes by sanitized user ID. @@ -52,8 +57,9 @@ func NewServer(cfg *config.Config) (*Server, error) { } return &Server{ - docker: cli, - cfg: cfg, + docker: cli, + diskClient: diskservice.NewClient(cfg.DiskServiceSocket, cfg.ManagerWaitTime), + cfg: cfg, mu: sync.Mutex{}, runtimes: make(map[string]*RuntimeState), @@ -108,6 +114,9 @@ func (s *Server) Close() error { return nil } + if s.diskClient != nil { + s.diskClient.CloseIdleConnections() + } return s.docker.Close() } diff --git a/src/internal/manager/server.go b/src/internal/manager/server.go index 235b0da..097d2a3 100644 --- a/src/internal/manager/server.go +++ b/src/internal/manager/server.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "time" ctl_config "github.com/elecbug/linuxus/src/internal/common/config" @@ -51,6 +52,10 @@ func parseConfigFromEnv() (*config.Config, error) { return nil, fmt.Errorf("USER_IMAGE environment variable is required") } + diskSocket := os.Getenv("DISK_SERVICE_SOCKET") + if cfg.Volumes.AutoEnsure && !filepath.IsAbs(diskSocket) { + return nil, fmt.Errorf("DISK_SERVICE_SOCKET must be absolute when volumes.auto-ensure is enabled") + } managerWaitTime, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout) if err != nil { return nil, fmt.Errorf("invalid ManagerService.AuthService.ConnectionTimeout: %v", err) @@ -84,6 +89,8 @@ func parseConfigFromEnv() (*config.Config, error) { } return &config.Config{ + AutoEnsure: cfg.Volumes.AutoEnsure, + DiskServiceSocket: diskSocket, ListenAddr: ":5959", UserImage: userImage, UserContainerNamePrefix: cfg.UserService.Container.NamePrefix, diff --git a/src/internal/manager/server_test.go b/src/internal/manager/server_test.go new file mode 100644 index 0000000..0f1dc68 --- /dev/null +++ b/src/internal/manager/server_test.go @@ -0,0 +1,51 @@ +package manager + +import ( + "encoding/json" + "os" + "testing" + + ctlconfig "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/diskservice" + "gopkg.in/yaml.v3" +) + +func TestAutoEnsureConfigFromEnv(t *testing.T) { + data, err := os.ReadFile("../../../cfg/config.yml") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + enabled bool + socket string + wantError bool + }{ + {"enabled", true, diskservice.ContainerSocket, false}, + {"disabled", false, "", false}, + {"missing_socket", true, "", true}, + {"relative_socket", true, "disk.sock", true}, + } { + t.Run(tc.name, func(t *testing.T) { + var cfg ctlconfig.Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + cfg.Volumes.AutoEnsure = tc.enabled + env, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + t.Setenv("ENV", string(env)) + t.Setenv("USER_IMAGE", "test-user") + t.Setenv("DISK_SERVICE_SOCKET", tc.socket) + parsed, err := parseConfigFromEnv() + if (err != nil) != tc.wantError { + t.Fatalf("config=%+v error=%v", parsed, err) + } + if !tc.wantError && (parsed.AutoEnsure != tc.enabled || parsed.DiskServiceSocket != tc.socket) { + t.Fatalf("lost setting: %+v", parsed) + } + }) + } +} From 6a512b31c91c0d0d6951562f4d365499c25cd2b9 Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 01:23:44 +0900 Subject: [PATCH 3/9] feat: refactor container management and enhance error handling - Improved the ensureContainer function to validate local settings before removing existing containers. - Added rollback mechanism for container creation failures to ensure resources are cleaned up properly. - Introduced runtimeLock to serialize runtime changes and allow cancellation of queued requests. - Enhanced error handling in service management to capture and report multiple errors during cleanup. - Implemented tests for container creation validation, rollback scenarios, and network allocation. - Updated network management to respect Docker's address ranges and prevent collisions. - Refactored session state management to handle out-of-order reports and maintain idle deadlines. - Added comprehensive tests for lifecycle management and network allocation to ensure robustness. --- doc/USAGE.md | 24 +++ src/cmd/ctl/main.go | 35 +++- src/cmd/ctl/main_test.go | 32 ++++ src/internal/auth/handler/app.go | 36 +--- src/internal/auth/handler/handle_login.go | 45 ++--- src/internal/auth/handler/handle_logout.go | 18 +- src/internal/auth/handler/handle_signup.go | 28 +-- src/internal/auth/handler/handle_terminal.go | 89 +++++---- src/internal/auth/handler/manager_client.go | 4 +- src/internal/auth/handler/request_test.go | 77 ++++++++ src/internal/auth/handler/session_cookie.go | 85 ++++++++ .../auth/handler/session_cookie_test.go | 51 +++++ src/internal/auth/handler/session_test.go | 67 +++++++ .../auth/handler/terminal_proxy_test.go | 172 +++++++++++++++++ src/internal/auth/server.go | 4 + src/internal/common/config/paths.go | 93 +++++++++ src/internal/common/config/routes.go | 41 ++++ src/internal/common/config/routes_test.go | 28 +++ src/internal/common/config/validate.go | 40 ++-- src/internal/common/config/validate_test.go | 74 +++++++ src/internal/common/convert/convert.go | 18 +- src/internal/common/convert/convert_test.go | 35 ++++ src/internal/common/subnet/subnet.go | 23 ++- src/internal/common/subnet/subnet_test.go | 56 ++++++ src/internal/common/system_api/api.go | 1 + src/internal/common/system_api/linux.go | 43 ++--- src/internal/common/system_api/linux_test.go | 25 +++ src/internal/common/system_api/mounts.go | 84 ++++++++ src/internal/common/system_api/mounts_test.go | 62 ++++++ src/internal/common/system_api/unsupported.go | 4 + src/internal/common/user/file_update.go | 67 +++++++ src/internal/common/user/file_update_test.go | 129 +++++++++++++ src/internal/common/user/user.go | 181 +++++++++--------- src/internal/common/user/user_test.go | 119 ++++++++++++ src/internal/ctl/app/config.go | 25 ++- src/internal/ctl/app/disk.go | 124 +++++------- src/internal/ctl/app/disk_service_test.go | 41 ++++ src/internal/ctl/app/disk_test.go | 110 +++++++++++ src/internal/ctl/app/docker_container.go | 47 +++-- src/internal/ctl/app/docker_container_test.go | 111 +++++++++++ src/internal/ctl/app/runtime_test.go | 39 ++++ src/internal/ctl/app/service.go | 8 +- src/internal/ctl/cli/cli.go | 60 +++--- .../manager/handler/handle_session.go | 18 +- src/internal/manager/handler/handle_up.go | 154 +++++++++------ .../manager/handler/handle_up_test.go | 86 ++++++++- .../manager/handler/lifecycle_test.go | 113 +++++++++++ .../handler/network_allocation_test.go | 55 ++++++ src/internal/manager/handler/runtime_lock.go | 30 +++ src/internal/manager/handler/server.go | 65 +++++-- 50 files changed, 2492 insertions(+), 484 deletions(-) create mode 100644 src/cmd/ctl/main_test.go create mode 100644 src/internal/auth/handler/request_test.go create mode 100644 src/internal/auth/handler/session_cookie.go create mode 100644 src/internal/auth/handler/session_cookie_test.go create mode 100644 src/internal/auth/handler/session_test.go create mode 100644 src/internal/auth/handler/terminal_proxy_test.go create mode 100644 src/internal/common/config/paths.go create mode 100644 src/internal/common/config/routes.go create mode 100644 src/internal/common/config/routes_test.go create mode 100644 src/internal/common/convert/convert_test.go create mode 100644 src/internal/common/subnet/subnet_test.go create mode 100644 src/internal/common/system_api/mounts.go create mode 100644 src/internal/common/system_api/mounts_test.go create mode 100644 src/internal/common/user/file_update.go create mode 100644 src/internal/common/user/file_update_test.go create mode 100644 src/internal/ctl/app/docker_container_test.go create mode 100644 src/internal/manager/handler/lifecycle_test.go create mode 100644 src/internal/manager/handler/network_allocation_test.go create mode 100644 src/internal/manager/handler/runtime_lock.go diff --git a/doc/USAGE.md b/doc/USAGE.md index 2da2714..a29af93 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -99,6 +99,24 @@ and does not create the account automatically. Host directories can also point to an external storage location through absolute YAML paths or root-level `data/` and `volumes/` symlinks. Manager passes the resolved host paths to Docker; it does not need its own copies of the volumes. +The home, shared, and readonly host paths must not overlap or resolve to the +filesystem root. Keep `AUTH_LIST` outside these disk directories and images. +Connection timeouts must be positive; a cleanup timeout of `0s` disables idle +cleanup, and disk sizes must be greater than 1 MiB. +Unknown YAML keys (including `auto_ensure` instead of `auto-ensure`) and multiple +YAML documents are rejected. Auth route paths must be distinct, clean paths +without leading/trailing slashes or wildcard patterns; `static/` and +`favicon.ico` are reserved. Nested paths such as `auth/login` are supported. +Docker subnet allocation checks all existing Docker IPAM ranges to avoid +conflicts with other projects. Trusted proxy CIDRs support both IPv4 and IPv6. + +When HTTPS terminates at a reverse proxy, include its source address in +`auth_service.security.trusted_proxies` and configure it to overwrite +`X-Forwarded-Proto` with the external scheme (`https` or `http`). Auth then sets +`Secure` on HTTPS session cookies and preserves that scheme for the terminal. +The Auth session cookie is removed before forwarding to a user's container. + + The legacy `manager_service.container.{homes_dir,share_dir,readonly_dir}` fields are retained for configuration compatibility but are no longer mounted into Manager. @@ -154,6 +172,12 @@ A **Signup** link will appear on the login page. --- +Session cookies expire after 12 hours and are checked against the current account +on each authenticated request. Removing or changing an account invalidates its +previous cookies. Upgrading from the older cookie format requires users to log in +again once. CLI and Auth account updates use the same file lock while preserving +the auth file's Docker bind mount. + ### 4.2 User Registration Flow 1. User opens the login page diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index 0535951..ea32b47 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -5,9 +5,11 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/elecbug/linuxus/src/internal/auth" "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/ruleset" "github.com/elecbug/linuxus/src/internal/ctl/app" "github.com/elecbug/linuxus/src/internal/ctl/cli" "github.com/elecbug/linuxus/src/internal/ctl/log" @@ -181,8 +183,37 @@ func parseArgs(bin string, args []string) (Options, error) { params = append(params, args[1:]...) } - result.Params = cli.ParseParams(params) - + parsed, err := cli.ParseParams(params) + if err != nil { + return result, err + } + result.Params = parsed + switch result.Option { + case UP, DOWN, RESTART, HELP, SERVE_DISKS: + if len(params) != 0 { + return result, fmt.Errorf("%s does not accept arguments", args[0]) + } + case PS: + if len(parsed.Params) != 0 { + return result, fmt.Errorf("ps only accepts container, network, all, c, n, or a") + } + switch strings.ToLower(parsed.MainParam) { + case "", "container", "network", "all", "c", "n", "a": + default: + return result, fmt.Errorf("ps only accepts container, network, all, c, n, or a") + } + case ADD_USER, REMOVE_USER, CLEAN_VOLUME, ENSURE_DISK: + if parsed.MainParam != "" || len(parsed.Params) != 1 { + return result, fmt.Errorf("%s requires exactly one --user or supported --all option", args[0]) + } + if id, ok := parsed.Params["user"]; ok { + if !ruleset.AllowedUserID(id) { + return result, fmt.Errorf("invalid user ID: %q", id) + } + } else if result.Option == ADD_USER || result.Option == REMOVE_USER { + return result, fmt.Errorf("%s requires --user ", args[0]) + } + } return result, nil } diff --git a/src/cmd/ctl/main_test.go b/src/cmd/ctl/main_test.go new file mode 100644 index 0000000..77df899 --- /dev/null +++ b/src/cmd/ctl/main_test.go @@ -0,0 +1,32 @@ +package main + +import "testing" + +func TestRejectAmbiguousArguments(t *testing.T) { + for _, args := range [][]string{ + {"ensure-disk", "--user"}, {"clean-volume", "--user"}, {"remove-user", "--user"}, + {"clean-volume", "--user", "alice", "--user", "bob"}, + {"ensure-disk", "--user", "alice", "-u", "bob"}, + {"ensure-disk", "--all", "--user", "alice"}, + {"ps", "unknown"}, {"ps", "container", "extra"}, {"up", "extra"}, {"help", "extra"}, + {"serve-disks", "--all"}, {"add-user", "--unknown", "alice"}, + {"clean-volume", "--all=false"}, {"add-user", "--user", "../outside"}, + } { + if _, err := parseArgs("linuxusctl", args); err == nil { + t.Errorf("accepted %q", args) + } + } +} + +func TestValidArguments(t *testing.T) { + for _, args := range [][]string{ + {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, + {"ensure-disk", "--all"}, {"clean-volume", "-a"}, + {"add-user", "--user", "true"}, {"remove-user", "-u", "alice"}, + {"ensure-disk", "--user=alice"}, + } { + if _, err := parseArgs("linuxusctl", args); err != nil { + t.Errorf("rejected %q: %v", args, err) + } + } +} diff --git a/src/internal/auth/handler/app.go b/src/internal/auth/handler/app.go index 537fb78..9c598ab 100644 --- a/src/internal/auth/handler/app.go +++ b/src/internal/auth/handler/app.go @@ -1,8 +1,6 @@ package handler import ( - "crypto/hmac" - "encoding/base64" "html/template" "log" "net" @@ -143,6 +141,9 @@ func NewApp(config *AppConfig) *App { timeout = 10 * time.Second } + if config.Users == nil { + config.Users = make(map[string]string) + } app := &App{ users: config.Users, authListFile: config.AuthListFile, @@ -217,7 +218,8 @@ func (a *App) SignupPath() string { // Start launches the HTTP server using the configured route multiplexer. func (a *App) Start(addr string) error { log.Printf("Auth server listening on %s", addr) - return http.ListenAndServe(addr, a.mux) + server := &http.Server{Addr: addr, Handler: a.mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second} + return server.ListenAndServe() } // Stop signals background maintenance routines to terminate. @@ -284,31 +286,3 @@ func (a *App) evictStaleEntries() { } } } - -// getSessionID validates the signed session cookie and returns the user ID. -func (a *App) getSessionID(r *http.Request) (string, bool) { - cookie, err := r.Cookie("session") - if err != nil { - return "", false - } - - raw, err := base64.StdEncoding.DecodeString(cookie.Value) - if err != nil { - return "", false - } - - parts := strings.SplitN(string(raw), "|", 2) - if len(parts) != 2 { - return "", false - } - - id := parts[0] - signature := parts[1] - - expected := a.sign(id) - if !hmac.Equal([]byte(signature), []byte(expected)) { - return "", false - } - - return id, true -} diff --git a/src/internal/auth/handler/handle_login.go b/src/internal/auth/handler/handle_login.go index 2e31c2c..935bb08 100644 --- a/src/internal/auth/handler/handle_login.go +++ b/src/internal/auth/handler/handle_login.go @@ -2,9 +2,6 @@ package handler import ( "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" "net" "net/http" "strconv" @@ -34,6 +31,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { return case http.MethodPost: + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) if err := r.ParseForm(); err != nil { a.renderError(w, "Bad request", http.StatusBadRequest) return @@ -77,7 +75,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { // 6) Success a.clearFail(ip, id) - a.setSessionCookie(w, id) + a.setSessionCookie(w, r, id, hash) http.Redirect(w, r, "/"+a.servicePath+"/", http.StatusSeeOther) return @@ -134,29 +132,6 @@ func (a *App) renderError(w http.ResponseWriter, errMsg string, statusCode int) _, _ = buf.WriteTo(w) } -// setSessionCookie creates and sets a signed session cookie for the given user. -func (a *App) setSessionCookie(w http.ResponseWriter, id string) { - signature := a.sign(id) - payload := id + "|" + signature - value := base64.StdEncoding.EncodeToString([]byte(payload)) - - http.SetCookie(w, &http.Cookie{ - Name: "session", - Value: value, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(12 * time.Hour), - }) -} - -// sign creates a base64 HMAC signature for a session value. -func (a *App) sign(value string) string { - mac := hmac.New(sha256.New, a.sessionKey) - mac.Write([]byte(value)) - return base64.StdEncoding.EncodeToString(mac.Sum(nil)) -} - // recordFail updates per-IP and optional per-user failure counters and lock states. func (a *App) recordFail(ip, id string, trackUser bool) { a.mu.Lock() @@ -214,11 +189,21 @@ func (a *App) clientIP(r *http.Request) string { if a.isTrustedProxy(remoteHost) { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") - return strings.TrimSpace(parts[0]) + // Walk from the nearest hop. Entries before the first untrusted + // address may have been supplied by the client itself. + for i := len(parts) - 1; i >= 0; i-- { + ip := net.ParseIP(strings.TrimSpace(parts[i])) + if ip == nil { + return remoteHost + } + if !a.isTrustedProxy(ip.String()) || i == 0 { + return ip.String() + } + } } - if xrip := r.Header.Get("X-Real-IP"); xrip != "" { - return strings.TrimSpace(xrip) + if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil { + return ip.String() } } diff --git a/src/internal/auth/handler/handle_logout.go b/src/internal/auth/handler/handle_logout.go index fcfb40a..3b5d98d 100644 --- a/src/internal/auth/handler/handle_logout.go +++ b/src/internal/auth/handler/handle_logout.go @@ -1,29 +1,19 @@ package handler import ( - "log" "net/http" ) -// handleLogout clears the local session state, reports logout to manager, -// removes the session cookie, and redirects to login. +// handleLogout removes this browser's cookie. Other open terminals keep their +// session counts until their WebSocket connections actually close. func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { - id, ok := a.getSessionID(r) - if ok { - a.sessionMu.Lock() - delete(a.activeSessions, id) - a.sessionMu.Unlock() - - if err := a.reportSessionState(id, 0); err != nil { - log.Printf("failed to report logout session state for %s: %v", id, err) - } - } - http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", HttpOnly: true, + Secure: a.requestScheme(r) == "https", + SameSite: http.SameSiteLaxMode, MaxAge: -1, }) diff --git a/src/internal/auth/handler/handle_signup.go b/src/internal/auth/handler/handle_signup.go index 9308c77..d7bb2db 100644 --- a/src/internal/auth/handler/handle_signup.go +++ b/src/internal/auth/handler/handle_signup.go @@ -12,14 +12,6 @@ import ( // handleSignup processes GET and POST requests to the signup endpoint for user registration. func (a *App) handleSignup(w http.ResponseWriter, r *http.Request) { - a.usersMu.Lock() - defer a.usersMu.Unlock() - err := user.SyncUsers(a.users, a.authListFile) - if err != nil { - a.renderError(w, "Failed to load user data", http.StatusInternalServerError) - return - } - if !a.allowSignup { a.renderError(w, "User signup is currently disabled.", http.StatusForbidden) return @@ -31,6 +23,7 @@ func (a *App) handleSignup(w http.ResponseWriter, r *http.Request) { return case http.MethodPost: + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) if err := r.ParseForm(); err != nil { a.renderError(w, "Bad request", http.StatusBadRequest) return @@ -56,15 +49,28 @@ func (a *App) handleSignup(w http.ResponseWriter, r *http.Request) { return } - if _, exists := a.users[id]; exists { - a.renderSignup(w, "This ID is already registered.") + if len(password) > 72 { + a.renderSignup(w, "Password must be at most 72 bytes.") return } - if err := user.AddUser(a.authListFile, a.users, id, password); err != nil { + // Read request bodies before taking the credential mutex: a slow + // signup request must not block other users from signing in. + a.usersMu.Lock() + err := user.SyncUsers(a.users, a.authListFile) + _, exists := a.users[id] + if err == nil && !exists { + err = user.AddUser(a.authListFile, a.users, id, password) + } + a.usersMu.Unlock() + if err != nil { a.renderSignup(w, "Failed to create user.") return } + if exists { + a.renderSignup(w, "This ID is already registered.") + return + } http.Redirect(w, r, "/"+a.loginPath, http.StatusSeeOther) return diff --git a/src/internal/auth/handler/handle_terminal.go b/src/internal/auth/handler/handle_terminal.go index 32ee6de..bcf8149 100644 --- a/src/internal/auth/handler/handle_terminal.go +++ b/src/internal/auth/handler/handle_terminal.go @@ -7,6 +7,7 @@ import ( "net/http/httputil" "net/url" "strings" + "time" ) // handleTerminalRedirect normalizes terminal route access for authenticated users. @@ -42,36 +43,11 @@ func (a *App) handleTerminalProxy(w http.ResponseWriter, r *http.Request) { return } - proxy := httputil.NewSingleHostReverseProxy(target) + proxy := a.terminalProxy(target) - isWS := isWebSocketRequest(r) - if isWS { - go a.markSessionStart(id) - defer func(sessionID string) { - go a.markSessionEnd(sessionID) - }(id) - } - - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalDirector(req) - - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.Host = target.Host - - newPath := strings.TrimPrefix(req.URL.Path, "/"+a.terminalPath) - if newPath == "" { - newPath = "/" - } - if !strings.HasPrefix(newPath, "/") { - newPath = "/" + newPath - } - req.URL.Path = newPath - req.URL.RawPath = "" - - req.Header.Set("X-Forwarded-Host", r.Host) - req.Header.Set("X-Forwarded-Proto", "http") + if isWebSocketRequest(r) { + a.markSessionStart(id) + defer a.markSessionEnd(id) } proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { @@ -82,12 +58,43 @@ func (a *App) handleTerminalProxy(w http.ResponseWriter, r *http.Request) { proxy.ServeHTTP(w, r) } -// isWebSocketRequest checks if the incoming HTTP request is a WebSocket upgrade request. -func isWebSocketRequest(r *http.Request) bool { - connection := strings.ToLower(r.Header.Get("Connection")) - upgrade := strings.ToLower(r.Header.Get("Upgrade")) +// terminalProxy strips Auth's credential before crossing into a user's runtime. +func (a *App) terminalProxy(target *url.URL) *httputil.ReverseProxy { + return &httputil.ReverseProxy{Rewrite: func(p *httputil.ProxyRequest) { + p.SetURL(target) + path := strings.TrimPrefix(p.Out.URL.Path, "/"+a.terminalPath) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + p.Out.URL.Path = path + p.Out.URL.RawPath = "" + + p.SetXForwarded() + p.Out.Header.Set("X-Forwarded-For", a.clientIP(p.In)) + p.Out.Header.Set("X-Forwarded-Proto", a.requestScheme(p.In)) + cookies := p.Out.Cookies() + p.Out.Header.Del("Cookie") + for _, cookie := range cookies { + if cookie.Name != "session" { + p.Out.AddCookie(cookie) + } + } + }} +} - return strings.Contains(connection, "upgrade") && upgrade == "websocket" +// isWebSocketRequest matches complete Connection tokens, including repeated headers. +func isWebSocketRequest(r *http.Request) bool { + if !strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") { + return false + } + for _, value := range r.Header.Values("Connection") { + for _, token := range strings.Split(value, ",") { + if strings.EqualFold(strings.TrimSpace(token), "upgrade") { + return true + } + } + } + return false } // markSessionStart increments active session count and reports it to manager. @@ -95,11 +102,10 @@ func (a *App) markSessionStart(id string) { a.sessionMu.Lock() a.activeSessions[id]++ current := a.activeSessions[id] + observedAt := time.Now() a.sessionMu.Unlock() - if err := a.reportSessionState(id, current); err != nil { - log.Printf("failed to report session start for %s: %v", id, err) - } + go a.reportSessionSnapshot(id, current, observedAt) } // markSessionEnd decrements the active session count for a user and reports the updated state to the manager. @@ -109,9 +115,14 @@ func (a *App) markSessionEnd(id string) { a.activeSessions[id]-- } current := a.activeSessions[id] + observedAt := time.Now() a.sessionMu.Unlock() - if err := a.reportSessionState(id, current); err != nil { - log.Printf("failed to report session end for %s: %v", id, err) + go a.reportSessionSnapshot(id, current, observedAt) +} + +func (a *App) reportSessionSnapshot(id string, active int, observedAt time.Time) { + if err := a.reportSessionState(id, active, observedAt); err != nil { + log.Printf("failed to report session state for %s: %v", id, err) } } diff --git a/src/internal/auth/handler/manager_client.go b/src/internal/auth/handler/manager_client.go index 99e9ee7..75998a2 100644 --- a/src/internal/auth/handler/manager_client.go +++ b/src/internal/auth/handler/manager_client.go @@ -92,7 +92,7 @@ func (a *App) ensureUserContainerReady(ctx context.Context, userID string) (stri } // reportSessionState sends the current active session count for a user to the manager. -func (a *App) reportSessionState(id string, active int) error { +func (a *App) reportSessionState(id string, active int, observedAt time.Time) error { if a.managerBaseURL == "" { return nil } @@ -100,7 +100,7 @@ func (a *App) reportSessionState(id string, active int) error { payload := packet.SessionStateReport{ UserID: id, ActiveSessions: active, - ObservedAt: time.Now(), + ObservedAt: observedAt, } body, err := json.Marshal(payload) diff --git a/src/internal/auth/handler/request_test.go b/src/internal/auth/handler/request_test.go new file mode 100644 index 0000000..5852e08 --- /dev/null +++ b/src/internal/auth/handler/request_test.go @@ -0,0 +1,77 @@ +package handler + +import ( + "io" + "net" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestClientIPIgnoresForgedForwardedPrefix(t *testing.T) { + _, trusted, _ := net.ParseCIDR("10.0.0.0/8") + a := &App{trustedProxies: []*net.IPNet{trusted}} + for _, tc := range []struct{ remote, forwarded, want string }{ + {"10.0.0.1:8080", "192.0.2.99, 203.0.113.10", "203.0.113.10"}, + {"10.0.0.1:8080", "192.0.2.99, 203.0.113.10, 10.0.0.2", "203.0.113.10"}, + {"203.0.113.10:8080", "192.0.2.99", "203.0.113.10"}, + {"10.0.0.1:8080", "not-an-ip", "10.0.0.1"}, + } { + r := httptest.NewRequest("GET", "/login", nil) + r.RemoteAddr = tc.remote + r.Header.Set("X-Forwarded-For", tc.forwarded) + if got := a.clientIP(r); got != tc.want { + t.Fatalf("client IP=%q, want %q", got, tc.want) + } + } +} + +type delayedForm struct { + reader io.Reader + once sync.Once + reading, release chan struct{} +} + +func (b *delayedForm) Read(p []byte) (int, error) { + b.once.Do(func() { close(b.reading) }) + <-b.release + return b.reader.Read(p) +} +func (b *delayedForm) Close() error { return nil } + +func TestSlowSignupDoesNotHoldCredentialMutex(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:hash\n"), 0600); err != nil { + t.Fatal(err) + } + a := NewApp(&AppConfig{Users: map[string]string{}, AuthListFile: path, AllowSignup: true, LoginPath: "login", SignupPath: "signup"}) + defer a.Stop() + body := &delayedForm{reader: strings.NewReader("id=alice&password=password&confirm_password=password"), reading: make(chan struct{}), release: make(chan struct{})} + var once sync.Once + release := func() { once.Do(func() { close(body.release) }) } + defer release() + r := httptest.NewRequest("POST", "/signup", body) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + done := make(chan struct{}) + go func() { a.handleSignup(httptest.NewRecorder(), r); close(done) }() + select { + case <-body.reading: + case <-time.After(time.Second): + t.Fatal("signup body was not read") + } + if !a.usersMu.TryLock() { + t.Error("slow request blocked credential access") + } else { + a.usersMu.Unlock() + } + release() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("signup did not finish") + } +} diff --git a/src/internal/auth/handler/session_cookie.go b/src/internal/auth/handler/session_cookie.go new file mode 100644 index 0000000..69980b1 --- /dev/null +++ b/src/internal/auth/handler/session_cookie.go @@ -0,0 +1,85 @@ +package handler + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/elecbug/linuxus/src/internal/common/ruleset" + "github.com/elecbug/linuxus/src/internal/common/user" +) + +const sessionLifetime = 12 * time.Hour + +// The expiry is signed and verified on the server. Binding the signature to the +// current credential hash also invalidates cookies after password changes or +// account removal/recreation without placing the hash in the cookie itself. +func (a *App) setSessionCookie(w http.ResponseWriter, r *http.Request, id, credentialHash string) { + expires := time.Now().Add(sessionLifetime) + payload := id + "|" + strconv.FormatInt(expires.Unix(), 10) + signature := a.sign("session|" + payload + "|" + credentialHash) + http.SetCookie(w, &http.Cookie{ + Name: "session", Value: base64.StdEncoding.EncodeToString([]byte(payload + "|" + signature)), + Path: "/", HttpOnly: true, Secure: a.requestScheme(r) == "https", SameSite: http.SameSiteLaxMode, Expires: expires, + }) +} + +func (a *App) getSessionID(r *http.Request) (string, bool) { + cookie, err := r.Cookie("session") + if err != nil { + return "", false + } + raw, err := base64.StdEncoding.DecodeString(cookie.Value) + if err != nil { + return "", false + } + parts := strings.Split(string(raw), "|") + if len(parts) != 3 || !ruleset.AllowedUserID(parts[0]) { + return "", false + } + expires, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || time.Now().Unix() >= expires { + return "", false + } + users, err := user.LoadUsers(a.authListFile) + if err != nil { + return "", false + } + hash, exists := users[parts[0]] + if !exists { + return "", false + } + expected := a.sign("session|" + parts[0] + "|" + parts[1] + "|" + hash) + if !hmac.Equal([]byte(parts[2]), []byte(expected)) { + return "", false + } + return parts[0], true +} + +func (a *App) sign(value string) string { + mac := hmac.New(sha256.New, a.sessionKey) + mac.Write([]byte(value)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// Only an explicitly trusted immediate proxy may describe the external scheme. +// Multiple/comma-separated values are ambiguous and are not accepted. +func (a *App) requestScheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + values := r.Header.Values("X-Forwarded-Proto") + if a.isTrustedProxy(host) && len(values) == 1 && strings.EqualFold(strings.TrimSpace(values[0]), "https") { + return "https" + } + return "http" +} diff --git a/src/internal/auth/handler/session_cookie_test.go b/src/internal/auth/handler/session_cookie_test.go new file mode 100644 index 0000000..2b25b2d --- /dev/null +++ b/src/internal/auth/handler/session_cookie_test.go @@ -0,0 +1,51 @@ +package handler + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestSessionExpiryCredentialsAndSignature(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:original-hash\n"), 0600); err != nil { + t.Fatal(err) + } + a := &App{authListFile: path, sessionKey: []byte("test-secret")} + w := httptest.NewRecorder() + a.setSessionCookie(w, httptest.NewRequest("POST", "/login", nil), "alice", "original-hash") + cookie := w.Result().Cookies()[0] + check := func(cookie *http.Cookie, want bool) { + t.Helper() + r := httptest.NewRequest("GET", "/terminal/", nil) + r.AddCookie(cookie) + id, ok := a.getSessionID(r) + if ok != want || (ok && id != "alice") { + t.Fatalf("session accepted=%t, want %t", ok, want) + } + } + check(cookie, true) + expired := "alice|" + strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10) + check(&http.Cookie{Name: "session", Value: base64.StdEncoding.EncodeToString([]byte(expired + "|" + a.sign("session|"+expired+"|original-hash")))}, false) + raw, _ := base64.StdEncoding.DecodeString(cookie.Value) + tampered := strings.Replace(string(raw), "alice", "bob", 1) + check(&http.Cookie{Name: "session", Value: base64.StdEncoding.EncodeToString([]byte(tampered))}, false) + // Legacy cookies do not contain a verifiable expiry and require re-login. + check(&http.Cookie{Name: "session", Value: base64.StdEncoding.EncodeToString([]byte("alice|" + a.sign("alice")))}, false) + for _, contents := range []string{"", "alice:changed-hash\n"} { + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + check(cookie, false) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + check(cookie, false) +} diff --git a/src/internal/auth/handler/session_test.go b/src/internal/auth/handler/session_test.go new file mode 100644 index 0000000..47266a6 --- /dev/null +++ b/src/internal/auth/handler/session_test.go @@ -0,0 +1,67 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/elecbug/linuxus/src/internal/common/packet" +) + +func TestLogoutPreservesOtherOpenTerminals(t *testing.T) { + a := &App{activeSessions: map[string]int{"alice": 2}, sessionKey: []byte("test-secret"), loginPath: "login"} + cookie := httptest.NewRecorder() + a.setSessionCookie(cookie, httptest.NewRequest("POST", "/login", nil), "alice", "test-hash") + r := httptest.NewRequest("GET", "/logout", nil) + r.AddCookie(cookie.Result().Cookies()[0]) + w := httptest.NewRecorder() + a.handleLogout(w, r) + if a.activeSessions["alice"] != 2 { + t.Fatal("logout erased sessions that are still connected") + } + if w.Code != http.StatusSeeOther || len(w.Result().Cookies()) != 1 || w.Result().Cookies()[0].MaxAge >= 0 { + t.Fatal("logout did not clear the browser cookie") + } +} + +func TestSessionSnapshotsKeepEventOrder(t *testing.T) { + reports := make(chan packet.SessionStateReport, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var report packet.SessionStateReport + if err := json.NewDecoder(r.Body).Decode(&report); err != nil { + t.Error(err) + return + } + reports <- report + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + a := &App{activeSessions: map[string]int{}, managerBaseURL: server.URL, managerClient: server.Client(), sessionReportTimeout: time.Second} + a.markSessionStart("alice") + if a.activeSessions["alice"] != 1 { + t.Fatal("start count was deferred") + } + endBoundary := time.Now() + a.markSessionEnd("alice") + if a.activeSessions["alice"] != 0 { + t.Fatal("end count was deferred") + } + var start, end packet.SessionStateReport + for range 2 { + select { + case report := <-reports: + if report.ActiveSessions == 1 { + start = report + } else { + end = report + } + case <-time.After(2 * time.Second): + t.Fatal("missing session report") + } + } + if start.ObservedAt.IsZero() || end.ObservedAt.IsZero() || start.ObservedAt.After(endBoundary) || end.ObservedAt.Before(endBoundary) { + t.Fatalf("reports lost observation time: start=%v boundary=%v end=%v", start, endBoundary, end) + } +} diff --git a/src/internal/auth/handler/terminal_proxy_test.go b/src/internal/auth/handler/terminal_proxy_test.go new file mode 100644 index 0000000..4561889 --- /dev/null +++ b/src/internal/auth/handler/terminal_proxy_test.go @@ -0,0 +1,172 @@ +package handler + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestTerminalProxyHidesSessionAndPreservesRequest(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := r.Cookie("session"); err != http.ErrNoCookie { + t.Error("Auth credential reached user runtime") + } + if cookie, err := r.Cookie("terminal-setting"); err != nil || cookie.Value != "dark" { + t.Error("terminal cookie was lost") + } + if r.URL.RequestURI() != "/socket?arg=a%2Fb" { + t.Errorf("backend URI: %s", r.URL.RequestURI()) + } + if r.Header.Get("X-Forwarded-Proto") != "https" || r.Header.Get("X-Forwarded-Host") != "linuxus.example" { + t.Error("external origin was not preserved") + } + if r.Header.Get("X-Forwarded-For") != "203.0.113.8" { + t.Error("spoofed forwarding header reached runtime") + } + io.WriteString(w, "backend response") + })) + defer backend.Close() + target, _ := url.Parse(backend.URL) + a := &App{terminalPath: "terminal"} + r := httptest.NewRequest("GET", "https://linuxus.example/terminal/socket?arg=a%2Fb", nil) + r.RemoteAddr = "203.0.113.8:1234" + r.Header.Set("X-Forwarded-For", "192.0.2.1") + r.Header.Set("X-Forwarded-Proto", "http") + r.AddCookie(&http.Cookie{Name: "session", Value: "private"}) + r.AddCookie(&http.Cookie{Name: "terminal-setting", Value: "dark"}) + w := httptest.NewRecorder() + a.terminalProxy(target).ServeHTTP(w, r) + if w.Code != http.StatusOK || w.Body.String() != "backend response" { + t.Fatalf("proxy response: %d %s", w.Code, w.Body.String()) + } + if _, err := r.Cookie("session"); err != nil { + t.Error("proxy mutated the original request") + } +} + +func TestSecureCookieRespectsTrustedScheme(t *testing.T) { + _, trusted, _ := net.ParseCIDR("10.0.0.0/8") + a := &App{sessionKey: []byte("secret"), trustedProxies: []*net.IPNet{trusted}} + for _, tc := range []struct { + name, target, remote string + forwarded []string + secure bool + }{ + {"direct HTTP", "http://example/login", "203.0.113.8:1234", nil, false}, + {"direct HTTPS", "https://example/login", "203.0.113.8:1234", []string{"http"}, true}, + {"trusted TLS proxy", "http://example/login", "10.0.0.1:1234", []string{"https"}, true}, + {"untrusted header", "http://example/login", "203.0.113.8:1234", []string{"https"}, false}, + {"ambiguous list", "http://example/login", "10.0.0.1:1234", []string{"https, http"}, false}, + {"repeated header", "http://example/login", "10.0.0.1:1234", []string{"https", "http"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", tc.target, nil) + r.RemoteAddr = tc.remote + for _, v := range tc.forwarded { + r.Header.Add("X-Forwarded-Proto", v) + } + w := httptest.NewRecorder() + a.setSessionCookie(w, r, "alice", "hash") + if got := w.Result().Cookies()[0].Secure; got != tc.secure { + t.Fatalf("Secure=%t, want %t", got, tc.secure) + } + }) + } +} + +func TestWebSocketRequiresCompleteUpgradeToken(t *testing.T) { + for _, tc := range []struct { + connection []string + upgrade string + want bool + }{ + {[]string{"keep-alive, Upgrade"}, "websocket", true}, + {[]string{"keep-alive", "UPGRADE"}, "WebSocket", true}, + {[]string{"notupgrade"}, "websocket", false}, + {[]string{"upgrade"}, "h2c", false}, + } { + r := httptest.NewRequest("GET", "/terminal/", nil) + for _, v := range tc.connection { + r.Header.Add("Connection", v) + } + r.Header.Set("Upgrade", tc.upgrade) + if got := isWebSocketRequest(r); got != tc.want { + t.Errorf("headers %v/%s: got %t", tc.connection, tc.upgrade, got) + } + } +} + +func TestTerminalProxyPreservesBidirectionalUpgrade(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isWebSocketRequest(r) || r.URL.Path != "/ws" { + t.Errorf("upgrade request was changed: %s", r.URL) + } + if _, err := r.Cookie("session"); err != http.ErrNoCookie { + t.Error("session leaked on upgrade") + } + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + fmt.Fprint(rw, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + if err := rw.Flush(); err != nil { + t.Error(err) + return + } + data := make([]byte, 3) + if _, err := io.ReadFull(rw, data); err != nil { + t.Error(err) + return + } + if _, err := rw.Write(data); err != nil { + t.Error(err) + return + } + if err := rw.Flush(); err != nil { + t.Error(err) + } + })) + defer backend.Close() + target, _ := url.Parse(backend.URL) + a := &App{terminalPath: "terminal"} + front := httptest.NewServer(a.terminalProxy(target)) + defer front.Close() + frontURL, _ := url.Parse(front.URL) + conn, err := net.DialTimeout("tcp", frontURL.Host, time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := fmt.Fprint(conn, "GET /terminal/ws HTTP/1.1\r\nHost: example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nCookie: session=private\r\n\r\n"); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(conn) + response, err := http.ReadResponse(reader, &http.Request{Method: "GET"}) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("upgrade status=%d", response.StatusCode) + } + if _, err := io.WriteString(conn, "abc"); err != nil { + t.Fatal(err) + } + data := make([]byte, 3) + if _, err := io.ReadFull(reader, data); err != nil { + t.Fatal(err) + } + if string(data) != "abc" { + t.Fatalf("upgrade bytes changed: %q", data) + } +} diff --git a/src/internal/auth/server.go b/src/internal/auth/server.go index 1c49a6d..d736d5f 100644 --- a/src/internal/auth/server.go +++ b/src/internal/auth/server.go @@ -42,6 +42,10 @@ func parseConfig() (*handler.AppConfig, error) { return nil, fmt.Errorf("failed to parse ENV variable as JSON: %v", err) } + if err := config.ValidateAuthRoutes(&cfg); err != nil { + return nil, err + } + users, err := user.LoadUsers(cfg.AuthService.Mounts.ContainerAuthListPath) if err != nil { return nil, fmt.Errorf("failed to load users: %v", err) diff --git a/src/internal/common/config/paths.go b/src/internal/common/config/paths.go new file mode 100644 index 0000000..85fdac1 --- /dev/null +++ b/src/internal/common/config/paths.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// resolveStoragePath also resolves symlinked parents of paths not yet created. +func resolveStoragePath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err == nil { + return resolved, nil + } + if !os.IsNotExist(err) { + return "", err + } + if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("storage symlink has a missing target: %s", absolute) + } + parent := filepath.Dir(absolute) + if parent == absolute { + return "", err + } + resolvedParent, err := resolveStoragePath(parent) + if err != nil { + return "", err + } + return filepath.Join(resolvedParent, filepath.Base(absolute)), nil +} + +func containsPath(parent, path string) bool { + rel, err := filepath.Rel(parent, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +func validateVolumePaths(cfg *Config) error { + paths := []string{cfg.Volumes.Host.Homes, cfg.Volumes.Host.Share, cfg.Volumes.Host.Readonly} + for i, path := range paths { + if path == "" { + continue + } + resolved, err := resolveStoragePath(path) + if err != nil { + return fmt.Errorf("invalid volume path %q: %w", path, err) + } + if resolved == filepath.Dir(resolved) { + return fmt.Errorf("volume path cannot be the filesystem root: %s", path) + } + paths[i] = resolved + } + for i, path := range paths { + if path == "" { + continue + } + for _, other := range paths[i+1:] { + if other != "" && (containsPath(path, other) || containsPath(other, path)) { + return fmt.Errorf("home, shared and readonly volume paths must not overlap: %s and %s", path, other) + } + } + } + if cfg.AuthService.Mounts.HostAuthListPath != "" { + auth, err := resolveStoragePath(cfg.AuthService.Mounts.HostAuthListPath) + if err != nil { + return err + } + for _, path := range paths { + if path != "" && containsPath(path, auth) { + return fmt.Errorf("auth list must be outside managed disks and their images: %s", auth) + } + } + // A shared mount point may itself be a symlink; its image remains + // beside the configured path rather than beside the resolved target. + for _, mount := range []string{cfg.Volumes.Host.Share, cfg.Volumes.Host.Readonly} { + if mount == "" { + continue + } + image, err := resolveStoragePath(filepath.Clean(mount) + ".img") + if err != nil { + return err + } + if image == auth { + return fmt.Errorf("auth list cannot be a managed disk image: %s", auth) + } + } + } + return nil +} diff --git a/src/internal/common/config/routes.go b/src/internal/common/config/routes.go new file mode 100644 index 0000000..b5cecc4 --- /dev/null +++ b/src/internal/common/config/routes.go @@ -0,0 +1,41 @@ +package config + +import ( + "fmt" + "path" + "strings" +) + +// ValidateAuthRoutes rejects patterns that ServeMux would reinterpret, conflict +// with, or redirect away from. Nested paths such as auth/login remain supported. +func ValidateAuthRoutes(cfg *Config) error { + routes := []struct{ name, value string }{ + {"login", cfg.AuthService.ServiceURL.Login}, + {"logout", cfg.AuthService.ServiceURL.Logout}, + {"service", cfg.AuthService.ServiceURL.Service}, + {"terminal", cfg.AuthService.ServiceURL.Terminal}, + {"signup", cfg.AuthService.ServiceURL.Signup}, + } + seen := make(map[string]string) + for _, route := range routes { + value := route.value + valid := value != "" && !strings.HasPrefix(value, "/") && path.Clean("/"+value) == "/"+value + for _, char := range value { + if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || strings.ContainsRune("-_.~/", char)) { + valid = false + break + } + } + if !valid { + return fmt.Errorf("auth_service.service_url.%s must be a clean URL path without leading/trailing slashes, query, escapes, or wildcard patterns", route.name) + } + if value == "static" || strings.HasPrefix(value, "static/") || value == "favicon.ico" { + return fmt.Errorf("auth_service.service_url.%s conflicts with a reserved asset route", route.name) + } + if previous, exists := seen[value]; exists { + return fmt.Errorf("auth_service.service_url.%s duplicates %s", route.name, previous) + } + seen[value] = route.name + } + return nil +} diff --git a/src/internal/common/config/routes_test.go b/src/internal/common/config/routes_test.go new file mode 100644 index 0000000..3980337 --- /dev/null +++ b/src/internal/common/config/routes_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +func TestAuthRouteValidation(t *testing.T) { + for _, tc := range []struct { + path string + valid bool + }{ + {"login", true}, {"auth/login-v2", true}, {"login.v2", true}, + {"", false}, {"/login", false}, {"login/", false}, {"auth//login", false}, + {"../login", false}, {"auth/../login", false}, {"login?query=1", false}, + {"login%2Flogout", false}, {"login#fragment", false}, {"{user}", false}, + {"bad path", false}, {"logout", false}, {"static", false}, {"static/custom", false}, {"favicon.ico", false}, + } { + t.Run(tc.path, func(t *testing.T) { + var cfg Config + cfg.AuthService.ServiceURL.Login = tc.path + cfg.AuthService.ServiceURL.Logout = "logout" + cfg.AuthService.ServiceURL.Service = "service" + cfg.AuthService.ServiceURL.Terminal = "terminal" + cfg.AuthService.ServiceURL.Signup = "signup" + if err := ValidateAuthRoutes(&cfg); (err == nil) != tc.valid { + t.Fatalf("route=%q error=%v", tc.path, err) + } + }) + } +} diff --git a/src/internal/common/config/validate.go b/src/internal/common/config/validate.go index 8f2b50b..d11c5da 100644 --- a/src/internal/common/config/validate.go +++ b/src/internal/common/config/validate.go @@ -81,24 +81,8 @@ func ValidateConfig(cfg *Config) error { errMsgs = append(errMsgs, "auth_service.runtime.timezone is required") } - if cfg.AuthService.ServiceURL.Login == "" { - errMsgs = append(errMsgs, "auth_service.service_url.login is required") - } - - if cfg.AuthService.ServiceURL.Logout == "" { - errMsgs = append(errMsgs, "auth_service.service_url.logout is required") - } - - if cfg.AuthService.ServiceURL.Service == "" { - errMsgs = append(errMsgs, "auth_service.service_url.service is required") - } - - if cfg.AuthService.ServiceURL.Terminal == "" { - errMsgs = append(errMsgs, "auth_service.service_url.terminal is required") - } - - if cfg.AuthService.ServiceURL.Signup == "" { - errMsgs = append(errMsgs, "auth_service.service_url.signup is required") + if err := ValidateAuthRoutes(cfg); err != nil { + errMsgs = append(errMsgs, err.Error()) } if cfg.AuthService.Mounts.HostAuthListPath == "" { @@ -175,14 +159,14 @@ func ValidateConfig(cfg *Config) error { if cfg.ManagerService.UserManagement.CleanupTimeout == "" { errMsgs = append(errMsgs, "manager_service.user_management.cleanup_timeout is required") - } else if _, err := time.ParseDuration(cfg.ManagerService.UserManagement.CleanupTimeout); err != nil { - errMsgs = append(errMsgs, "manager_service.user_management.cleanup_timeout must be a valid duration string (e.g., 30s, 5m)") + } else if duration, err := time.ParseDuration(cfg.ManagerService.UserManagement.CleanupTimeout); err != nil || duration < 0 { + errMsgs = append(errMsgs, "manager_service.user_management.cleanup_timeout must be a non-negative duration (0 disables cleanup)") } if cfg.ManagerService.AuthService.ConnectionTimeout == "" { errMsgs = append(errMsgs, "manager_service.auth_service.connection_timeout is required") - } else if _, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout); err != nil { - errMsgs = append(errMsgs, "manager_service.auth_service.connection_timeout must be a valid duration string (e.g., 30s, 5m)") + } else if duration, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout); err != nil || duration <= 0 { + errMsgs = append(errMsgs, "manager_service.auth_service.connection_timeout must be a positive duration (e.g., 30s, 5m)") } if cfg.ManagerService.Security.SessionSecret == "" { @@ -233,8 +217,12 @@ func ValidateConfig(cfg *Config) error { if cfg.Volumes.DiskLimit == "" { errMsgs = append(errMsgs, "volumes.disk_limit is required") - } else if _, err := convert.BytesFromString(cfg.Volumes.DiskLimit); err != nil { - errMsgs = append(errMsgs, "volumes.disk_limit must be a valid size string (e.g., 1g, 512m)") + } else if size, err := convert.BytesFromString(cfg.Volumes.DiskLimit); err != nil || size <= 1024*1024 { + errMsgs = append(errMsgs, "volumes.disk_limit must be a valid size greater than 1MiB (e.g., 1g, 512m)") + } + + if err := validateVolumePaths(cfg); err != nil { + errMsgs = append(errMsgs, err.Error()) } if len(errMsgs) > 0 { @@ -278,8 +266,8 @@ func validateLimits(l Limits) error { disk, err := convert.BytesFromString(l.Disk) if err != nil { errMsgs = append(errMsgs, "disk limit must be a valid size string (e.g., 1g, 512m)") - } else if disk <= 0 { - errMsgs = append(errMsgs, "disk limit must be greater than zero") + } else if disk <= 1024*1024 { + errMsgs = append(errMsgs, "disk limit must be greater than 1MiB") } if l.Ulimits.Nofile.Soft <= 0 { diff --git a/src/internal/common/config/validate_test.go b/src/internal/common/config/validate_test.go index c5ec9ad..3b33b9c 100644 --- a/src/internal/common/config/validate_test.go +++ b/src/internal/common/config/validate_test.go @@ -1,6 +1,7 @@ package config import ( + "gopkg.in/yaml.v3" "os" "path/filepath" "testing" @@ -19,3 +20,76 @@ func TestUsablePathDoesNotCreateParentDirectories(t *testing.T) { t.Fatalf("validation created files: %v", entries) } } + +func TestRejectUnusableRuntimeSettings(t *testing.T) { + data, err := os.ReadFile("../../../../cfg/config.yml") + if err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*Config){ + "zero connection timeout": func(c *Config) { c.ManagerService.AuthService.ConnectionTimeout = "0s" }, + "negative connection timeout": func(c *Config) { c.ManagerService.AuthService.ConnectionTimeout = "-1s" }, + "negative cleanup timeout": func(c *Config) { c.ManagerService.UserManagement.CleanupTimeout = "-1s" }, + "empty shared disk": func(c *Config) { c.Volumes.DiskLimit = "0" }, + "too small user disk": func(c *Config) { c.UserService.Limits.User.Disk = "1M" }, + "root home directory": func(c *Config) { c.Volumes.Host.Homes = "/" }, + "overlapping disk directories": func(c *Config) { c.Volumes.Host.Share = filepath.Join(c.Volumes.Host.Homes, "share") }, + "credentials inside disk directory": func(c *Config) { + c.AuthService.Mounts.HostAuthListPath = filepath.Join(c.Volumes.Host.Homes, "AUTH_LIST") + }, + } { + t.Run(name, func(t *testing.T) { + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + root := t.TempDir() + cfg.AuthService.Mounts.HostAuthListPath = filepath.Join(root, "data", "AUTH_LIST") + cfg.Volumes.Host.Volumes = filepath.Join(root, "volumes") + cfg.Volumes.Host.Homes = filepath.Join(root, "volumes", "homes") + cfg.Volumes.Host.Share = filepath.Join(root, "volumes", "share") + cfg.Volumes.Host.Readonly = filepath.Join(root, "volumes", "readonly") + if err := ValidateConfig(&cfg); err != nil { + t.Fatalf("invalid fixture: %v", err) + } + change(&cfg) + if err := ValidateConfig(&cfg); err == nil { + t.Fatal("accepted unusable setting") + } + }) + } +} + +func TestVolumePathValidationResolvesSymlinks(t *testing.T) { + root, storage := t.TempDir(), t.TempDir() + alias := filepath.Join(root, "volumes") + if err := os.Symlink(storage, alias); err != nil { + t.Fatal(err) + } + var cfg Config + cfg.Volumes.Host.Homes = filepath.Join(alias, "homes") + cfg.Volumes.Host.Share = filepath.Join(storage, "share") + cfg.Volumes.Host.Readonly = filepath.Join(storage, "readonly") + cfg.AuthService.Mounts.HostAuthListPath = filepath.Join(root, "data", "AUTH_LIST") + if err := validateVolumePaths(&cfg); err != nil { + t.Fatal(err) + } + cfg.Volumes.Host.Share = filepath.Join(storage, "homes", "shared") + if err := validateVolumePaths(&cfg); err == nil { + t.Fatal("symlink hid overlapping volume paths") + } +} + +func TestAuthListCannotAliasSymlinkedShareImage(t *testing.T) { + root, storage := t.TempDir(), t.TempDir() + share := filepath.Join(root, "share") + if err := os.Symlink(storage, share); err != nil { + t.Fatal(err) + } + var cfg Config + cfg.Volumes.Host.Share = share + cfg.AuthService.Mounts.HostAuthListPath = share + ".img" + if err := validateVolumePaths(&cfg); err == nil { + t.Fatal("auth list would be deleted as a shared image") + } +} diff --git a/src/internal/common/convert/convert.go b/src/internal/common/convert/convert.go index ad377e9..57d81e9 100644 --- a/src/internal/common/convert/convert.go +++ b/src/internal/common/convert/convert.go @@ -2,6 +2,7 @@ package convert import ( "fmt" + "math" "strconv" "strings" @@ -51,10 +52,11 @@ func NanoCPUsFromString(v string) (int64, error) { if err != nil { return 0, err } - if f < 0 { - return 0, fmt.Errorf("must be non-negative") + nano := f * 1_000_000_000 + if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 || nano >= float64(math.MaxInt64) { + return 0, fmt.Errorf("CPU limit must be finite, non-negative and fit in int64 NanoCPUs") } - return int64(f * 1_000_000_000), nil + return int64(nano), nil } // BytesFromString converts a memory string to bytes. @@ -93,6 +95,9 @@ func BytesFromString(v string) (int64, error) { if n < 0 { return 0, fmt.Errorf("must be non-negative") } + if n > math.MaxInt64/mult { + return 0, fmt.Errorf("size exceeds int64 byte range") + } return n * mult, nil } @@ -106,6 +111,13 @@ func PortBindingFromString(s string) (nat.Port, nat.PortBinding, error) { hostPort := strings.TrimSpace(parts[0]) containerPart := strings.TrimSpace(parts[1]) + if _, err := strconv.ParseUint(hostPort, 10, 16); err != nil { + return "", nat.PortBinding{}, fmt.Errorf("host port must be in 0-65535: %w", err) + } + if port, err := strconv.ParseUint(containerPart, 10, 16); err != nil || port == 0 { + return "", nat.PortBinding{}, fmt.Errorf("container port must be in 1-65535") + } + containerPort, err := nat.NewPort("tcp", containerPart) if err != nil { return "", nat.PortBinding{}, err diff --git a/src/internal/common/convert/convert_test.go b/src/internal/common/convert/convert_test.go new file mode 100644 index 0000000..5351146 --- /dev/null +++ b/src/internal/common/convert/convert_test.go @@ -0,0 +1,35 @@ +package convert + +import "testing" + +func TestResourceLimitsRejectOverflow(t *testing.T) { + for _, value := range []string{"8589934592G", "18014398509481985k", "9223372036854775807m"} { + if got, err := BytesFromString(value); err == nil { + t.Errorf("size %q overflowed to %d", value, got) + } + } + for _, value := range []string{"NaN", "+Inf", "-Inf", "1e20", "9223372037", "-0.5"} { + if got, err := NanoCPUsFromString(value); err == nil { + t.Errorf("CPU %q accepted as %d", value, got) + } + } + if got, err := BytesFromString("2GB"); err != nil || got != 2*1024*1024*1024 { + t.Fatalf("valid size: %d, %v", got, err) + } + if got, err := NanoCPUsFromString("1.5"); err != nil || got != 1500000000 { + t.Fatalf("valid CPU: %d, %v", got, err) + } +} + +func TestPortBindingBounds(t *testing.T) { + for _, value := range []string{"bad:80", "65536:80", "-1:80", ":80", "80:0", "80:65536", "80:-1"} { + if _, _, err := PortBindingFromString(value); err == nil { + t.Errorf("accepted port %q", value) + } + } + for _, value := range []string{"0:80", "8080:80", "65535:65535"} { + if _, _, err := PortBindingFromString(value); err != nil { + t.Errorf("rejected %q: %v", value, err) + } + } +} diff --git a/src/internal/common/subnet/subnet.go b/src/internal/common/subnet/subnet.go index 64cb6db..8897218 100644 --- a/src/internal/common/subnet/subnet.go +++ b/src/internal/common/subnet/subnet.go @@ -3,14 +3,17 @@ package subnet import ( "fmt" "net" - "regexp" "strings" ) // IsValidSubnet checks if the given string is a valid subnet in CIDR notation. func IsValidSubnet(subnet string) bool { - regex := regexp.MustCompile(`^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/(3[0-2]|[12]?[0-9])$`) - return regex.MatchString(subnet) + ip, network, err := net.ParseCIDR(subnet) + if err != nil { + return false + } + _, bits := network.Mask.Size() + return bits == 32 && ip.To4() != nil } // IsValidSubnetList checks if the given string is a valid comma-separated list of CIDR blocks. @@ -24,7 +27,7 @@ func IsValidSubnetList(proxies string) error { for _, proxy := range proxyList { proxy = strings.TrimSpace(proxy) - if !IsValidSubnet(proxy) { + if _, _, err := net.ParseCIDR(proxy); err != nil { return fmt.Errorf("invalid CIDR block: %s", proxy) } } @@ -35,14 +38,14 @@ func IsValidSubnetList(proxies string) error { // IsValidSubnet16 checks if the given string is a valid /16 subnet. // Like IsValidSubnet but specifically for /16 subnets in the form x.x.0.0. func IsValidSubnet16(subnet string) bool { - regex := regexp.MustCompile(`^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.0\.0$`) - return regex.MatchString(subnet) + ip := net.ParseIP(subnet).To4() + return ip != nil && !strings.Contains(subnet, ":") && ip[2] == 0 && ip[3] == 0 } // GetSubnetByIndex computes a /28 subnet string from base IP and slot index. func GetSubnetByIndex(baseIP string, index int) (string, error) { - ip := net.ParseIP(strings.TrimSpace(baseIP)).To4() - if ip == nil { + ip := net.ParseIP(baseIP).To4() + if !IsValidSubnet16(baseIP) { return "", fmt.Errorf("invalid base ip") } if index < 0 { @@ -63,8 +66,8 @@ func GetSubnetByIndex(baseIP string, index int) (string, error) { // SubnetToIndex converts a /28 subnet back to a slot index relative to base IP. func SubnetToIndex(baseIP, subnet string) (int, bool) { - base := net.ParseIP(strings.TrimSpace(baseIP)).To4() - if base == nil { + base := net.ParseIP(baseIP).To4() + if !IsValidSubnet16(baseIP) { return 0, false } diff --git a/src/internal/common/subnet/subnet_test.go b/src/internal/common/subnet/subnet_test.go new file mode 100644 index 0000000..cca8c28 --- /dev/null +++ b/src/internal/common/subnet/subnet_test.go @@ -0,0 +1,56 @@ +package subnet + +import "testing" + +func TestSubnetParsingMatchesRuntime(t *testing.T) { + for _, value := range []string{"010.10.0.0/16", "::ffff:10.10.0.0/112", "256.1.0.0/16", "10.1.0.0/33", "not-a-subnet"} { + if IsValidSubnet(value) { + t.Errorf("accepted subnet %q", value) + } + } + for _, base := range []string{"010.10.0.0", "10.10.1.0", "10.10.0.1", "::ffff:10.10.0.0", "invalid"} { + if IsValidSubnet16(base) { + t.Errorf("accepted base %q", base) + } + if _, err := GetSubnetByIndex(base, 0); err == nil { + t.Errorf("allocated from base %q", base) + } + if _, ok := SubnetToIndex(base, "10.10.0.0/28"); ok { + t.Errorf("accepted reverse base %q", base) + } + } + if err := IsValidSubnetList("10.0.0.0/8, 2001:db8::/32, ::1/128"); err != nil { + t.Fatalf("rejected trusted IPv6 proxy: %v", err) + } + if err := IsValidSubnetList("10.0.0.0/8,invalid"); err == nil { + t.Fatal("accepted invalid proxy") + } +} + +func TestEveryUserSubnetRoundTripsAndStaysWithinBase(t *testing.T) { + seen := make(map[string]bool) + for index := 0; index < 4096; index++ { + sn, err := GetSubnetByIndex("10.10.0.0", index) + if err != nil { + t.Fatal(err) + } + if seen[sn] { + t.Fatalf("duplicate subnet %s", sn) + } + seen[sn] = true + got, ok := SubnetToIndex("10.10.0.0", sn) + if !ok || got != index { + t.Fatalf("slot %d maps back to %d/%t", index, got, ok) + } + } + for _, index := range []int{-1, 4096} { + if _, err := GetSubnetByIndex("10.10.0.0", index); err == nil { + t.Errorf("accepted slot %d outside /16", index) + } + } + for _, sn := range []string{"10.11.0.0/28", "10.10.0.1/28", "10.10.0.0/24"} { + if _, ok := SubnetToIndex("10.10.0.0", sn); ok { + t.Errorf("accepted invalid user subnet %q", sn) + } + } +} diff --git a/src/internal/common/system_api/api.go b/src/internal/common/system_api/api.go index f499ff9..9e432e2 100644 --- a/src/internal/common/system_api/api.go +++ b/src/internal/common/system_api/api.go @@ -18,6 +18,7 @@ type API interface { FormatExt4(path string) error IsMountPoint(path string) (bool, error) + MountPointsUnder(root string) ([]string, error) AttachLoopDevice(imagePath string) (string, error) DetachLoopDevice(loopDevice string) error Mount(source string, target string) error diff --git a/src/internal/common/system_api/linux.go b/src/internal/common/system_api/linux.go index dd960ac..42327b5 100644 --- a/src/internal/common/system_api/linux.go +++ b/src/internal/common/system_api/linux.go @@ -2,6 +2,7 @@ package system_api import ( "encoding/json" + "errors" "fmt" "io" "os" @@ -51,18 +52,25 @@ func (LinuxSystemAPI) Exists(path string) (bool, error) { } // CreateEmptyFile creates an empty file at the specified path with the given size in bytes. -func (LinuxSystemAPI) CreateEmptyFile(path string, sizeBytes int64) error { +func (LinuxSystemAPI) CreateEmptyFile(path string, sizeBytes int64) (retErr error) { path = filepath.Clean(path) di := filepath.Dir(path) if err := os.MkdirAll(di, 0755); err != nil { return fmt.Errorf("failed to create parent directories for %s: %w", path, err) } - f, err := os.Create(path) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { return fmt.Errorf("failed to create file: %s: %w", path, err) } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close image %s: %w", path, err)) + } + if retErr != nil { + retErr = errors.Join(retErr, os.Remove(path)) + } + }() if sizeBytes != 0 { if err := f.Truncate(sizeBytes); err != nil { @@ -105,35 +113,6 @@ func (LinuxSystemAPI) FormatExt4(path string) error { return nil } -// IsMountPoint checks if the specified path is a mount point by comparing device and inode numbers with its parent. -func (LinuxSystemAPI) IsMountPoint(path string) (bool, error) { - path, err := filepath.Abs(path) - if err != nil { - return false, err - } - - var st syscall.Stat_t - var parent syscall.Stat_t - - if err := syscall.Stat(path, &st); err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, fmt.Errorf("stat failed: %s: %w", path, err) - } - - parentPath := filepath.Dir(path) - if parentPath == path { - return true, nil - } - - if err := syscall.Stat(parentPath, &parent); err != nil { - return false, fmt.Errorf("parent stat failed: %s: %w", parentPath, err) - } - - return st.Dev != parent.Dev || st.Ino == parent.Ino, nil -} - // AttachLoopDevice attaches the specified image file to a free loop device and returns its path. func (LinuxSystemAPI) AttachLoopDevice(imagePath string) (string, error) { cmd := exec.Command("losetup", "--find", "--show", imagePath) diff --git a/src/internal/common/system_api/linux_test.go b/src/internal/common/system_api/linux_test.go index e524d3f..7251f93 100644 --- a/src/internal/common/system_api/linux_test.go +++ b/src/internal/common/system_api/linux_test.go @@ -59,3 +59,28 @@ func TestLoopDevicesResolveVolumeSymlink(t *testing.T) { } } } + +func TestCreateEmptyFilePreservesExistingData(t *testing.T) { + api := LinuxSystemAPI{} + path := filepath.Join(t.TempDir(), "disk.img") + if err := os.WriteFile(path, []byte("existing disk data"), 0600); err != nil { + t.Fatal(err) + } + if err := api.CreateEmptyFile(path, 1024); err == nil { + t.Error("overwrote an existing image") + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "existing disk data" { + t.Fatalf("existing image was modified (size=%d, error=%v)", len(data), err) + } +} + +func TestCreateEmptyFileFailureLeavesNoImage(t *testing.T) { + path := filepath.Join(t.TempDir(), "disk.img") + if err := (LinuxSystemAPI{}).CreateEmptyFile(path, -1); err == nil { + t.Fatal("accepted negative image size") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("failed image remains: %v", err) + } +} diff --git a/src/internal/common/system_api/mounts.go b/src/internal/common/system_api/mounts.go new file mode 100644 index 0000000..5d99290 --- /dev/null +++ b/src/internal/common/system_api/mounts.go @@ -0,0 +1,84 @@ +package system_api + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +// mountinfo includes same-filesystem bind mounts that device/inode comparisons miss. +func parseMountPoints(data []byte) ([]string, error) { + unescape := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`) + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 4096), 1<<20) + var points []string + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 10 || !strings.Contains(scanner.Text(), " - ") || !filepath.IsAbs(fields[4]) { + return nil, fmt.Errorf("invalid mountinfo entry") + } + points = append(points, filepath.Clean(unescape.Replace(fields[4]))) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read mountinfo: %w", err) + } + return points, nil +} + +func readMountPoints(path string) (string, []string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", nil, err + } + resolved, err := filepath.EvalSymlinks(absolute) + if os.IsNotExist(err) { + return "", nil, nil + } + if err != nil { + return "", nil, err + } + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return "", nil, fmt.Errorf("read mountinfo: %w", err) + } + points, err := parseMountPoints(data) + return resolved, points, err +} + +func (LinuxSystemAPI) IsMountPoint(path string) (bool, error) { + resolved, points, err := readMountPoints(path) + if err != nil { + return false, err + } + for _, point := range points { + if point == resolved { + return true, nil + } + } + return false, nil +} + +// MountPointsUnder does not traverse user files or follow symlinks inside them. +// Only the configured root is resolved, allowing deployments on linked storage. +func (LinuxSystemAPI) MountPointsUnder(root string) ([]string, error) { + resolved, points, err := readMountPoints(root) + if err != nil || resolved == "" { + return nil, err + } + return mountPointsUnder(resolved, points), nil +} + +func mountPointsUnder(root string, points []string) []string { + var result []string + for _, point := range points { + relative, err := filepath.Rel(root, point) + if err == nil && relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + // Keep duplicate mount points: stacked mounts each need an unmount. + result = append(result, point) + } + } + return result +} diff --git a/src/internal/common/system_api/mounts_test.go b/src/internal/common/system_api/mounts_test.go new file mode 100644 index 0000000..b610c4c --- /dev/null +++ b/src/internal/common/system_api/mounts_test.go @@ -0,0 +1,62 @@ +package system_api + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestMountTableIncludesBindMountsAndEscapedPaths(t *testing.T) { + data := []byte("25 1 8:1 / / rw - ext4 /dev/sda rw\n" + + "26 25 8:1 /source /volumes/homes/alice rw - ext4 /dev/sda rw\n" + + "27 26 8:1 /nested /volumes/homes/alice/a\\040b\\134c rw - ext4 /dev/sda rw\n" + + "28 25 8:1 /other /volumes/homes-other/bob rw - ext4 /dev/sda rw\n" + + "29 27 8:1 /stack /volumes/homes/alice/a\\040b\\134c rw - ext4 /dev/sda rw\n") + points, err := parseMountPoints(data) + if err != nil { + t.Fatal(err) + } + got := mountPointsUnder("/volumes/homes", points) + want := []string{"/volumes/homes/alice", `/volumes/homes/alice/a b\c`, `/volumes/homes/alice/a b\c`} + if !reflect.DeepEqual(got, want) { + t.Fatalf("mounts=%v, want %v", got, want) + } + if _, err := parseMountPoints([]byte("incomplete mount record\n")); err == nil { + t.Fatal("ignored invalid mount table") + } +} + +func TestMountDetectionResolvesStorageLink(t *testing.T) { + api := LinuxSystemAPI{} + alias := filepath.Join(t.TempDir(), "storage") + if err := os.Symlink("/proc", alias); err != nil { + t.Fatal(err) + } + mounted, err := api.IsMountPoint(alias) + if err != nil || !mounted { + t.Fatalf("linked mount undetected: %v", err) + } + got, err := api.MountPointsUnder(alias) + if err != nil { + t.Fatal(err) + } + want, err := api.MountPointsUnder("/proc") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("linked storage mounts differ: %v != %v", got, want) + } + // A symlink to a directory on another filesystem is not necessarily a mount. + ordinary := filepath.Join(t.TempDir(), "process") + if err := os.Symlink("/proc/self", ordinary); err != nil { + t.Fatal(err) + } + if mounted, err := api.IsMountPoint(ordinary); err != nil || mounted { + t.Fatalf("ordinary linked directory treated as mount: %t %v", mounted, err) + } + if mounted, err := api.IsMountPoint(filepath.Join(t.TempDir(), "missing")); err != nil || mounted { + t.Fatalf("missing mount=%t, error=%v", mounted, err) + } +} diff --git a/src/internal/common/system_api/unsupported.go b/src/internal/common/system_api/unsupported.go index 40ae8f8..43bb20f 100644 --- a/src/internal/common/system_api/unsupported.go +++ b/src/internal/common/system_api/unsupported.go @@ -109,3 +109,7 @@ func (u UnsupportedSystemAPI) Unmount(mountPoint string) error { func (u UnsupportedSystemAPI) FindLoopDevicesForImages(dir string) ([]string, error) { return nil, fmt.Errorf("loop device listing is unsupported on %s", u.OS) } + +func (u UnsupportedSystemAPI) MountPointsUnder(root string) ([]string, error) { + return nil, fmt.Errorf("mount point listing is unsupported on %s", u.OS) +} diff --git a/src/internal/common/user/file_update.go b/src/internal/common/user/file_update.go new file mode 100644 index 0000000..54ba56a --- /dev/null +++ b/src/internal/common/user/file_update.go @@ -0,0 +1,67 @@ +package user + +import ( + "errors" + "fmt" + "io" +) + +// The caller holds the file's exclusive lock for both the update and recovery. +// Keep the inode: replacing the file would leave Docker reading an old bind mount. +type authFileEditor interface { + WriteAt([]byte, int64) (int, error) + Truncate(int64) error + Sync() error +} + +func writeAuthAt(file authFileEditor, data []byte, offset int64) error { + n, err := file.WriteAt(data, offset) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + if err != nil { + return fmt.Errorf("write auth file: %w", err) + } + return nil +} + +func resizeAndSyncAuth(file authFileEditor, size int64) error { + if err := file.Truncate(size); err != nil { + return fmt.Errorf("truncate auth file: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("sync auth file: %w", err) + } + return nil +} + +func appendAuthEntry(file authFileEditor, originalSize int64, entry []byte) error { + err := writeAuthAt(file, entry, originalSize) + if err == nil { + if syncErr := file.Sync(); syncErr != nil { + err = fmt.Errorf("sync auth file: %w", syncErr) + } + } + if err != nil { + if rollbackErr := resizeAndSyncAuth(file, originalSize); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("restore auth file: %w", rollbackErr)) + } + } + return err +} + +func replaceAuthData(file authFileEditor, original, updated []byte) error { + replace := func(data []byte) error { + if err := writeAuthAt(file, data, 0); err != nil { + return err + } + return resizeAndSyncAuth(file, int64(len(data))) + } + if err := replace(updated); err != nil { + if rollbackErr := replace(original); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("restore auth file: %w", rollbackErr)) + } + return err + } + return nil +} diff --git a/src/internal/common/user/file_update_test.go b/src/internal/common/user/file_update_test.go new file mode 100644 index 0000000..7bd884f --- /dev/null +++ b/src/internal/common/user/file_update_test.go @@ -0,0 +1,129 @@ +package user + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +type faultyAuthFile struct { + *os.File + fail string + failure error + rollbackFailure error +} + +func (f *faultyAuthFile) WriteAt(data []byte, offset int64) (int, error) { + if f.fail == "write" || f.fail == "short" { + step := f.fail + f.fail = "" + n, err := f.File.WriteAt(data[:len(data)/2], offset) + if err != nil { + return n, err + } + if step == "short" { + return n, nil + } + return n, f.failure + } + return f.File.WriteAt(data, offset) +} +func (f *faultyAuthFile) Truncate(size int64) error { + if f.fail == "truncate" { + f.fail = "" + return f.failure + } + if f.rollbackFailure != nil { + return f.rollbackFailure + } + return f.File.Truncate(size) +} +func (f *faultyAuthFile) Sync() error { + if f.fail == "sync" { + f.fail = "" + return f.failure + } + return f.File.Sync() +} + +func TestCredentialUpdatesRestoreFileAfterPartialFailure(t *testing.T) { + for _, operation := range []string{"append", "remove"} { + for _, step := range []string{"write", "short", "truncate", "sync", "success"} { + if operation == "append" && step == "truncate" { + continue + } + t.Run(operation+"/"+step, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + original := []byte("alice:alice-hash\nbob:bob-hash\n") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(path, os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer file.Close() + before, err := file.Stat() + if err != nil { + t.Fatal(err) + } + failure := errors.New("injected I/O failure") + faulty := &faultyAuthFile{File: file, fail: step, failure: failure} + updated := []byte("bob:bob-hash\n") + if operation == "append" { + entry := []byte("carol:carol-hash\n") + updated = append(append([]byte{}, original...), entry...) + err = appendAuthEntry(faulty, int64(len(original)), entry) + } else { + err = replaceAuthData(faulty, original, updated) + } + want := original + if step == "success" { + want = updated + if err != nil { + t.Fatal(err) + } + } else { + expectedErr := failure + if step == "short" { + expectedErr = io.ErrShortWrite + } + if !errors.Is(err, expectedErr) { + t.Fatalf("lost update failure: %v", err) + } + } + got, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(got) != string(want) { + t.Fatalf("credentials changed after %s failure: %q", step, got) + } + after, statErr := os.Stat(path) + if statErr != nil { + t.Fatal(statErr) + } + if !os.SameFile(before, after) { + t.Fatal("bind mount inode changed") + } + }) + } + } +} + +func TestCredentialRollbackFailureIsReported(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "auth") + if err != nil { + t.Fatal(err) + } + defer file.Close() + original, recovery := errors.New("write failure"), errors.New("recovery failure") + faulty := &faultyAuthFile{File: file, fail: "write", failure: original, rollbackFailure: recovery} + err = appendAuthEntry(faulty, 0, []byte("alice:hash\n")) + if !errors.Is(err, original) || !errors.Is(err, recovery) || !strings.Contains(err.Error(), "restore auth file") { + t.Fatalf("lost recovery failure: %v", err) + } +} diff --git a/src/internal/common/user/user.go b/src/internal/common/user/user.go index cb94479..c459597 100644 --- a/src/internal/common/user/user.go +++ b/src/internal/common/user/user.go @@ -2,78 +2,100 @@ package user import ( "bufio" + "bytes" "fmt" + "io" "os" "path/filepath" "strings" + "syscall" "golang.org/x/crypto/bcrypt" "github.com/elecbug/linuxus/src/internal/common/ruleset" ) -// LoadUsers reads user credentials from the auth list file. +// openLocked locks the auth file itself so host commands and the container's +// bind mount synchronize on the same inode. Closing the file releases the lock. +func openLocked(path string, flags, operation int) (*os.File, error) { + file, err := os.OpenFile(path, flags, 0600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(file.Fd()), operation); err != nil { + file.Close() + return nil, fmt.Errorf("lock auth list: %w", err) + } + return file, nil +} + +// LoadUsers reads a complete credential snapshot, waiting for active writers. func LoadUsers(path string) (map[string]string, error) { - file, err := os.Open(path) + file, err := openLocked(path, os.O_RDONLY, syscall.LOCK_SH) if err != nil { return nil, err } defer file.Close() + return readUsers(file) +} +func readUsers(reader io.Reader) (map[string]string, error) { users := make(map[string]string) - scanner := bufio.NewScanner(file) - + scanner := bufio.NewScanner(reader) + lineNumber := 0 for scanner.Scan() { + lineNumber++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } - parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { - return nil, fmt.Errorf("invalid line in auths file: %s", line) + return nil, fmt.Errorf("invalid auth list entry at line %d", lineNumber) } - - id := strings.TrimSpace(parts[0]) - hash := strings.TrimSpace(parts[1]) - + id, hash := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) if !ruleset.AllowedUserID(id) || hash == "" { - return nil, fmt.Errorf("invalid line in auths file: %s", line) + return nil, fmt.Errorf("invalid auth list entry at line %d", lineNumber) + } + if _, exists := users[id]; exists { + return nil, fmt.Errorf("duplicate auth list entry at line %d", lineNumber) } - users[id] = hash } - if err := scanner.Err(); err != nil { return nil, err } - return users, nil } -// AddUser adds a new user with a bcrypt-hashed password to memory and file. +// AddUser rechecks the file under an exclusive lock instead of trusting a stale +// caller snapshot. Callers must synchronize access to their in-memory map. func AddUser(path string, users map[string]string, id, password string) error { - if _, ok := users[id]; ok { - return fmt.Errorf("user '%s' already exists", id) + if users == nil { + return fmt.Errorf("user map is not initialized") } - if !ruleset.AllowedUserID(id) || password == "" { return fmt.Errorf("invalid user ID or password") } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - return fmt.Errorf("failed to hash password: %v", err) + return fmt.Errorf("failed to hash password: %w", err) } - if err := EnsureFile(path); err != nil { return err } - file, err := os.OpenFile(path, os.O_APPEND|os.O_RDWR, 0600) + file, err := openLocked(path, os.O_RDWR, syscall.LOCK_EX) if err != nil { - return fmt.Errorf("failed to open auth file: %v", err) + return fmt.Errorf("failed to open auth file: %w", err) } defer file.Close() + current, err := readUsers(file) + if err != nil { + return err + } + if _, exists := current[id]; exists { + return fmt.Errorf("user %q already exists", id) + } prefix := "" info, err := file.Stat() @@ -89,94 +111,81 @@ func AddUser(path string, users map[string]string, id, password string) error { prefix = "\n" } } - if _, err := file.WriteString(fmt.Sprintf("%s%s:%s\n", prefix, id, string(hash))); err != nil { - return fmt.Errorf("failed to write to auth file: %v", err) + entry := []byte(fmt.Sprintf("%s%s:%s\n", prefix, id, string(hash))) + if err := appendAuthEntry(file, info.Size(), entry); err != nil { + return err } - users[id] = string(hash) - + current[id] = string(hash) + replaceUsers(users, current) return nil } -// RemoveUser deletes a user from memory and the auth list file. +// RemoveUser preserves the inode used by Docker's file bind mount. Readers and +// other writers cannot observe or overwrite the in-place update while locked. func RemoveUser(path string, users map[string]string, id string) error { - if _, ok := users[id]; !ok { - return fmt.Errorf("user '%s' does not exist", id) + if users == nil { + return fmt.Errorf("user map is not initialized") } - - file, err := os.OpenFile(path, os.O_RDWR, 0600) + if !ruleset.AllowedUserID(id) { + return fmt.Errorf("invalid user ID") + } + file, err := openLocked(path, os.O_RDWR, syscall.LOCK_EX) if err != nil { - return fmt.Errorf("failed to open auth file: %v", err) + return fmt.Errorf("failed to open auth file: %w", err) } defer file.Close() - - var lines []string - scanner := bufio.NewScanner(file) - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - lines = append(lines, line) - continue - } - - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - lines = append(lines, line) - continue - } - - existingID := strings.TrimSpace(parts[0]) - if existingID != id { - lines = append(lines, line) - } - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("failed to read auth file: %v", err) + data, err := io.ReadAll(file) + if err != nil { + return err } - - if err := file.Truncate(0); err != nil { - return fmt.Errorf("failed to truncate auth file: %v", err) + current, err := readUsers(bytes.NewReader(data)) + if err != nil { + return err } - - if _, err := file.Seek(0, 0); err != nil { - return fmt.Errorf("failed to seek auth file: %v", err) + if _, exists := current[id]; !exists { + return fmt.Errorf("user %q does not exist", id) } - writer := bufio.NewWriter(file) - for _, line := range lines { - if _, err := writer.WriteString(line + "\n"); err != nil { - return fmt.Errorf("failed to write to auth file: %v", err) + var lines []string + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + parts := strings.SplitN(trimmed, ":", 2) + if !strings.HasPrefix(trimmed, "#") && len(parts) == 2 && strings.TrimSpace(parts[0]) == id { + continue } + lines = append(lines, line) } - if err := writer.Flush(); err != nil { - return fmt.Errorf("failed to flush auth file: %v", err) + updated := []byte(strings.Join(lines, "\n")) + if err := replaceAuthData(file, data, updated); err != nil { + return err } - delete(users, id) - + delete(current, id) + replaceUsers(users, current) return nil } -// SyncUsers reloads the user credentials from the auth list file into memory. -func SyncUsers(users map[string]string, authListPath string) error { - loadedUsers, err := LoadUsers(authListPath) - if err != nil { - return fmt.Errorf("failed to sync users: %v", err) - } - +func replaceUsers(users, loaded map[string]string) { clear(users) - for id, hash := range loadedUsers { + for id, hash := range loaded { users[id] = hash } +} +// SyncUsers reloads the user credentials from the auth list file into memory. +func SyncUsers(users map[string]string, authListPath string) error { + if users == nil { + return fmt.Errorf("user map is not initialized") + } + loaded, err := LoadUsers(authListPath) + if err != nil { + return fmt.Errorf("failed to sync users: %w", err) + } + replaceUsers(users, loaded) return nil } // ExistsUser checks if a user ID exists in the provided user map. -func ExistsUser(users map[string]string, id string) bool { - _, ok := users[id] - return ok -} +func ExistsUser(users map[string]string, id string) bool { _, ok := users[id]; return ok } // EnsureFile initializes a new deployment without truncating existing credentials. func EnsureFile(path string) error { @@ -186,9 +195,9 @@ func EnsureFile(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return fmt.Errorf("create auth directory: %w", err) } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return fmt.Errorf("initialize auth list: %w", err) } - return f.Close() + return file.Close() } diff --git a/src/internal/common/user/user_test.go b/src/internal/common/user/user_test.go index 055a6a6..2ded468 100644 --- a/src/internal/common/user/user_test.go +++ b/src/internal/common/user/user_test.go @@ -4,7 +4,12 @@ import ( "os" "path/filepath" "reflect" + "strings" + "sync" + "sync/atomic" + "syscall" "testing" + "time" "golang.org/x/crypto/bcrypt" ) @@ -76,3 +81,117 @@ func TestFailedCredentialWritesLeaveMemoryUnchanged(t *testing.T) { t.Fatal("accepted path traversal ID") } } + +func TestStaleUserMapCannotCreateDuplicateAccount(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:original-hash\n"), 0600); err != nil { + t.Fatal(err) + } + if err := AddUser(path, map[string]string{}, "alice", "replacement"); err == nil { + t.Fatal("stale map overwrote an existing account") + } + loaded, err := LoadUsers(path) + if err != nil || loaded["alice"] != "original-hash" { + t.Fatal("existing credentials changed") + } +} + +func TestCredentialErrorsDoNotExposeHashes(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("bad/id:private-password-hash\n"), 0600); err != nil { + t.Fatal(err) + } + _, err := LoadUsers(path) + if err == nil || strings.Contains(err.Error(), "private-password-hash") { + t.Fatalf("unsafe parse error: %v", err) + } +} + +func TestCredentialReadersHonorWriterLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:hash\n"), 0600); err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(path, os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + done := make(chan error, 1) + go func() { _, err := LoadUsers(path); done <- err }() + select { + case err := <-done: + t.Fatalf("reader ignored an active file writer: %v", err) + case <-time.After(50 * time.Millisecond): + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("reader did not resume") + } +} + +func TestIndependentWritersCannotRegisterSameID(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := EnsureFile(path); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + var successes atomic.Int32 + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + if AddUser(path, map[string]string{}, "alice", "password") == nil { + successes.Add(1) + } + }() + } + wg.Wait() + if successes.Load() != 1 { + t.Fatalf("successful duplicate registrations: %d", successes.Load()) + } + users, err := LoadUsers(path) + if err != nil || len(users) != 1 { + t.Fatalf("invalid auth list: %v", err) + } +} + +func TestRemoveUsesCurrentFileAndPreservesBindMountInode(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("# users\nalice:hash\nbob:hash\n"), 0600); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + stale := map[string]string{} + if err := RemoveUser(path, stale, "alice"); err != nil { + t.Fatal(err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("auth file replacement broke the bind mount") + } + if !reflect.DeepEqual(stale, map[string]string{"bob": "hash"}) { + t.Fatal("did not refresh the caller snapshot") + } + loaded, err := LoadUsers(path) + if err != nil || !reflect.DeepEqual(loaded, stale) { + t.Fatalf("file differs from snapshot: %v", err) + } +} diff --git a/src/internal/ctl/app/config.go b/src/internal/ctl/app/config.go index 4dcaf28..56a154a 100644 --- a/src/internal/ctl/app/config.go +++ b/src/internal/ctl/app/config.go @@ -1,10 +1,13 @@ package app import ( + "bytes" "fmt" + "io" "os" "path/filepath" + "github.com/elecbug/linuxus/src/internal/common/config" "github.com/elecbug/linuxus/src/internal/common/user" "gopkg.in/yaml.v3" ) @@ -13,11 +16,22 @@ import ( func (a *App) LoadConfig() error { data, err := os.ReadFile(a.configFile) if err != nil { - return fmt.Errorf("config file not found: %s", a.configFile) + return fmt.Errorf("read config %s: %w", a.configFile, err) } - if err := yaml.Unmarshal(data, &a.Config); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + var parsed config.Config + if err := decoder.Decode(&parsed); err != nil { return fmt.Errorf("failed to parse yaml config: %w", err) } + var extra yaml.Node + if err := decoder.Decode(&extra); err != io.EOF { + if err != nil { + return fmt.Errorf("failed to parse trailing yaml: %w", err) + } + return fmt.Errorf("config must contain exactly one YAML document") + } + a.Config = parsed a.normalizeConfigPaths() @@ -36,8 +50,11 @@ func (a *App) LoadConfig() error { // normalizeConfigPaths resolves host paths relative to the deployment directory. func (a *App) normalizeConfigPaths() { resolve := func(path string) string { - if path == "" || filepath.IsAbs(path) { - return path + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return filepath.Clean(path) } return filepath.Join(a.runtimeRoot, path) } diff --git a/src/internal/ctl/app/disk.go b/src/internal/ctl/app/disk.go index 1068770..2831f43 100644 --- a/src/internal/ctl/app/disk.go +++ b/src/internal/ctl/app/disk.go @@ -1,8 +1,8 @@ package app import ( + "errors" "fmt" - "os" "path/filepath" "sort" "strings" @@ -53,7 +53,7 @@ func (a *App) cleanVolumesAllUnlocked() error { } for _, mountPoint := range []string{a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly} { - err = a.umountDisk(mountPoint) + err = a.unmountDiskTree(mountPoint) if err != nil { return fmt.Errorf("unmount shared disk %s: %w", mountPoint, err) } @@ -123,7 +123,7 @@ func (a *App) cleanVolumeUser(userID string) error { func (a *App) cleanVolumeUserUnlocked(userID string) error { log.Log(log.RUN_PREFIX, "Cleaning volume for user: %s...", userID) - if err := a.umountDisk(filepath.Join(a.Config.Volumes.Host.Homes, userID)); err != nil { + if err := a.unmountDiskTree(filepath.Join(a.Config.Volumes.Host.Homes, userID)); err != nil { return fmt.Errorf("unmount home disk for %s: %w", userID, err) } @@ -214,7 +214,7 @@ func (a *App) ensureDiskUserUnlocked(userID string) error { } // createSharedDisk creates and mounts a shared loopback disk at the target path. -func (a *App) createSharedDisk(path string) error { +func (a *App) createSharedDisk(path string) (retErr error) { sizeStr := a.Config.Volumes.DiskLimit size, err := convert.BytesFromString(sizeStr) if err != nil { @@ -246,10 +246,7 @@ func (a *App) createSharedDisk(path string) error { } else if !exists { log.Log(log.DETAIL_PREFIX, "Creating shared disk for %s (%s)", mountPoint, sizeStr) - if err := a.systemAPI.CreateEmptyFile(img, size); err != nil { - return err - } - if err := a.systemAPI.FormatExt4(img); err != nil { + if err := a.createFormattedImage(img, size); err != nil { return err } } @@ -265,13 +262,9 @@ func (a *App) createSharedDisk(path string) error { mounted := false defer func() { - if err == nil { - return - } - if mounted { - _ = a.systemAPI.Unmount(mountPoint) + if retErr != nil { + retErr = errors.Join(retErr, a.rollbackDiskMount(loopdev, mountPoint, mounted)) } - _ = a.systemAPI.DetachLoopDevice(loopdev) }() if err = a.systemAPI.Mount(loopdev, mountPoint); err != nil { @@ -279,23 +272,11 @@ func (a *App) createSharedDisk(path string) error { } mounted = true - if err = a.systemAPI.Chown( - mountPoint, - a.Config.UserService.Runtime.UID, - a.Config.UserService.Runtime.GID, - ); err != nil { - return err - } - - if err = a.systemAPI.Chmod(mountPoint, 0755); err != nil { - return err - } - - return nil + return a.setDiskPermissions(mountPoint) } // createUserDisk creates and mounts a per-user loopback disk. -func (a *App) createUserDisk(userID string, isAdmin bool) error { +func (a *App) createUserDisk(userID string, isAdmin bool) (retErr error) { if !ruleset.AllowedUserID(userID) { return fmt.Errorf("invalid user ID: %q", userID) } @@ -331,10 +312,7 @@ func (a *App) createUserDisk(userID string, isAdmin bool) error { } else if !exists { log.Log(log.DETAIL_PREFIX, "Creating disk for %s (%s)", userID, sizeStr) - if err := a.systemAPI.CreateEmptyFile(img, size); err != nil { - return err - } - if err := a.systemAPI.FormatExt4(img); err != nil { + if err := a.createFormattedImage(img, size); err != nil { return err } } @@ -350,13 +328,9 @@ func (a *App) createUserDisk(userID string, isAdmin bool) error { mounted := false defer func() { - if err == nil { - return + if retErr != nil { + retErr = errors.Join(retErr, a.rollbackDiskMount(loopdev, mountPoint, mounted)) } - if mounted { - _ = a.systemAPI.Unmount(mountPoint) - } - _ = a.systemAPI.DetachLoopDevice(loopdev) }() if err = a.systemAPI.Mount(loopdev, mountPoint); err != nil { @@ -364,50 +338,44 @@ func (a *App) createUserDisk(userID string, isAdmin bool) error { } mounted = true - if err = a.systemAPI.Chown( - mountPoint, - a.Config.UserService.Runtime.UID, - a.Config.UserService.Runtime.GID, - ); err != nil { + return a.setDiskPermissions(mountPoint) +} + +// createFormattedImage removes only the new image when formatting fails, so a +// retry can initialize it again. Existing images never pass through this path. +func (a *App) createFormattedImage(img string, size int64) error { + if err := a.systemAPI.CreateEmptyFile(img, size); err != nil { return err } + if err := a.systemAPI.FormatExt4(img); err != nil { + return errors.Join(err, a.systemAPI.Remove(img)) + } + return nil +} - if err = a.systemAPI.Chmod(mountPoint, 0755); err != nil { +func (a *App) setDiskPermissions(path string) error { + if err := a.systemAPI.Chown(path, a.Config.UserService.Runtime.UID, a.Config.UserService.Runtime.GID); err != nil { return err } + return a.systemAPI.Chmod(path, 0755) +} +func (a *App) rollbackDiskMount(loopdev, mountPoint string, mounted bool) error { + if mounted { + if err := a.systemAPI.Unmount(mountPoint); err != nil { + // The loop device still backs a mounted filesystem; leave it attached. + return fmt.Errorf("rollback unmount %s: %w", mountPoint, err) + } + } + if err := a.systemAPI.DetachLoopDevice(loopdev); err != nil { + return fmt.Errorf("rollback detach %s: %w", loopdev, err) + } return nil } // listMountedDirsDeepestFirst returns mounted directories under root from deepest to shallowest. func (a *App) listMountedDirsDeepestFirst(root string) ([]string, error) { - var dirs []string - - exists, err := a.systemAPI.Exists(root) - if err != nil { - return nil, err - } - if !exists { - return nil, nil - } - - err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if path == root || !d.IsDir() { - return nil - } - - mounted, err := a.systemAPI.IsMountPoint(path) - if err != nil { - return err - } - if mounted { - dirs = append(dirs, path) - } - return nil - }) + dirs, err := a.systemAPI.MountPointsUnder(root) if err != nil { return nil, err } @@ -443,3 +411,17 @@ func (a *App) umountDisk(mountPoint string) error { func (a *App) detachLoopDevice(dev string) error { return a.systemAPI.DetachLoopDevice(dev) } + +// Nested bind mounts must be released before their parent filesystem. +func (a *App) unmountDiskTree(root string) error { + mounted, err := a.listMountedDirsDeepestFirst(root) + if err != nil { + return err + } + for _, path := range mounted { + if err := a.umountDisk(path); err != nil { + return err + } + } + return a.umountDisk(root) +} diff --git a/src/internal/ctl/app/disk_service_test.go b/src/internal/ctl/app/disk_service_test.go index 5283e1b..b2928fd 100644 --- a/src/internal/ctl/app/disk_service_test.go +++ b/src/internal/ctl/app/disk_service_test.go @@ -350,3 +350,44 @@ func TestDiskServiceShutdownWaitsForActivePreparation(t *testing.T) { } } } + +func TestServiceDownStopsDiskServiceWhenDockerFails(t *testing.T) { + dir, err := os.MkdirTemp("", "linuxus-down-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + a := &App{} // No Docker client: container cleanup fails immediately. + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(dir, "AUTH_LIST") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- a.serveDisks(ctx) }() + client := diskservice.NewClient(a.diskSocket(), time.Second) + defer client.CloseIdleConnections() + deadline := time.Now().Add(3 * time.Second) + for { + resp, err := client.Get("http://disk-service/healthz") + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + break + } + } + if time.Now().After(deadline) { + t.Fatal("service did not become healthy") + } + time.Sleep(10 * time.Millisecond) + } + if err := a.ServiceDown(nil); err == nil { + t.Fatal("lost the Docker cleanup error") + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("Docker failure left the disk service running") + } +} diff --git a/src/internal/ctl/app/disk_test.go b/src/internal/ctl/app/disk_test.go index 85ff8b7..1e39a27 100644 --- a/src/internal/ctl/app/disk_test.go +++ b/src/internal/ctl/app/disk_test.go @@ -2,6 +2,7 @@ package app import ( "errors" + "fmt" "path/filepath" "reflect" "testing" @@ -13,6 +14,11 @@ type cleanupAPI struct { system_api.API events []string unmountErr, detachErr error + nested []string +} + +func (f *cleanupAPI) MountPointsUnder(string) ([]string, error) { + return append([]string(nil), f.nested...), nil } func (f *cleanupAPI) IsMountPoint(p string) (bool, error) { return true, nil } @@ -82,3 +88,107 @@ func TestDiskOperationsRejectInvalidUserPaths(t *testing.T) { } } } + +type failingDiskAPI struct { + recordingDiskAPI + formatErr, chownErr, unmountErr, detachErr error + nested []string + unmounted int +} + +func (f *failingDiskAPI) FormatExt4(path string) error { + if f.formatErr != nil { + return f.formatErr + } + return f.recordingDiskAPI.FormatExt4(path) +} +func (f *failingDiskAPI) Remove(path string) error { delete(f.images, path); return nil } +func (f *failingDiskAPI) Chown(string, int, int) error { return f.chownErr } +func (f *failingDiskAPI) Unmount(path string) error { + f.unmounted++ + if f.unmountErr == nil { + delete(f.mounted, path) + } + return f.unmountErr +} +func (f *failingDiskAPI) DetachLoopDevice(path string) error { f.detached++; return f.detachErr } + +func diskFailureFixture(t *testing.T, shared bool) (*failingDiskAPI, string, func() error) { + t.Helper() + api := &failingDiskAPI{recordingDiskAPI: recordingDiskAPI{ + images: map[string]int64{}, formatted: map[string]int{}, mounted: map[string]bool{}, + }} + a := &App{systemAPI: api} + a.Config.Volumes.DiskLimit = "8M" + a.Config.UserService.Limits.User.Disk = "8M" + a.Config.Volumes.Host.Homes = filepath.Join(t.TempDir(), "homes") + if shared { + path := filepath.Join(t.TempDir(), "share") + return api, path + ".img", func() error { return a.createSharedDisk(path) } + } + return api, filepath.Join(a.Config.Volumes.Host.Homes, "alice.img"), func() error { return a.createUserDisk("alice", false) } +} + +func TestFailedFormatCanBeRetried(t *testing.T) { + for _, shared := range []bool{false, true} { + t.Run(map[bool]string{true: "shared", false: "user"}[shared], func(t *testing.T) { + api, image, create := diskFailureFixture(t, shared) + failure := errors.New("mkfs failed") + api.formatErr = failure + if err := create(); !errors.Is(err, failure) { + t.Fatal(err) + } + if _, exists := api.images[image]; exists { + t.Fatal("incomplete image prevents a successful retry") + } + api.formatErr = nil + if err := create(); err != nil { + t.Fatal(err) + } + if api.formatted[image] != 1 { + t.Fatal("retry did not format a new image") + } + }) + } +} + +func TestMountRollbackPreservesCleanupErrors(t *testing.T) { + for _, shared := range []bool{false, true} { + for _, busy := range []bool{false, true} { + t.Run(fmt.Sprintf("shared=%t/unmount_busy=%t", shared, busy), func(t *testing.T) { + api, _, create := diskFailureFixture(t, shared) + original, cleanup := errors.New("chown failed"), errors.New("cleanup failed") + api.chownErr = original + if busy { + api.unmountErr = cleanup + } else { + api.detachErr = cleanup + } + err := create() + if !errors.Is(err, original) || !errors.Is(err, cleanup) { + t.Fatalf("lost failure details: %v", err) + } + if busy && api.detached != 0 { + t.Fatal("detached loop device while filesystem was mounted") + } + if !busy && api.detached != 1 { + t.Fatal("did not attempt to release loop device") + } + }) + } + } +} + +func TestUserDiskCleanupUnmountsNestedDisksFirst(t *testing.T) { + root := "/deployment/volumes/homes/alice" + api := &cleanupAPI{nested: []string{root + "/bind", root + "/bind/deeper"}} + a := &App{systemAPI: api} + a.Config.Volumes.Host.Homes = filepath.Dir(root) + if err := a.cleanVolumeUserUnlocked("alice"); err != nil { + t.Fatal(err) + } + want := []string{"unmount:" + root + "/bind/deeper", "unmount:" + root + "/bind", "unmount:" + root} + if len(api.events) < len(want) || !reflect.DeepEqual(api.events[:len(want)], want) { + t.Fatalf("unsafe unmount order: %v", api.events) + } +} diff --git a/src/internal/ctl/app/docker_container.go b/src/internal/ctl/app/docker_container.go index 7f854fb..a0378c5 100644 --- a/src/internal/ctl/app/docker_container.go +++ b/src/internal/ctl/app/docker_container.go @@ -1,9 +1,13 @@ package app import ( + "context" + "errors" "fmt" "strings" + "time" + "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/network" @@ -32,25 +36,12 @@ func (a *App) ensureManagerContainer() error { } // ensureContainer creates a container from the given runtime specification. -func (a *App) ensureContainer(spec spec.RuntimeContainerSpec) error { +func (a *App) ensureContainer(spec spec.RuntimeContainerSpec) (retErr error) { cli := a.dockerClient if cli == nil { return fmt.Errorf("Docker client is not initialized") } - exists, err := a.existDockerContainer(spec.Name) - if err != nil { - return err - } - if exists { - log.Log(log.DETAIL_PREFIX, "Recreating container: %s", spec.Name) - if err := cli.ContainerRemove(a.context, spec.Name, container.RemoveOptions{ - Force: true, - }); err != nil { - return fmt.Errorf("failed to remove existing container %s: %w", spec.Name, err) - } - } - var ( exposedPorts nat.PortSet portBindings nat.PortMap @@ -127,6 +118,20 @@ func (a *App) ensureContainer(spec spec.RuntimeContainerSpec) error { } } + // Validate all local settings before removing a running service. + exists, err := a.existDockerContainer(spec.Name) + if err != nil { + return err + } + if exists { + log.Log(log.DETAIL_PREFIX, "Recreating container: %s", spec.Name) + if err := cli.ContainerRemove(a.context, spec.Name, container.RemoveOptions{ + Force: true, + }); err != nil { + return fmt.Errorf("failed to remove existing container %s: %w", spec.Name, err) + } + } + resp, err := cli.ContainerCreate( a.context, cfg, @@ -139,7 +144,19 @@ func (a *App) ensureContainer(spec spec.RuntimeContainerSpec) error { return fmt.Errorf("failed to create container %s: %w", spec.Name, err) } - for _, netName := range spec.Networks[1:] { + defer func() { + if retErr != nil { + ctx, cancel := context.WithTimeout(context.WithoutCancel(a.context), 10*time.Second) + defer cancel() + if err := cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) { + retErr = errors.Join(retErr, fmt.Errorf("remove failed container %s: %w", resp.ID, err)) + } + } + }() + for i, netName := range spec.Networks { + if i == 0 { + continue + } if err := cli.NetworkConnect(a.context, netName, resp.ID, &network.EndpointSettings{}); err != nil { return fmt.Errorf("failed to connect %s to %s: %w", spec.Name, netName, err) } diff --git a/src/internal/ctl/app/docker_container_test.go b/src/internal/ctl/app/docker_container_test.go new file mode 100644 index 0000000..f63a30b --- /dev/null +++ b/src/internal/ctl/app/docker_container_test.go @@ -0,0 +1,111 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/ctl/spec" +) + +func TestContainerCreationValidationAndRollback(t *testing.T) { + for _, tc := range []struct { + name string + existing bool + invalidLimit bool + networks []string + failPath string + wantError bool + wantDeletes int + }{ + {"no_networks", false, false, nil, "", false, 0}, + {"invalid_settings_preserve_running_container", true, true, nil, "", true, 0}, + {"start_failure", false, false, []string{"main"}, "/containers/created-id/start", true, 1}, + {"network_failure", false, false, []string{"main", "extra"}, "/networks/extra/connect", true, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + deletes := make(chan string, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1.47") + w.Header().Set("Content-Type", "application/json") + if path == tc.failPath { + w.WriteHeader(500) + w.Write([]byte(`{"message":"injected failure"}`)) + return + } + switch { + case r.Method == "DELETE": + deletes <- path + w.WriteHeader(204) + case path == "/containers/json": + if tc.existing { + w.Write([]byte(`[{"Id":"old","Names":["/auth"]}]`)) + } else { + w.Write([]byte(`[]`)) + } + case path == "/containers/create": + w.WriteHeader(201) + w.Write([]byte(`{"Id":"created-id"}`)) + case strings.HasSuffix(path, "/start") || strings.HasSuffix(path, "/connect"): + w.WriteHeader(204) + default: + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"message": "unexpected API: " + path}) + } + })) + defer server.Close() + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(server.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(server.Client())) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + a := &App{dockerClient: cli, context: context.Background()} + spec := spec.RuntimeContainerSpec{Name: "auth", Image: "test", Networks: tc.networks} + if tc.invalidLimit { + spec.Limits.Memory = "invalid" + } + err = a.ensureContainer(spec) + if (err != nil) != tc.wantError { + t.Fatalf("error=%v", err) + } + if len(deletes) != tc.wantDeletes { + t.Fatalf("removed %d containers, expected %d", len(deletes), tc.wantDeletes) + } + for len(deletes) > 0 { + if id := <-deletes; id != "/containers/created-id" { + t.Fatalf("removed unrelated container %s", id) + } + } + }) + } +} + +func TestInvalidPortPreservesExistingContainer(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + t.Errorf("invalid port reached Docker API: %s", r.URL.Path) + w.WriteHeader(500) + })) + defer server.Close() + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(server.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(server.Client())) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + a := &App{dockerClient: cli, context: context.Background()} + for _, port := range []string{"invalid:8080", "65536:8080", "8080:0"} { + if err := a.ensureContainer(spec.RuntimeContainerSpec{Name: "auth", Image: "test", Ports: []string{port}}); err == nil { + t.Fatalf("accepted port %q", port) + } + } + // HTTP handlers are joined by Close before the server's state is examined. + server.Close() + if calls != 0 { + t.Fatal("modified Docker resources") + } +} diff --git a/src/internal/ctl/app/runtime_test.go b/src/internal/ctl/app/runtime_test.go index fe9d0c2..68151e7 100644 --- a/src/internal/ctl/app/runtime_test.go +++ b/src/internal/ctl/app/runtime_test.go @@ -156,3 +156,42 @@ func TestAutoEnsureManagerSpec(t *testing.T) { } } } + +func TestAbsoluteHostPathsAreCleanedBeforeDiskOperations(t *testing.T) { + a := &App{runtimeRoot: t.TempDir()} + a.Config.Volumes.Host.Share = "/storage/share/" + a.Config.Volumes.Host.Readonly = "/storage/unused/../readonly/" + a.normalizeConfigPaths() + if a.Config.Volumes.Host.Share != "/storage/share" || a.Config.Volumes.Host.Readonly != "/storage/readonly" { + t.Fatal("absolute path suffixes would change disk image locations") + } +} + +func TestConfigRejectsTyposAndMultipleDocuments(t *testing.T) { + for _, tc := range []struct { + name, contents string + valid bool + }{ + {"hyphenated key", "volumes:\n auto-ensure: true\n", true}, + {"misspelled key", "volumes:\n auto_ensure: true\n", false}, + {"unknown section", "unknown: true\n", false}, + {"duplicate documents", "volumes:\n auto-ensure: true\n---\nvolumes:\n auto-ensure: false\n", false}, + {"trailing empty document", "volumes: {}\n---\n", false}, + {"empty file", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + a := &App{runtimeRoot: root, configFile: filepath.Join(root, "config.yml")} + if err := os.WriteFile(a.configFile, []byte(tc.contents), 0600); err != nil { + t.Fatal(err) + } + err := a.LoadConfig() + if (err == nil) != tc.valid { + t.Fatalf("accepted=%t, want %t: %v", err == nil, tc.valid, err) + } + if tc.valid && !a.Config.Volumes.AutoEnsure { + t.Fatal("auto-ensure was not loaded") + } + }) + } +} diff --git a/src/internal/ctl/app/service.go b/src/internal/ctl/app/service.go index ea451ac..5b043e0 100644 --- a/src/internal/ctl/app/service.go +++ b/src/internal/ctl/app/service.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "strings" @@ -55,10 +56,9 @@ func (a *App) ServiceDown(params *cli.Parameters) error { log.Log(log.RUN_PREFIX, "Stopping runtime-managed containers...") - if err := a.removeManagedContainers(); err != nil { - return err - } - if err := a.stopDiskService(); err != nil { + containerErr := a.removeManagedContainers() + diskErr := a.stopDiskService() + if err := errors.Join(containerErr, diskErr); err != nil { return err } if err := a.removeManagedNetworks(); err != nil { diff --git a/src/internal/ctl/cli/cli.go b/src/internal/ctl/cli/cli.go index a4b45f5..1f8efd6 100644 --- a/src/internal/ctl/cli/cli.go +++ b/src/internal/ctl/cli/cli.go @@ -1,6 +1,9 @@ package cli -import "strings" +import ( + "fmt" + "strings" +) const TRUE_STR = "true" const FALSE_STR = "false" @@ -30,33 +33,44 @@ func IsKeyword(s string) bool { } // ParseParams converts CLI arguments into a structured Parameters instance, separating key-value pairs and the main parameter. -func ParseParams(params []string) *Parameters { - result := make(map[string]string) - mainParam := "" - +func ParseParams(params []string) (*Parameters, error) { + result := NewParameters() for i := 0; i < len(params); i++ { param := params[i] - - if IsKeyword(param) { - key := strings.TrimLeft(param, "-") - - if i+1 < len(params) && !IsKeyword(params[i+1]) { - result[key] = params[i+1] - i++ - } else { - result[key] = TRUE_STR + if !strings.HasPrefix(param, "-") { + if result.MainParam != "" || param == "" { + return nil, fmt.Errorf("unexpected positional argument %q", param) } - + result.MainParam = param continue } - - if mainParam == "" { - mainParam = param + key, value, hasValue := strings.Cut(param, "=") + switch key { + case "--all", "-a": + key = "all" + if hasValue { + return nil, fmt.Errorf("--all does not accept a value") + } + value = TRUE_STR + case "--user", "-u": + key = "user" + if !hasValue { + if i+1 == len(params) || strings.HasPrefix(params[i+1], "-") { + return nil, fmt.Errorf("--user requires a username") + } + i++ + value = params[i] + } + if value == "" { + return nil, fmt.Errorf("--user requires a username") + } + default: + return nil, fmt.Errorf("unknown option %q", key) } + if _, exists := result.Params[key]; exists { + return nil, fmt.Errorf("duplicate option --%s", key) + } + result.Params[key] = value } - - return &Parameters{ - Params: result, - MainParam: mainParam, - } + return result, nil } diff --git a/src/internal/manager/handler/handle_session.go b/src/internal/manager/handler/handle_session.go index 77e07bc..b9851e7 100644 --- a/src/internal/manager/handler/handle_session.go +++ b/src/internal/manager/handler/handle_session.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto/subtle" "encoding/json" "fmt" "log" @@ -23,7 +24,7 @@ func (s *Server) HandleUserSessionState(w http.ResponseWriter, r *http.Request) } if s.cfg.ManagerSessionSecret != "" { - if r.Header.Get("X-Manager-Session-Secret") != s.cfg.ManagerSessionSecret { + if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Manager-Session-Secret")), []byte(s.cfg.ManagerSessionSecret)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -35,8 +36,8 @@ func (s *Server) HandleUserSessionState(w http.ResponseWriter, r *http.Request) return } - if req.UserID == "" { - http.Error(w, "missing user_id", http.StatusBadRequest) + if !ruleset.AllowedUserID(req.UserID) { + http.Error(w, "invalid user_id", http.StatusBadRequest) return } @@ -67,6 +68,9 @@ func (s *Server) updateSessionState(userID string, active int, observedAt time.T s.runtimes[userID] = rt } + if observedAt.Before(rt.LastObservedAt) { + return + } prev := rt.ActiveSessions rt.ActiveSessions = active @@ -80,6 +84,9 @@ func (s *Server) updateSessionState(userID string, active int, observedAt time.T } else if rt.IdleSince.IsZero() { rt.IdleSince = observedAt } + if rt.IdleSince.Before(rt.LastPreparedAt) { + rt.IdleSince = rt.LastPreparedAt + } } } @@ -150,7 +157,8 @@ func (s *Server) resolveUserRuntimeNames(ctx context.Context, userID string) (st // disconnectAuthFromUserNetwork detaches the auth container from a user network. func (s *Server) disconnectAuthFromUserNetwork(ctx context.Context, networkName string) error { if err := s.docker.NetworkDisconnect(ctx, networkName, s.cfg.AuthContainerName, true); err != nil { - if strings.Contains(strings.ToLower(err.Error()), "not found") || + if errdefs.IsNotFound(err) || strings.Contains(strings.ToLower(err.Error()), "not found") || + strings.Contains(strings.ToLower(err.Error()), "not connected") || strings.Contains(strings.ToLower(err.Error()), "already disconnected") { return nil } @@ -163,7 +171,7 @@ func (s *Server) disconnectAuthFromUserNetwork(ctx context.Context, networkName // removeNetwork removes a user network if present. func (s *Server) removeNetwork(ctx context.Context, name string) error { if err := s.docker.NetworkRemove(ctx, name); err != nil { - if strings.Contains(strings.ToLower(err.Error()), "not found") { + if errdefs.IsNotFound(err) || strings.Contains(strings.ToLower(err.Error()), "not found") { return nil } return fmt.Errorf("network remove failed: %w", err) diff --git a/src/internal/manager/handler/handle_up.go b/src/internal/manager/handler/handle_up.go index 07f1739..c0f6a99 100644 --- a/src/internal/manager/handler/handle_up.go +++ b/src/internal/manager/handler/handle_up.go @@ -4,8 +4,10 @@ import ( "context" "crypto/subtle" "encoding/json" + "errors" "fmt" "log" + "net" "net/http" "path/filepath" "strings" @@ -84,20 +86,24 @@ func (s *Server) HandleUserUp(w http.ResponseWriter, r *http.Request) { } // ensureUserRuntimeReady ensures a user container and network are ready to serve requests. -func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (*packet.UserUpResponse, error) { +func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (response *packet.UserUpResponse, retErr error) { if !ruleset.AllowedUserID(userID) { return nil, fmt.Errorf("user_id contains invalid characters") } - s.prepareMu.Lock() - defer s.prepareMu.Unlock() - if err := ctx.Err(); err != nil { + if err := s.prepareMu.LockContext(ctx); err != nil { return nil, err } + defer s.prepareMu.Unlock() + defer func() { + if retErr == nil { + s.markRuntimeReady(userID) + } + }() containerName := s.cfg.UserContainerNamePrefix + userID if _, err := s.docker.ImageInspect(ctx, s.cfg.UserImage, client.ImageInspectWithRawResponse(nil)); err != nil { - return nil, fmt.Errorf("user image not found: %s", s.cfg.UserImage) + return nil, fmt.Errorf("inspect user image %s: %w", s.cfg.UserImage, err) } exists, running, err := s.inspectContainerState(ctx, containerName) @@ -148,17 +154,25 @@ func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (*pa networkName = fmt.Sprintf("%sidx_%d", s.cfg.NetworkPrefix, index) } - if err := s.createNetwork(ctx, networkName, subnet); err != nil { + networkID, err := s.createNetwork(ctx, networkName, subnet) + if err != nil { return nil, err } - - if err := s.createUserContainer(ctx, containerName, userID, networkName); err != nil { + var containerID string + authConnected := false + defer func() { + if retErr != nil { + retErr = errors.Join(retErr, s.rollbackNewRuntime(ctx, containerID, networkID, authConnected)) + } + }() + containerID, err = s.createUserContainer(ctx, containerName, userID, networkName) + if err != nil { return nil, err } - if err := s.ensureAuthConnected(ctx, networkName); err != nil { return nil, err } + authConnected = true if _, err := s.waitForContainerIP(ctx, containerName, networkName); err != nil { return nil, err @@ -247,11 +261,11 @@ func (s *Server) ensureAuthConnected(ctx context.Context, networkName string) er } // createUserContainer creates and starts a user runtime container on the target network. -func (s *Server) createUserContainer(ctx context.Context, containerName, userID, networkName string) error { +func (s *Server) createUserContainer(ctx context.Context, containerName, userID, networkName string) (string, error) { baseDir := strings.TrimRight(s.cfg.HostHomesDir, "/") homeDir := filepath.Clean(baseDir + "/" + userID) if !strings.HasPrefix(homeDir, baseDir+"/") { - return fmt.Errorf("invalid user_id: path traversal detected") + return "", fmt.Errorf("invalid user_id: path traversal detected") } cfg := &container.Config{ @@ -319,14 +333,14 @@ func (s *Server) createUserContainer(ctx context.Context, containerName, userID, resp, err := s.docker.ContainerCreate(ctx, cfg, hostCfg, networkingCfg, nil, containerName) if err != nil { - return fmt.Errorf("container create failed: %w", err) + return "", fmt.Errorf("container create failed: %w", err) } if err := s.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { - return fmt.Errorf("container start failed: %w", err) + return resp.ID, fmt.Errorf("container start failed: %w", err) } - return nil + return resp.ID, nil } // waitForContainerIP polls until a container obtains an IPv4 on the target network. @@ -367,50 +381,53 @@ func (s *Server) containerIPv4OnNetwork(ctx context.Context, containerName, netw return ep.IPAddress, nil } -// findFirstFreeNetworkSlot finds an unused subnet slot for a new user network. +// findFirstFreeNetworkSlot avoids every Docker network's IPAM ranges, including +// unrelated networks and larger ranges that span several user slots. func (s *Server) findFirstFreeNetworkSlot(ctx context.Context) (int, string, error) { - networks, err := s.docker.NetworkList(ctx, network.ListOptions{ - Filters: filters.NewArgs(filters.KeyValuePair{ - Key: "name", - Value: "^" + s.cfg.NetworkPrefix, - }), - }) + if !subnet.IsValidSubnet16(s.cfg.BaseIP) { + return 0, "", fmt.Errorf("invalid base /16 subnet: %s", s.cfg.BaseIP) + } + networks, err := s.docker.NetworkList(ctx, network.ListOptions{}) if err != nil { return 0, "", fmt.Errorf("network list failed: %w", err) } - - used := make(map[int]struct{}) - + var occupied []*net.IPNet for _, nw := range networks { - if !strings.HasPrefix(nw.Name, s.cfg.NetworkPrefix) { - continue - } - - inspect, err := s.docker.NetworkInspect(ctx, nw.ID, network.InspectOptions{}) - if err != nil { - return 0, "", fmt.Errorf("network inspect failed: %w", err) - } - if len(inspect.IPAM.Config) == 0 { - continue - } - - sn := strings.TrimSpace(inspect.IPAM.Config[0].Subnet) - idx, ok := subnet.SubnetToIndex(s.cfg.BaseIP, sn) - if ok { - used[idx] = struct{}{} + for _, cfg := range nw.IPAM.Config { + raw := strings.TrimSpace(cfg.Subnet) + if raw == "" { + continue + } + ip, block, err := net.ParseCIDR(raw) + if err != nil { + return 0, "", fmt.Errorf("invalid subnet on network %s: %w", nw.Name, err) + } + if ip.To4() != nil { + occupied = append(occupied, block) + } } } - - for idx := 0; ; idx++ { - if _, exists := used[idx]; exists { - continue + for idx := 0; idx < 4096; idx++ { + if err := ctx.Err(); err != nil { + return 0, "", err } - sn, err := subnet.GetSubnetByIndex(s.cfg.BaseIP, idx) + candidate, err := subnet.GetSubnetByIndex(s.cfg.BaseIP, idx) if err != nil { return 0, "", err } - return idx, sn, nil + _, block, _ := net.ParseCIDR(candidate) + overlaps := false + for _, used := range occupied { + if block.Contains(used.IP) || used.Contains(block.IP) { + overlaps = true + break + } + } + if !overlaps { + return idx, candidate, nil + } } + return 0, "", fmt.Errorf("no free /28 subnet in %s/16", s.cfg.BaseIP) } // existNetwork reports whether a Docker network with exact name exists. @@ -432,24 +449,43 @@ func (s *Server) existNetwork(ctx context.Context, name string) (bool, error) { return false, nil } -// createNetwork creates a managed user network if it does not already exist. -func (s *Server) createNetwork(ctx context.Context, name, subnet string) error { +// createNetwork creates a new, isolated network and returns its immutable ID. +func (s *Server) createNetwork(ctx context.Context, name, subnet string) (string, error) { if exists, err := s.existNetwork(ctx, name); err != nil { - return err + return "", err } else if exists { - return nil + return "", fmt.Errorf("network name is already in use: %s", name) } - - _, err := s.docker.NetworkCreate(ctx, name, network.CreateOptions{ + created, err := s.docker.NetworkCreate(ctx, name, network.CreateOptions{ Driver: "bridge", - IPAM: &network.IPAM{ - Config: []network.IPAMConfig{ - {Subnet: subnet}, - }, - }, + IPAM: &network.IPAM{Config: []network.IPAMConfig{{Subnet: subnet}}}, }) if err != nil { - return fmt.Errorf("network create failed: %w", err) + return "", fmt.Errorf("network create failed: %w", err) } - return nil + return created.ID, nil +} + +// rollbackNewRuntime only removes resources created by this request. Cleanup +// still runs if the original preparation context expired or was canceled. +func (s *Server) rollbackNewRuntime(ctx context.Context, containerID, networkID string, authConnected bool) error { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + var failures []error + if containerID != "" { + if err := s.docker.ContainerRemove(cleanupCtx, containerID, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) { + failures = append(failures, fmt.Errorf("rollback container %s: %w", containerID, err)) + } + } + if networkID != "" { + if authConnected { + if err := s.disconnectAuthFromUserNetwork(cleanupCtx, networkID); err != nil { + failures = append(failures, err) + } + } + if err := s.removeNetwork(cleanupCtx, networkID); err != nil { + failures = append(failures, err) + } + } + return errors.Join(failures...) } diff --git a/src/internal/manager/handler/handle_up_test.go b/src/internal/manager/handler/handle_up_test.go index b912233..3b969b8 100644 --- a/src/internal/manager/handler/handle_up_test.go +++ b/src/internal/manager/handler/handle_up_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "strings" "sync" "sync/atomic" @@ -28,6 +29,8 @@ type runtimeDocker struct { mutations []string hostConfig container.HostConfig beforeMutation func() + failPath string + existingNetwork string } func (d *runtimeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -35,13 +38,24 @@ func (d *runtimeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer d.mu.Unlock() path := strings.TrimPrefix(r.URL.Path, "/v1.47") w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost { + if r.Method == http.MethodPost || r.Method == http.MethodDelete { if d.beforeMutation != nil { d.beforeMutation() } d.mutations = append(d.mutations, path) } + if path == d.failPath { + w.WriteHeader(500) + w.Write([]byte(`{"message":"injected failure"}`)) + return + } switch { + case r.Method == http.MethodDelete: + if strings.HasPrefix(path, "/containers/") { + d.exists = false + d.running = false + } + w.WriteHeader(http.StatusNoContent) case path == "/images/test-user/json": json.NewEncoder(w).Encode(map[string]any{"Id": "image-id"}) case path == "/containers/user_alice/json": @@ -55,7 +69,11 @@ func (d *runtimeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) { "NetworkSettings": map[string]any{"Networks": map[string]any{"net_alice": map[string]string{"IPAddress": "10.10.0.2"}}}, }) case path == "/networks": - w.Write([]byte("[]")) + if d.existingNetwork != "" { + json.NewEncoder(w).Encode([]map[string]string{{"Name": d.existingNetwork, "Id": "existing-id"}}) + } else { + w.Write([]byte("[]")) + } case path == "/networks/create": w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"Id":"network-id"}`)) @@ -239,3 +257,67 @@ func TestUserUpRequiresSharedSecret(t *testing.T) { } } } + +func TestFailedNewRuntimeIsRolledBack(t *testing.T) { + for _, failPath := range []string{"/containers/create", "/containers/user-id/start", "/networks/net_alice"} { + t.Run(failPath, func(t *testing.T) { + d := &runtimeDocker{failPath: failPath} + s := runtimeTestServer(t, d, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + if _, err := s.ensureUserRuntimeReady(context.Background(), "alice"); err == nil { + t.Fatal("expected preparation failure") + } + d.mu.Lock() + defer d.mu.Unlock() + if d.exists { + t.Fatal("failed preparation left a container behind") + } + if !slices.Contains(d.mutations, "/networks/network-id") { + t.Fatalf("new network leaked: %v", d.mutations) + } + if failPath != "/containers/create" && !slices.Contains(d.mutations, "/containers/user-id") { + t.Fatalf("container was not removed by ID: %v", d.mutations) + } + }) + } +} + +func TestExistingRuntimeFailureDoesNotDeleteResources(t *testing.T) { + d := &runtimeDocker{exists: true, failPath: "/containers/user_alice/start"} + s := runtimeTestServer(t, d, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + if _, err := s.ensureUserRuntimeReady(context.Background(), "alice"); err == nil { + t.Fatal("expected start failure") + } + d.mu.Lock() + defer d.mu.Unlock() + if !d.exists || len(d.mutations) != 1 { + t.Fatalf("modified existing runtime during rollback: %v", d.mutations) + } +} + +func TestNetworkNameCollisionDoesNotJoinExistingNetwork(t *testing.T) { + d := &runtimeDocker{existingNetwork: "net_alice"} + s := runtimeTestServer(t, d, nil) + if _, err := s.createNetwork(context.Background(), "net_alice", "10.10.0.0/28"); err == nil { + t.Fatal("reused another network with the same name") + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != 0 { + t.Fatal("changed an existing network") + } +} + +func TestRuntimeRollbackSurvivesRequestCancellation(t *testing.T) { + d := &runtimeDocker{exists: true} + s := runtimeTestServer(t, d, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := s.rollbackNewRuntime(ctx, "user-id", "network-id", false); err != nil { + t.Fatal(err) + } + d.mu.Lock() + defer d.mu.Unlock() + if d.exists || !slices.Contains(d.mutations, "/networks/network-id") { + t.Fatalf("cancellation prevented cleanup: %v", d.mutations) + } +} diff --git a/src/internal/manager/handler/lifecycle_test.go b/src/internal/manager/handler/lifecycle_test.go new file mode 100644 index 0000000..3881377 --- /dev/null +++ b/src/internal/manager/handler/lifecycle_test.go @@ -0,0 +1,113 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/elecbug/linuxus/src/internal/manager/config" +) + +func TestReconnectResetsIdleDeadline(t *testing.T) { + d := &runtimeDocker{exists: true, running: true} + s := runtimeTestServer(t, d, nil) + s.cfg.ContainerTimeout = time.Minute + s.runtimes = map[string]*RuntimeState{"alice": {UserID: "alice", IdleSince: time.Now().Add(-time.Hour)}} + if _, err := s.ensureUserRuntimeReady(context.Background(), "alice"); err != nil { + t.Fatal(err) + } + s.reapIdleContainers(context.Background()) + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != 0 { + t.Fatalf("reaper stopped a reconnecting user: %v", d.mutations) + } +} + +func TestUserUpDeadlineWhileQueued(t *testing.T) { + s := &Server{} + s.prepareMu.Lock() + defer s.prepareMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { _, err := s.ensureUserRuntimeReady(ctx, "alice"); done <- err }() + select { + case err := <-done: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal(err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("queued request ignored its deadline") + } +} + +func TestSessionStateIgnoresOutOfOrderReports(t *testing.T) { + s := &Server{runtimes: make(map[string]*RuntimeState)} + now := time.Now() + s.updateSessionState("alice", 1, now) + s.updateSessionState("alice", 0, now.Add(-time.Hour)) + if rt := s.runtimes["alice"]; rt.ActiveSessions != 1 || !rt.IdleSince.IsZero() { + t.Fatalf("old report reset a live session: %+v", rt) + } +} + +func TestSessionStateRejectsInvalidUser(t *testing.T) { + s := &Server{cfg: &config.Config{}, runtimes: make(map[string]*RuntimeState)} + for _, id := range []string{"../outside", "a/b", " ", ""} { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/user/session-state", strings.NewReader(`{"user_id":"`+id+`","active_sessions":0}`)) + s.HandleUserSessionState(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("accepted invalid user %q: %d", id, w.Code) + } + } +} + +func TestLateIdleReportPreservesReconnectGrace(t *testing.T) { + s := &Server{runtimes: make(map[string]*RuntimeState)} + s.markRuntimeReady("alice") + prepared := s.runtimes["alice"].IdleSince + s.updateSessionState("alice", 0, prepared.Add(-time.Hour)) + if s.runtimes["alice"].IdleSince.Before(prepared) { + t.Fatal("late report expired the reconnect grace period") + } +} + +func TestReaperWaitsForPreparationAndRechecksState(t *testing.T) { + d := &runtimeDocker{exists: true, running: true} + s := runtimeTestServer(t, d, nil) + s.cfg.ContainerTimeout = time.Minute + s.runtimes = map[string]*RuntimeState{"alice": {UserID: "alice", IdleSince: time.Now().Add(-time.Hour)}} + s.prepareMu.Lock() + var once sync.Once + unlock := func() { once.Do(s.prepareMu.Unlock) } + defer unlock() + done := make(chan error, 1) + go func() { done <- s.reapIdleUser(context.Background(), "alice") }() + select { + case err := <-done: + t.Fatalf("reaper ran during runtime preparation: %v", err) + case <-time.After(50 * time.Millisecond): + } + s.markRuntimeReady("alice") + unlock() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("reaper did not resume") + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != 0 { + t.Fatalf("reaper ignored the updated deadline: %v", d.mutations) + } +} diff --git a/src/internal/manager/handler/network_allocation_test.go b/src/internal/manager/handler/network_allocation_test.go new file mode 100644 index 0000000..f94453e --- /dev/null +++ b/src/internal/manager/handler/network_allocation_test.go @@ -0,0 +1,55 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/manager/config" +) + +func TestNetworkSlotsRespectAllDockerAddressRanges(t *testing.T) { + for _, tc := range []struct { + name string + subnets []string + want int + fail bool + }{ + {"unrelated broader network", []string{"10.10.0.0/24"}, 16, false}, + {"later IPAM range", []string{"2001:db8::/64", "10.10.0.0/28"}, 1, false}, + {"narrower overlapping range", []string{"10.10.0.8/29"}, 1, false}, + {"adjacent nonoverlapping network", []string{"10.11.0.0/16"}, 0, false}, + {"all slots occupied", []string{"10.10.0.0/16"}, 0, true}, + {"invalid returned range", []string{"invalid"}, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.TrimPrefix(r.URL.Path, "/v1.47") != "/networks" || r.URL.Query().Get("filters") != "" { + t.Errorf("network discovery was restricted: %s", r.URL) + } + var ranges []network.IPAMConfig + for _, sn := range tc.subnets { + ranges = append(ranges, network.IPAMConfig{Subnet: sn}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]any{{"Name": "another-project", "Id": "external", "IPAM": network.IPAM{Config: ranges}}}) + })) + defer backend.Close() + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(backend.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(backend.Client())) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + s := &Server{docker: cli, cfg: &config.Config{BaseIP: "10.10.0.0", NetworkPrefix: "net_"}} + index, _, err := s.findFirstFreeNetworkSlot(context.Background()) + if (err != nil) != tc.fail || (!tc.fail && index != tc.want) { + t.Fatalf("slot=%d error=%v, want slot=%d fail=%t", index, err, tc.want, tc.fail) + } + }) + } +} diff --git a/src/internal/manager/handler/runtime_lock.go b/src/internal/manager/handler/runtime_lock.go new file mode 100644 index 0000000..20a69e7 --- /dev/null +++ b/src/internal/manager/handler/runtime_lock.go @@ -0,0 +1,30 @@ +package handler + +import ( + "context" + "sync" +) + +// runtimeLock serializes runtime changes while allowing queued requests to cancel. +// Its zero value is ready to use. +type runtimeLock struct { + once sync.Once + held chan struct{} +} + +func (m *runtimeLock) LockContext(ctx context.Context) error { + m.once.Do(func() { m.held = make(chan struct{}, 1) }) + select { + case <-ctx.Done(): + return ctx.Err() + case m.held <- struct{}{}: + if err := ctx.Err(); err != nil { + m.Unlock() + return err + } + return nil + } +} + +func (m *runtimeLock) Lock() { _ = m.LockContext(context.Background()) } +func (m *runtimeLock) Unlock() { <-m.held } diff --git a/src/internal/manager/handler/server.go b/src/internal/manager/handler/server.go index b0218c8..3bd0e94 100644 --- a/src/internal/manager/handler/server.go +++ b/src/internal/manager/handler/server.go @@ -27,8 +27,8 @@ type Server struct { // cfg is the active runtime configuration. cfg *config.Config - // prepareMu serializes disk preparation and runtime allocation. - prepareMu sync.Mutex + // prepareMu serializes disk preparation, runtime allocation and idle cleanup. + prepareMu runtimeLock diskClient *http.Client // mu protects runtimes map access. @@ -45,6 +45,8 @@ type RuntimeState struct { ActiveSessions int // LastObservedAt is the most recent session state observation time. LastObservedAt time.Time + // LastPreparedAt protects a newly prepared runtime until session reporting starts. + LastPreparedAt time.Time // IdleSince is when active sessions dropped to zero. IdleSince time.Time } @@ -174,25 +176,58 @@ func (s *Server) reapIdleContainers(ctx context.Context) { s.mu.Unlock() for _, userID := range candidates { - s.mu.Lock() - rt, ok := s.runtimes[userID] - if !ok || rt == nil || rt.ActiveSessions > 0 || rt.IdleSince.IsZero() || - time.Since(rt.IdleSince) <= s.cfg.ContainerTimeout { - s.mu.Unlock() - continue + if err := s.reapIdleUser(ctx, userID); err != nil { + log.Printf("idle cleanup failed for %s: %v", userID, err) } + } +} + +func (s *Server) reapIdleUser(ctx context.Context, userID string) error { + if err := s.prepareMu.LockContext(ctx); err != nil { + return err + } + defer s.prepareMu.Unlock() + + // A user-up request may have refreshed the idle deadline after selection. + s.mu.Lock() + rt, ok := s.runtimes[userID] + if !ok || rt == nil || rt.ActiveSessions > 0 || rt.IdleSince.IsZero() || + time.Since(rt.IdleSince) <= s.cfg.ContainerTimeout { s.mu.Unlock() + return nil + } + s.mu.Unlock() - if err := s.stopAndRemoveUserContainerAndNetwork(ctx, userID); err != nil { - log.Printf("idle cleanup failed for %s: %v", userID, err) - continue - } + if err := s.stopAndRemoveUserContainerAndNetwork(ctx, userID); err != nil { + return err + } - s.mu.Lock() + s.mu.Lock() + // Preserve a session update that arrived during the Docker calls. + if current := s.runtimes[userID]; current == rt && current.ActiveSessions == 0 && + !current.IdleSince.IsZero() && time.Since(current.IdleSince) > s.cfg.ContainerTimeout { delete(s.runtimes, userID) - s.mu.Unlock() + } + s.mu.Unlock() + log.Printf("idle container cleaned up for %s", userID) + return nil +} - log.Printf("idle container cleaned up for %s", userID) +// markRuntimeReady grants time for Auth to establish and report the WebSocket. +func (s *Server) markRuntimeReady(userID string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.runtimes == nil { + s.runtimes = make(map[string]*RuntimeState) + } + rt := s.runtimes[userID] + if rt == nil { + rt = &RuntimeState{UserID: userID} + s.runtimes[userID] = rt + } + rt.LastPreparedAt = time.Now() + if rt.ActiveSessions == 0 { + rt.IdleSince = rt.LastPreparedAt } } From 857199b1985922e0d6e18eba034c55a574c4997f Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 01:24:48 +0900 Subject: [PATCH 4/9] fix: correct entry formatting in AddUser function --- src/internal/common/user/user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/common/user/user.go b/src/internal/common/user/user.go index c459597..7cedbb1 100644 --- a/src/internal/common/user/user.go +++ b/src/internal/common/user/user.go @@ -111,7 +111,7 @@ func AddUser(path string, users map[string]string, id, password string) error { prefix = "\n" } } - entry := []byte(fmt.Sprintf("%s%s:%s\n", prefix, id, string(hash))) + entry := fmt.Appendf(nil, "%s%s:%s\n", prefix, id, string(hash)) if err := appendAuthEntry(file, info.Size(), entry); err != nil { return err } From 212d8fd8885cf1cd620b2729ffa8c548df3c5a73 Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 15:18:33 +0900 Subject: [PATCH 5/9] feat: introduce .env configuration support for Linuxus - Added default.env file containing default configuration settings for User, Auth, and Manager services. - Implemented InitFile function to create a private deployment configuration from embedded defaults. - Developed ParseEnv function to parse .env settings into a Config struct without modifying the process environment. - Created tests for configuration initialization, parsing, and validation to ensure correctness and robustness. - Updated validation logic to reference environment variable names consistently. - Refactored app configuration loading to read from .env instead of YAML. - Adjusted error messages to reflect new environment variable naming conventions. --- .gitignore | 5 +- README.md | 4 +- cfg/config.yml | 86 ---------- doc/USAGE.md | 105 ++++++++---- shell/linuxus-completion.bash | 4 +- src/cmd/ctl/main.go | 17 +- src/cmd/ctl/main_test.go | 4 +- src/go.mod | 1 - src/internal/common/config/config.go | 136 ++++++++-------- src/internal/common/config/default.env | 64 ++++++++ src/internal/common/config/defaults.go | 44 +++++ src/internal/common/config/defaults_test.go | 90 ++++++++++ src/internal/common/config/env.go | 172 ++++++++++++++++++++ src/internal/common/config/env_test.go | 88 ++++++++++ src/internal/common/config/routes.go | 6 +- src/internal/common/config/validate.go | 128 +++++++-------- src/internal/common/config/validate_test.go | 9 +- src/internal/ctl/app/config.go | 23 +-- src/internal/ctl/app/disk_service.go | 2 +- src/internal/ctl/app/runtime_test.go | 42 ++--- src/internal/manager/server.go | 2 +- src/internal/manager/server_test.go | 10 +- 22 files changed, 725 insertions(+), 317 deletions(-) delete mode 100644 cfg/config.yml create mode 100644 src/internal/common/config/default.env create mode 100644 src/internal/common/config/defaults.go create mode 100644 src/internal/common/config/defaults_test.go create mode 100644 src/internal/common/config/env.go create mode 100644 src/internal/common/config/env_test.go diff --git a/.gitignore b/.gitignore index c67f558..4212111 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ tmp/ temp/ *.log *.tmp -*.temp \ No newline at end of file +*.temp + +# Deployment settings may contain local secrets; defaults live in the source tree. +/.env diff --git a/README.md b/README.md index 9261430..862703d 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Linuxus enables instructors to provide per-user Linux environments without requi 🌐 Web-based shell access (no SSH required) 📁 Per-user persistent storage 🔒 Resource and permission control -📦 Single binary deployment with root-level data and volumes
-⚙️ Fully configurable via YAML +📦 Single binary deployment with embedded defaults, data and volumes
+⚙️ Configure via `.env`, generated by `linuxusctl init`
--- diff --git a/cfg/config.yml b/cfg/config.yml deleted file mode 100644 index a0ea02f..0000000 --- a/cfg/config.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Relative host paths are resolved from the directory containing linuxusctl. -user_service: - container: - name_prefix: linuxus_user_ - network_name_prefix: linuxus_user_net_ - base_subnet_16: "10.10.0.0" - runtime: - uid: 1000 - gid: 1000 - linux_username: user - linux_hostname: linuxus - timezone: Asia/Seoul - limits: - user: - cpu: 1.5 - memory: 512M - pid: 128 - ulimits: - nofile: - soft: 1024 - hard: 2048 - disk: 512M - # network_bandwidth: 10mbit - # session_timeout: 3600 - admin: - cpu: 1.5 - memory: 512M - pid: 128 - ulimits: - nofile: - soft: 1024 - hard: 2048 - disk: 512M - # network_bandwidth: 10mbit - # session_timeout: 3600 - -auth_service: - container: - name: linuxus_auth - external_port: 8080 - runtime: - timezone: Asia/Seoul - service_url: - login: login - logout: logout - service: service - terminal: terminal - signup: signup - mounts: - host_auth_list_path: data/AUTH_LIST - container_auth_list_path: /data/AUTH_LIST - security: - session_secret: change-this-secret-before-production - trusted_proxies: "" - allow_signup: true - -manager_service: - container: - name: linuxus_manager - network: linuxus_manager_net - subnet: "10.11.0.0/24" - # Legacy Manager mount paths, retained for config compatibility. - # Docker receives the host paths from volumes.host directly. - homes_dir: /vol/homes - share_dir: /vol/share - readonly_dir: /vol/readonly - user_management: - cleanup_timeout: 5m - auth_service: - connection_timeout: 30s - security: - session_secret: change-this-secret-before-production - admin_id: alpha - -volumes: - # Prepare new users' disks automatically on first shell access. - auto-ensure: true - host: - volumes: volumes - homes: volumes/homes - share: volumes/share - readonly: volumes/readonly - container: - share: /home/share - readonly: /home/readonly - disk_limit: 1G diff --git a/doc/USAGE.md b/doc/USAGE.md index a29af93..d6edc0d 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -58,30 +58,38 @@ After enabling completion, you can use `TAB` to automatically complete commands ## 3. Run Service -Copy `linuxusctl` and `cfg/config.yml` into a deployment directory: +Copy `linuxusctl` into a deployment directory and initialize its configuration: + +```bash +mkdir -p deploy +cp linuxusctl deploy/ +./deploy/linuxusctl init +``` + +`init` writes the defaults embedded in the binary to `.env` beside the executable, +with file permissions `0600`. It works without Docker or the source tree and +refuses to overwrite an existing file or symlink. The source template is +`src/internal/common/config/default.env`; rebuild to include changes to that +file. The deployment's `.env` is read at runtime and is not used during builds. ```text linuxus/ ├── linuxusctl -├── cfg/ -│ └── config.yml +├── .env ├── data/ │ └── AUTH_LIST └── volumes/ ``` `data/` and `volumes/` are initialized when starting a fresh deployment. All -relative host paths in the YAML are resolved against the directory containing +relative host paths in `.env` are resolved against the directory containing the executable, even when invoked from another working directory. Absolute -paths remain unchanged. When invoking a symlink to `linuxusctl`, the real -executable's directory is used. +paths remain absolute. When invoking a symlink to `linuxusctl`, the real +executable's directory is used for both `init` and runtime configuration. -For example, prepare a separate deployment without copying `src/`: +For example, edit the generated `.env`, then start the deployment: ```bash -mkdir -p deploy/cfg -cp linuxusctl deploy/ -cp cfg/config.yml deploy/cfg/ sudo ./deploy/linuxusctl up ``` @@ -90,35 +98,47 @@ and `losetup` (util-linux). Disk operations require root privileges. Images use the executable's architecture. The first image build needs access to the base images and Ubuntu/Debian package repositories; it does not download Go modules. -Before starting, set the two session secrets in `cfg/config.yml` to your own -values. `up` creates a missing auth file without overwriting existing accounts, +Before starting, set `AUTH_SERVICE_SECURITY_SESSION_SECRET` and +`MANAGER_SERVICE_SECURITY_SESSION_SECRET` in `.env` to your own values. `up` creates a missing auth file without +overwriting existing accounts, prepares shared disks and registered users' disks, and starts the services. -A fresh auth file has no accounts; `admin_id` designates an account's privileges +A fresh auth file has no accounts; `MANAGER_SERVICE_ADMIN_ID` designates an account's privileges and does not create the account automatically. Host directories can also point to an external storage location through absolute -YAML paths or root-level `data/` and `volumes/` symlinks. Manager passes the +`.env` paths or root-level `data/` and `volumes/` symlinks. Manager passes the resolved host paths to Docker; it does not need its own copies of the volumes. The home, shared, and readonly host paths must not overlap or resolve to the filesystem root. Keep `AUTH_LIST` outside these disk directories and images. Connection timeouts must be positive; a cleanup timeout of `0s` disables idle cleanup, and disk sizes must be greater than 1 MiB. -Unknown YAML keys (including `auto_ensure` instead of `auto-ensure`) and multiple -YAML documents are rejected. Auth route paths must be distinct, clean paths +Settings use uppercase keys with underscores, for example +`VOLUMES_AUTO_ENSURE=true` and `AUTH_SERVICE_CONTAINER_EXTERNAL_PORT=8080`. +Unknown or duplicate keys are rejected, and booleans must be `true` or `false`. +Blank lines, comments, optional `export`, and single/double quoted values are +supported. An unquoted `#` starts a comment at the beginning of a value or after +whitespace. Single quotes preserve literal values; double quotes support +`\n`, `\r`, `\t`, `\\`, `\"`, and `\$`. Values occupy one physical line; +variable expansion and shell command execution are not performed. Process +environment variables do not override `.env` settings. Missing settings are +validated normally; they are not filled from embedded defaults at runtime. + +Auth route paths must be distinct, clean paths without leading/trailing slashes or wildcard patterns; `static/` and `favicon.ico` are reserved. Nested paths such as `auth/login` are supported. Docker subnet allocation checks all existing Docker IPAM ranges to avoid conflicts with other projects. Trusted proxy CIDRs support both IPv4 and IPv6. When HTTPS terminates at a reverse proxy, include its source address in -`auth_service.security.trusted_proxies` and configure it to overwrite +`AUTH_SERVICE_SECURITY_TRUSTED_PROXIES` and configure it to overwrite `X-Forwarded-Proto` with the external scheme (`https` or `http`). Auth then sets `Secure` on HTTPS session cookies and preserves that scheme for the terminal. The Auth session cookie is removed before forwarding to a user's container. - -The legacy `manager_service.container.{homes_dir,share_dir,readonly_dir}` fields -are retained for configuration compatibility but are no longer mounted into Manager. +The legacy `MANAGER_SERVICE_CONTAINER_HOMES_DIR`, +`MANAGER_SERVICE_CONTAINER_SHARE_DIR`, and `MANAGER_SERVICE_CONTAINER_READONLY_DIR` +settings are retained for configuration compatibility but are no longer mounted +into Manager. ### 3.1 Start / Manage Services @@ -130,6 +150,7 @@ are retained for configuration compatibility but are no longer mounted into Mana | Command | Description | | --------------------------------- | ------------------------------------------------------------------------------------------------ | +| `init` | Create `.env` beside the executable using embedded defaults; preserve existing files | | `help` | Show help message | | `up` | Build images and start services | | `down` | Stop and remove services | @@ -145,6 +166,7 @@ are retained for configuration compatibility but are no longer mounted into Mana ### 3.2 Example Usage ```bash +./linuxusctl init # Create .env once sudo ./linuxusctl up # Assemble images, prepare disks and start sudo ./linuxusctl restart # Restart ./linuxusctl ps network # Show network status of linuxus service @@ -161,11 +183,10 @@ sudo ./linuxusctl restart # Restart ### 4.1 Enable Signup -Edit `cfg/config.yml`: +Edit `.env`: -```yml -auth_service: - allow_signup: true +```dotenv +AUTH_SERVICE_ALLOW_SIGNUP=true ``` A **Signup** link will appear on the login page. @@ -185,17 +206,16 @@ the auth file's Docker bind mount. 3. Enters ID and password 4. Account is registered -With `volumes.auto-ensure: true`, the first shell access prepares the newly registered user's disk automatically. With the setting omitted or false, initialize new users manually as shown below. +With `VOLUMES_AUTO_ENSURE=true`, the first shell access prepares the newly registered user's disk automatically. With the setting omitted or false, initialize new users manually as shown below. --- ### 4.3 Activate User Environment -Enable automatic disk activation in `cfg/config.yml`: +Enable automatic disk activation in `.env`: -```yaml -volumes: - auto-ensure: true +```dotenv +VOLUMES_AUTO_ENSURE=true ``` Apply the setting with `sudo ./linuxusctl restart`. `up` starts the private host @@ -214,7 +234,7 @@ after a host reboot to restore mounts and the host process; Docker container restart policies alone do not restart this process. As with the existing deployment, Docker must run locally on the Linux host. -When `auto-ensure` is omitted or false, users registered after startup need manual +When `VOLUMES_AUTO_ENSURE` is omitted or false, users registered after startup need manual activation: ```bash @@ -240,9 +260,8 @@ This step: * Default admin ID: `alpha` * Configurable in: -```yml -manager_service: - admin_id: alpha +```dotenv +MANAGER_SERVICE_ADMIN_ID=alpha ``` --- @@ -269,13 +288,31 @@ service. `down` removes containers and networks but preserves disks. than continuing deletion. Cleaning all volumes leaves unrelated files in the volume root untouched. +### Migrating from config.yml + +The CLI now reads `.env` beside the executable. It no longer reads +`cfg/config.yml`. Run `linuxusctl init` once, then transfer any deployment-specific +values from the old YAML before starting services. Nested YAML names map to +uppercase underscore-separated keys, with hyphens also replaced by underscores: + +| Previous YAML setting | `.env` key | +| --- | --- | +| `volumes.auto-ensure` | `VOLUMES_AUTO_ENSURE` | +| `volumes.host.homes` | `VOLUMES_HOST_HOMES` | +| `auth_service.security.session_secret` | `AUTH_SERVICE_SECURITY_SESSION_SECRET` | +| `user_service.limits.user.cpu` | `USER_SERVICE_LIMITS_USER_CPU` | + +Preserve both session secrets and existing host paths when migrating to keep +account sessions and storage consistent. The embedded defaults are a build-time +snapshot; they do not import another deployment's YAML automatically. + ### Migrating an existing source-relative deployment Existing `src/data` and `src/volumes` are not moved automatically. Before changing paths, stop the old services and back up `AUTH_LIST` and all disk images. To keep existing storage in place, configure its absolute host paths in the new -YAML, or link the deployment's `data/` and `volumes/` to those locations. Keep the +`.env`, or link the deployment's `data/` and `volumes/` to those locations. Keep the storage directories even if you remove the rest of the source checkout. To relocate storage completely, stop the services, unmount the old user/shared diff --git a/shell/linuxus-completion.bash b/shell/linuxus-completion.bash index 3dde97e..2cacded 100644 --- a/shell/linuxus-completion.bash +++ b/shell/linuxus-completion.bash @@ -5,7 +5,7 @@ _linuxus_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="up down restart ps add-user remove-user clean-volume ensure-disk help" + commands="init up down restart ps add-user remove-user clean-volume ensure-disk help" if [[ ${COMP_CWORD} -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) @@ -38,4 +38,4 @@ _linuxus_completion() { esac } -complete -F _linuxus_completion ./linuxusctl \ No newline at end of file +complete -F _linuxus_completion linuxusctl ./linuxusctl diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index ea32b47..cc0f824 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -38,6 +38,7 @@ const ( REMOVE_USER HELP SERVE_DISKS + INIT ) // Options encapsulates the selected operations and their parameters. @@ -69,7 +70,7 @@ func run() error { } runtimeRoot := filepath.Dir(execPath) - configFile := filepath.Join(runtimeRoot, "cfg", "config.yml") + configFile := filepath.Join(runtimeRoot, ".env") opt, err := parseArgs(os.Args[0], os.Args[1:]) if err != nil { @@ -81,6 +82,14 @@ func run() error { return nil } + if opt.Option == INIT { + if err := config.InitFile(configFile); err != nil { + return err + } + log.Log(log.DETAIL_PREFIX, "Created %s from embedded defaults.", configFile) + return nil + } + a, err := app.CreateApp(execPath, runtimeRoot, configFile) if err != nil { return err @@ -153,6 +162,8 @@ func parseArgs(bin string, args []string) (Options, error) { } switch args[0] { + case "init": + result.Option = INIT case "up": result.Option = UP case "down": @@ -189,7 +200,7 @@ func parseArgs(bin string, args []string) (Options, error) { } result.Params = parsed switch result.Option { - case UP, DOWN, RESTART, HELP, SERVE_DISKS: + case UP, DOWN, RESTART, HELP, SERVE_DISKS, INIT: if len(params) != 0 { return result, fmt.Errorf("%s does not accept arguments", args[0]) } @@ -233,6 +244,7 @@ func usageText(bin string, showUsage, showExample, showLogFormat bool) string { result += "\n" result += "Options:\n" result += "├─ General:\n" + result += fmt.Sprintf("│ ├─ %-35s# Create .env beside this executable from embedded defaults\n", "init") result += fmt.Sprintf("│ └─ %-35s# Show help message\n", "help") result += "│\n" result += "├─ Service Management:\n" @@ -255,6 +267,7 @@ func usageText(bin string, showUsage, showExample, showLogFormat bool) string { if showExample { result += "\n" result += "Examples:\n" + result += fmt.Sprintf("├─ %-35s# Initialize deployment configuration\n", fmt.Sprintf("%s init", bin)) result += fmt.Sprintf("├─ %-35s# Build and start\n", fmt.Sprintf("%s up", bin)) result += fmt.Sprintf("├─ %-35s# Restart\n", fmt.Sprintf("%s restart", bin)) result += fmt.Sprintf("└─ %-35s# Show network status of linuxus service\n", fmt.Sprintf("%s ps network", bin)) diff --git a/src/cmd/ctl/main_test.go b/src/cmd/ctl/main_test.go index 77df899..588965a 100644 --- a/src/cmd/ctl/main_test.go +++ b/src/cmd/ctl/main_test.go @@ -8,7 +8,7 @@ func TestRejectAmbiguousArguments(t *testing.T) { {"clean-volume", "--user", "alice", "--user", "bob"}, {"ensure-disk", "--user", "alice", "-u", "bob"}, {"ensure-disk", "--all", "--user", "alice"}, - {"ps", "unknown"}, {"ps", "container", "extra"}, {"up", "extra"}, {"help", "extra"}, + {"ps", "unknown"}, {"ps", "container", "extra"}, {"init", "extra"}, {"init", "--all"}, {"up", "extra"}, {"help", "extra"}, {"serve-disks", "--all"}, {"add-user", "--unknown", "alice"}, {"clean-volume", "--all=false"}, {"add-user", "--user", "../outside"}, } { @@ -20,7 +20,7 @@ func TestRejectAmbiguousArguments(t *testing.T) { func TestValidArguments(t *testing.T) { for _, args := range [][]string{ - {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, + {"init"}, {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, {"ensure-disk", "--all"}, {"clean-volume", "-a"}, {"add-user", "--user", "true"}, {"remove-user", "-u", "alice"}, {"ensure-disk", "--user=alice"}, diff --git a/src/go.mod b/src/go.mod index fbc4a03..07aa902 100644 --- a/src/go.mod +++ b/src/go.mod @@ -8,7 +8,6 @@ require ( github.com/docker/go-connections v0.7.0 golang.org/x/crypto v0.52.0 golang.org/x/term v0.43.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/src/internal/common/config/config.go b/src/internal/common/config/config.go index 9823a47..13a4aa6 100644 --- a/src/internal/common/config/config.go +++ b/src/internal/common/config/config.go @@ -1,178 +1,178 @@ package config -// Config defines the full YAML schema consumed by linuxusctl. +// Config defines the runtime settings and .env key components consumed by linuxusctl. type Config struct { // UserService configures user image and runtime settings. UserService struct { // Container groups naming, runtime, and user limit settings. Container struct { // NamePrefix is prefixed to generated user container names. - NamePrefix string `yaml:"name_prefix"` + NamePrefix string `env:"NAME_PREFIX"` // NetworkNamePrefix is prefixed to generated user network names. - NetworkNamePrefix string `yaml:"network_name_prefix"` + NetworkNamePrefix string `env:"NETWORK_NAME_PREFIX"` // BaseSubnet16 is the base subnet used for user networking. - BaseSubnet16 string `yaml:"base_subnet_16"` - } `yaml:"container"` + BaseSubnet16 string `env:"BASE_SUBNET_16"` + } `env:"CONTAINER"` // Runtime defines execution identity inside user containers. Runtime struct { // UID is the runtime user ID. - UID int `yaml:"uid"` + UID int `env:"UID"` // GID is the runtime group ID. - GID int `yaml:"gid"` + GID int `env:"GID"` // LinuxUsername is the runtime username. - LinuxUsername string `yaml:"linux_username"` + LinuxUsername string `env:"LINUX_USERNAME"` // LinuxHostname is the default container hostname. - LinuxHostname string `yaml:"linux_hostname"` + LinuxHostname string `env:"LINUX_HOSTNAME"` // Timezone is the timezone inside the container. - Timezone string `yaml:"timezone"` - } `yaml:"runtime"` + Timezone string `env:"TIMEZONE"` + } `env:"RUNTIME"` Limits struct { // User contains resource limits for user containers. - User Limits `yaml:"user"` + User Limits `env:"USER"` // Admin contains resource limits for the admin user. - Admin Limits `yaml:"admin"` - } `yaml:"limits"` - } `yaml:"user_service"` + Admin Limits `env:"ADMIN"` + } `env:"LIMITS"` + } `env:"USER_SERVICE"` // AuthService configures the authentication gateway service. AuthService struct { // Container defines auth container runtime settings. Container struct { // Name is the auth container name. - Name string `yaml:"name"` + Name string `env:"NAME"` // ExternalPort is the host port exposed by auth service. - ExternalPort int `yaml:"external_port"` - } `yaml:"container"` + ExternalPort int `env:"EXTERNAL_PORT"` + } `env:"CONTAINER"` // Runtime defines execution identity and timezone inside auth container. Runtime struct { // Timezone is the timezone inside the auth container. - Timezone string `yaml:"timezone"` - } `yaml:"runtime"` + Timezone string `env:"TIMEZONE"` + } `env:"RUNTIME"` // ServiceURL defines auth endpoint paths. ServiceURL struct { // Login is the login route path. - Login string `yaml:"login"` + Login string `env:"LOGIN"` // Logout is the logout route path. - Logout string `yaml:"logout"` + Logout string `env:"LOGOUT"` // Service is the base service route path. - Service string `yaml:"service"` + Service string `env:"SERVICE"` // Terminal is the terminal route path. - Terminal string `yaml:"terminal"` + Terminal string `env:"TERMINAL"` // Signup is the user registration route path. - Signup string `yaml:"signup"` - } `yaml:"service_url"` + Signup string `env:"SIGNUP"` + } `env:"SERVICE_URL"` // Mounts defines host/container paths for auth list data. Mounts struct { // HostAuthListPath is the auth list path on the host. - HostAuthListPath string `yaml:"host_auth_list_path"` + HostAuthListPath string `env:"HOST_AUTH_LIST_PATH"` // ContainerAuthListPath is the mounted auth list path in container. - ContainerAuthListPath string `yaml:"container_auth_list_path"` - } `yaml:"mounts"` + ContainerAuthListPath string `env:"CONTAINER_AUTH_LIST_PATH"` + } `env:"MOUNTS"` // Security defines auth security settings. Security struct { // SessionSecret signs auth session data. - SessionSecret string `yaml:"session_secret"` + SessionSecret string `env:"SESSION_SECRET"` // TrustedProxies is the trusted proxy CIDR list. - TrustedProxies string `yaml:"trusted_proxies"` - } `yaml:"security"` + TrustedProxies string `env:"TRUSTED_PROXIES"` + } `env:"SECURITY"` // AllowSignup enables or disables user self-registration. - AllowSignup bool `yaml:"allow_signup"` - } `yaml:"auth_service"` + AllowSignup bool `env:"ALLOW_SIGNUP"` + } `env:"AUTH_SERVICE"` // ManagerService configures manager runtime and session behavior. ManagerService struct { // Container defines runtime and network settings for manager. Container struct { // Name is the manager container name. - Name string `yaml:"name"` + Name string `env:"NAME"` // Network is the primary manager runtime network name. - Network string `yaml:"network"` + Network string `env:"NETWORK"` // Subnet is the subnet CIDR for manager runtime network. - Subnet string `yaml:"subnet"` + Subnet string `env:"SUBNET"` // HomesDir is the in-container mount point for user home directories. - HomesDir string `yaml:"homes_dir"` + HomesDir string `env:"HOMES_DIR"` // ShareDir is the in-container mount point for writable shared data. - ShareDir string `yaml:"share_dir"` + ShareDir string `env:"SHARE_DIR"` // ReadonlyDir is the in-container mount point for read-only shared data. - ReadonlyDir string `yaml:"readonly_dir"` - } `yaml:"container"` + ReadonlyDir string `env:"READONLY_DIR"` + } `env:"CONTAINER"` // UserManagement defines session timeout settings for managed users. UserManagement struct { // CleanupTimeout is the idle timeout for user sessions. - CleanupTimeout string `yaml:"cleanup_timeout"` - } `yaml:"user_management"` + CleanupTimeout string `env:"CLEANUP_TIMEOUT"` + } `env:"USER_MANAGEMENT"` // AuthService defines manager request/session timing behavior. AuthService struct { // ConnectionTimeout is the manager request/session timeout duration. - ConnectionTimeout string `yaml:"connection_timeout"` - } `yaml:"auth_service"` + ConnectionTimeout string `env:"CONNECTION_TIMEOUT"` + } `env:"AUTH_SERVICE"` // Security defines authentication settings for manager endpoints. Security struct { // SessionSecret signs manager session data. - SessionSecret string `yaml:"session_secret"` - } `yaml:"security"` + SessionSecret string `env:"SESSION_SECRET"` + } `env:"SECURITY"` // AdminID is the admin user ID. - AdminID string `yaml:"admin_id"` - } `yaml:"manager_service"` + AdminID string `env:"ADMIN_ID"` + } `env:"MANAGER_SERVICE"` // Volumes configures host/container volume paths and default disk size. Volumes struct { // AutoEnsure prepares user disks on demand through the host disk service. - AutoEnsure bool `yaml:"auto-ensure"` + AutoEnsure bool `env:"AUTO_ENSURE"` // Host contains host-side directories. Host struct { // Volumes is the root host directory for managed volume data. - Volumes string `yaml:"volumes"` + Volumes string `env:"VOLUMES"` // Homes is the host path for per-user home disks. - Homes string `yaml:"homes"` + Homes string `env:"HOMES"` // Share is the host path for shared writable data. - Share string `yaml:"share"` + Share string `env:"SHARE"` // Readonly is the host path for shared read-only data. - Readonly string `yaml:"readonly"` - } `yaml:"host"` + Readonly string `env:"READONLY"` + } `env:"HOST"` // Container contains container-side mount points. Container struct { // Share is the writable shared mount path in containers. - Share string `yaml:"share"` + Share string `env:"SHARE"` // Readonly is the read-only shared mount path in containers. - Readonly string `yaml:"readonly"` - } `yaml:"container"` + Readonly string `env:"READONLY"` + } `env:"CONTAINER"` // DiskLimit is the default disk image size string (e.g., 1G, 512M). - DiskLimit string `yaml:"disk_limit"` - } `yaml:"volumes"` + DiskLimit string `env:"DISK_LIMIT"` + } `env:"VOLUMES"` } // Limits defines per-container resource limits from configuration. type Limits struct { // CPU is the CPU limit value (e.g., 0.5, 1, 2). - CPU any `yaml:"cpu"` + CPU any `env:"CPU"` // Memory is the memory limit string (e.g., 512m, 1g). - Memory string `yaml:"memory"` + Memory string `env:"MEMORY"` // PID is the process count limit. - PID int `yaml:"pid"` + PID int `env:"PID"` // Disk is the per-user disk size string (e.g., 1G, 512M). - Disk string `yaml:"disk"` + Disk string `env:"DISK"` // Ulimits contains configurable Unix resource limits. Ulimits struct { Nofile struct { // Soft is the soft open-file descriptor limit. - Soft int `yaml:"soft"` + Soft int `env:"SOFT"` // Hard is the hard open-file descriptor limit. - Hard int `yaml:"hard"` - } `yaml:"nofile"` - } `yaml:"ulimits"` + Hard int `env:"HARD"` + } `env:"NOFILE"` + } `env:"ULIMITS"` } diff --git a/src/internal/common/config/default.env b/src/internal/common/config/default.env new file mode 100644 index 0000000..fe8f961 --- /dev/null +++ b/src/internal/common/config/default.env @@ -0,0 +1,64 @@ +# Linuxus default configuration, embedded in linuxusctl at build time. +# Generate a deployment .env with: linuxusctl init +# Relative host paths are resolved from the directory containing linuxusctl. +# Values are literal: no shell execution or environment-variable expansion. +# Change both session secrets before exposing a new deployment. + +# User Service +USER_SERVICE_CONTAINER_NAME_PREFIX='linuxus_user_' +USER_SERVICE_CONTAINER_NETWORK_NAME_PREFIX='linuxus_user_net_' +USER_SERVICE_CONTAINER_BASE_SUBNET_16='10.10.0.0' +USER_SERVICE_RUNTIME_UID=1000 +USER_SERVICE_RUNTIME_GID=1000 +USER_SERVICE_RUNTIME_LINUX_USERNAME='user' +USER_SERVICE_RUNTIME_LINUX_HOSTNAME='linuxus' +USER_SERVICE_RUNTIME_TIMEZONE='Asia/Seoul' +USER_SERVICE_LIMITS_USER_CPU=1.5 +USER_SERVICE_LIMITS_USER_MEMORY='512M' +USER_SERVICE_LIMITS_USER_PID=128 +USER_SERVICE_LIMITS_USER_ULIMITS_NOFILE_SOFT=1024 +USER_SERVICE_LIMITS_USER_ULIMITS_NOFILE_HARD=2048 +USER_SERVICE_LIMITS_USER_DISK='512M' +USER_SERVICE_LIMITS_ADMIN_CPU=1.5 +USER_SERVICE_LIMITS_ADMIN_MEMORY='512M' +USER_SERVICE_LIMITS_ADMIN_PID=128 +USER_SERVICE_LIMITS_ADMIN_ULIMITS_NOFILE_SOFT=1024 +USER_SERVICE_LIMITS_ADMIN_ULIMITS_NOFILE_HARD=2048 +USER_SERVICE_LIMITS_ADMIN_DISK='512M' + +# Auth Service +AUTH_SERVICE_CONTAINER_NAME='linuxus_auth' +AUTH_SERVICE_CONTAINER_EXTERNAL_PORT=8080 +AUTH_SERVICE_RUNTIME_TIMEZONE='Asia/Seoul' +AUTH_SERVICE_SERVICE_URL_LOGIN='login' +AUTH_SERVICE_SERVICE_URL_LOGOUT='logout' +AUTH_SERVICE_SERVICE_URL_SERVICE='service' +AUTH_SERVICE_SERVICE_URL_TERMINAL='terminal' +AUTH_SERVICE_SERVICE_URL_SIGNUP='signup' +AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH='data/AUTH_LIST' +AUTH_SERVICE_MOUNTS_CONTAINER_AUTH_LIST_PATH='/data/AUTH_LIST' +AUTH_SERVICE_SECURITY_SESSION_SECRET='change-this-secret-before-production' +AUTH_SERVICE_SECURITY_TRUSTED_PROXIES='' +AUTH_SERVICE_ALLOW_SIGNUP=true + +# Manager Service +MANAGER_SERVICE_CONTAINER_NAME='linuxus_manager' +MANAGER_SERVICE_CONTAINER_NETWORK='linuxus_manager_net' +MANAGER_SERVICE_CONTAINER_SUBNET='10.11.0.0/24' +MANAGER_SERVICE_CONTAINER_HOMES_DIR='/vol/homes' +MANAGER_SERVICE_CONTAINER_SHARE_DIR='/vol/share' +MANAGER_SERVICE_CONTAINER_READONLY_DIR='/vol/readonly' +MANAGER_SERVICE_USER_MANAGEMENT_CLEANUP_TIMEOUT='5m' +MANAGER_SERVICE_AUTH_SERVICE_CONNECTION_TIMEOUT='30s' +MANAGER_SERVICE_SECURITY_SESSION_SECRET='change-this-secret-before-production' +MANAGER_SERVICE_ADMIN_ID='alpha' + +# Volumes +VOLUMES_AUTO_ENSURE=true +VOLUMES_HOST_VOLUMES='volumes' +VOLUMES_HOST_HOMES='volumes/homes' +VOLUMES_HOST_SHARE='volumes/share' +VOLUMES_HOST_READONLY='volumes/readonly' +VOLUMES_CONTAINER_SHARE='/home/share' +VOLUMES_CONTAINER_READONLY='/home/readonly' +VOLUMES_DISK_LIMIT='1G' diff --git a/src/internal/common/config/defaults.go b/src/internal/common/config/defaults.go new file mode 100644 index 0000000..25bab91 --- /dev/null +++ b/src/internal/common/config/defaults.go @@ -0,0 +1,44 @@ +package config + +import ( + _ "embed" + "errors" + "fmt" + "io" + "os" +) + +// DefaultEnv is copied from the project's settings and embedded at build time. +// Deployment files are never read during a build. +// +//go:embed default.env +var DefaultEnv string + +// InitFile creates a private deployment configuration without overwriting an +// existing file or following a symlink. It needs neither Docker nor host mounts. +func InitFile(path string) (retErr error) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("configuration already exists: %s; left unchanged", path) + } + return fmt.Errorf("create configuration %s: %w", path, err) + } + defer func() { + if err := file.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close configuration: %w", err)) + } + if retErr != nil { + retErr = errors.Join(retErr, os.Remove(path)) + } + }() + if n, err := io.WriteString(file, DefaultEnv); err != nil { + return fmt.Errorf("write configuration: %w", err) + } else if n != len(DefaultEnv) { + return io.ErrShortWrite + } + if err := file.Sync(); err != nil { + return fmt.Errorf("sync configuration: %w", err) + } + return nil +} diff --git a/src/internal/common/config/defaults_test.go b/src/internal/common/config/defaults_test.go new file mode 100644 index 0000000..958de3b --- /dev/null +++ b/src/internal/common/config/defaults_test.go @@ -0,0 +1,90 @@ +package config + +import ( + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" +) + +func TestInitCreatesPrivateConfigAndPreservesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + if err := InitFile(path); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != DefaultEnv { + t.Fatal("init did not copy embedded defaults exactly") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("configuration mode=%v", info.Mode().Perm()) + } + if err := os.WriteFile(path, []byte("local settings"), 0600); err != nil { + t.Fatal(err) + } + if err := InitFile(path); err == nil { + t.Fatal("init overwrote an existing config") + } + data, err = os.ReadFile(path) + if err != nil || string(data) != "local settings" { + t.Fatal("existing settings changed") + } +} + +func TestInitDoesNotFollowExistingSymlinks(t *testing.T) { + for _, targetExists := range []bool{false, true} { + root := t.TempDir() + target := filepath.Join(root, "target") + if targetExists { + if err := os.WriteFile(target, []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(root, ".env") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if err := InitFile(path); err == nil { + t.Fatal("init followed an existing symlink") + } + if targetExists { + data, err := os.ReadFile(target) + if err != nil || string(data) != "preserve" { + t.Fatal("symlink target changed") + } + } else if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatal("init created a symlink target") + } + } +} + +func TestConcurrentInitCreatesOneCompleteFile(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + var wg sync.WaitGroup + var successes atomic.Int32 + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + if InitFile(path) == nil { + successes.Add(1) + } + }() + } + wg.Wait() + if successes.Load() != 1 { + t.Fatalf("successful initializations=%d", successes.Load()) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != DefaultEnv { + t.Fatal("concurrent init damaged defaults") + } +} diff --git a/src/internal/common/config/env.go b/src/internal/common/config/env.go new file mode 100644 index 0000000..632eae1 --- /dev/null +++ b/src/internal/common/config/env.go @@ -0,0 +1,172 @@ +package config + +import ( + "bufio" + "bytes" + "fmt" + "reflect" + "strconv" + "strings" +) + +// ParseEnv parses deployment settings without reading or modifying the process +// environment. Missing settings keep their zero values; ValidateConfig checks +// required fields after host paths have been resolved by the caller. +func ParseEnv(data []byte) (Config, error) { + var cfg Config + fields := envFields(reflect.ValueOf(&cfg).Elem(), "") + seen := make(map[string]bool) + scanner := bufio.NewScanner(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")))) + scanner.Buffer(make([]byte, 4096), 1<<20) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") || strings.HasPrefix(line, "export\t") { + line = strings.TrimSpace(line[len("export"):]) + } + key, raw, ok := strings.Cut(line, "=") + key = strings.TrimSpace(key) + if !ok || !validEnvKey(key) { + return Config{}, fmt.Errorf("line %d: expected KEY=VALUE", lineNumber) + } + field, exists := fields[key] + if !exists { + return Config{}, fmt.Errorf("line %d: unknown setting %s", lineNumber, key) + } + if seen[key] { + return Config{}, fmt.Errorf("line %d: duplicate setting %s", lineNumber, key) + } + value, err := parseEnvValue(strings.TrimSpace(raw)) + if err != nil { + return Config{}, fmt.Errorf("line %d (%s): %w", lineNumber, key, err) + } + if err := setEnvField(field, value); err != nil { + return Config{}, fmt.Errorf("line %d (%s): %w", lineNumber, key, err) + } + seen[key] = true + } + if err := scanner.Err(); err != nil { + return Config{}, fmt.Errorf("read .env at line %d: %w", lineNumber+1, err) + } + if len(seen) == 0 { + return Config{}, fmt.Errorf(".env contains no settings") + } + return cfg, nil +} + +func envFields(value reflect.Value, prefix string) map[string]reflect.Value { + fields := make(map[string]reflect.Value) + for i := 0; i < value.NumField(); i++ { + name := value.Type().Field(i).Tag.Get("env") + if name == "" { + continue + } + if prefix != "" { + name = prefix + "_" + name + } + field := value.Field(i) + if field.Kind() == reflect.Struct { + for key, child := range envFields(field, name) { + fields[key] = child + } + } else { + fields[name] = field + } + } + return fields +} + +func validEnvKey(key string) bool { + if key == "" { + return false + } + for i, ch := range key { + if ch == '_' || ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || i > 0 && ch >= '0' && ch <= '9' { + continue + } + return false + } + return true +} + +func setEnvField(field reflect.Value, value string) error { + switch field.Kind() { + case reflect.String: + field.SetString(value) + case reflect.Interface: + // CPU limits accept numeric text, retaining the existing JSON transport. + field.Set(reflect.ValueOf(value)) + case reflect.Int: + number, err := strconv.ParseInt(value, 10, field.Type().Bits()) + if err != nil { + return fmt.Errorf("expected an integer in range") + } + field.SetInt(number) + case reflect.Bool: + if value != "true" && value != "false" { + return fmt.Errorf("expected true or false") + } + field.SetBool(value == "true") + default: + return fmt.Errorf("unsupported setting type") + } + return nil +} + +// Values are literal; $VAR and $(commands) are never expanded or executed. +// Quoted values support trailing comments. Unquoted # starts a comment only +// at the beginning of the value or after whitespace, preserving secret#suffix. +func parseEnvValue(raw string) (string, error) { + if strings.ContainsRune(raw, 0) { + return "", fmt.Errorf("NUL is not allowed") + } + if raw == "" { + return "", nil + } + quote := raw[0] + if quote != '\'' && quote != '"' { + for i, ch := range raw { + if ch == '#' && (i == 0 || raw[i-1] == ' ' || raw[i-1] == '\t') { + return strings.TrimSpace(raw[:i]), nil + } + } + return raw, nil + } + var result strings.Builder + for i := 1; i < len(raw); i++ { + ch := raw[i] + if ch == quote { + tail := strings.TrimSpace(raw[i+1:]) + if tail != "" && !strings.HasPrefix(tail, "#") { + return "", fmt.Errorf("unexpected text after quoted value") + } + return result.String(), nil + } + if quote == '"' && ch == '\\' { + i++ + if i == len(raw) { + break + } + switch raw[i] { + case 'n': + result.WriteByte('\n') + case 'r': + result.WriteByte('\r') + case 't': + result.WriteByte('\t') + case '\\', '"', '$': + result.WriteByte(raw[i]) + default: + result.WriteByte('\\') + result.WriteByte(raw[i]) + } + } else { + result.WriteByte(ch) + } + } + return "", fmt.Errorf("unterminated quoted value") +} diff --git a/src/internal/common/config/env_test.go b/src/internal/common/config/env_test.go new file mode 100644 index 0000000..0146628 --- /dev/null +++ b/src/internal/common/config/env_test.go @@ -0,0 +1,88 @@ +package config + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +func TestEmbeddedEnvCoversEverySetting(t *testing.T) { + cfg, err := ParseEnv([]byte(DefaultEnv)) + if err != nil { + t.Fatal(err) + } + fields := envFields(reflect.ValueOf(&cfg).Elem(), "") + for key := range fields { + if !strings.Contains(DefaultEnv, "\n"+key+"=") { + t.Errorf("embedded defaults omit %s", key) + } + } + if cfg.UserService.Runtime.UID != 1000 || cfg.UserService.Runtime.GID != 1000 || fmt.Sprint(cfg.UserService.Limits.User.CPU) != "1.5" { + t.Fatal("default runtime identity or resource limits changed") + } + if !cfg.Volumes.AutoEnsure || !cfg.AuthService.AllowSignup || cfg.Volumes.Host.Homes != "volumes/homes" { + t.Fatal("default disk or signup behavior changed") + } +} + +func TestEnvQuotingCommentsAndLiteralValues(t *testing.T) { + for _, tc := range []struct{ name, input, want string }{ + {"plain", "value", "value"}, + {"unquoted hash", "value#suffix", "value#suffix"}, + {"comment", "value # comment", "value"}, + {"empty", "# comment", ""}, + {"single quoted", "' x # y=$VALUE ' # comment", " x # y=$VALUE "}, + {"double quoted", `"x # y=\"quoted\"" # comment`, `x # y="quoted"`}, + {"double escapes", `"a\nb\rc\t\\\$VALUE"`, "a\nb\rc\t\\$VALUE"}, + {"single literal", `'a\nb$VALUE'`, `a\nb$VALUE`}, + {"command text", "$(touch /tmp/not-executed)", "$(touch /tmp/not-executed)"}, + } { + t.Run(tc.name, func(t *testing.T) { + data := "\xef\xbb\xbf# defaults\r\nexport AUTH_SERVICE_SECURITY_SESSION_SECRET = " + tc.input + "\r\n" + cfg, err := ParseEnv([]byte(data)) + if err != nil { + t.Fatal(err) + } + if got := cfg.AuthService.Security.SessionSecret; got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestEnvRejectsAmbiguousOrInvalidSettings(t *testing.T) { + for _, tc := range []struct{ contents, message string }{ + {"UNKNOWN=value", "unknown setting UNKNOWN"}, + {"VOLUMES_AUTO_ENSURE=true\nVOLUMES_AUTO_ENSURE=false", "duplicate setting VOLUMES_AUTO_ENSURE"}, + {"VOLUMES_AUTO_ENSURE=yes", "expected true or false"}, + {"USER_SERVICE_RUNTIME_UID=abc", "expected an integer"}, + {"USER_SERVICE_RUNTIME_UID=999999999999999999999999", "expected an integer"}, + {"AUTH_SERVICE_SECURITY_SESSION_SECRET='private", "unterminated quoted value"}, + {"AUTH_SERVICE_SECURITY_SESSION_SECRET=\"private\"suffix", "unexpected text"}, + {"AUTH_SERVICE_SECURITY_SESSION_SECRET=private\x00suffix", "NUL is not allowed"}, + {"no assignment", "expected KEY=VALUE"}, + {"BAD-KEY=private", "expected KEY=VALUE"}, + {"# just comments\n", "contains no settings"}, + } { + _, err := ParseEnv([]byte(tc.contents)) + if err == nil || !strings.Contains(err.Error(), tc.message) { + t.Errorf("expected %q, got %v", tc.message, err) + } + if err != nil && strings.Contains(err.Error(), "private") { + t.Error("configuration error exposed a value") + } + } +} + +func TestEnvDoesNotInheritOrModifyProcessEnvironment(t *testing.T) { + t.Setenv("VOLUMES_AUTO_ENSURE", "true") + t.Setenv("AUTH_SERVICE_SECURITY_SESSION_SECRET", "process-secret") + cfg, err := ParseEnv([]byte("AUTH_SERVICE_SECURITY_SESSION_SECRET=$AUTH_SERVICE_SECURITY_SESSION_SECRET\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Volumes.AutoEnsure || cfg.AuthService.Security.SessionSecret != "$AUTH_SERVICE_SECURITY_SESSION_SECRET" { + t.Fatal("process environment changed file configuration") + } +} diff --git a/src/internal/common/config/routes.go b/src/internal/common/config/routes.go index b5cecc4..2aacf27 100644 --- a/src/internal/common/config/routes.go +++ b/src/internal/common/config/routes.go @@ -27,13 +27,13 @@ func ValidateAuthRoutes(cfg *Config) error { } } if !valid { - return fmt.Errorf("auth_service.service_url.%s must be a clean URL path without leading/trailing slashes, query, escapes, or wildcard patterns", route.name) + return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s must be a clean URL path without leading/trailing slashes, query, escapes, or wildcard patterns", strings.ToUpper(route.name)) } if value == "static" || strings.HasPrefix(value, "static/") || value == "favicon.ico" { - return fmt.Errorf("auth_service.service_url.%s conflicts with a reserved asset route", route.name) + return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s conflicts with a reserved asset route", strings.ToUpper(route.name)) } if previous, exists := seen[value]; exists { - return fmt.Errorf("auth_service.service_url.%s duplicates %s", route.name, previous) + return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s duplicates %s", strings.ToUpper(route.name), strings.ToUpper(previous)) } seen[value] = route.name } diff --git a/src/internal/common/config/validate.go b/src/internal/common/config/validate.go index d11c5da..903c578 100644 --- a/src/internal/common/config/validate.go +++ b/src/internal/common/config/validate.go @@ -16,69 +16,69 @@ func ValidateConfig(cfg *Config) error { errMsgs := []string{} if cfg.UserService.Container.NamePrefix == "" { - errMsgs = append(errMsgs, "user_service.container.name_prefix is required") + errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_NAME_PREFIX is required") } else if !ruleset.AllowedDockerPrefix(cfg.UserService.Container.NamePrefix) { - errMsgs = append(errMsgs, "user_service.container.name_prefix must be a valid Docker prefix") + errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_NAME_PREFIX must be a valid Docker prefix") } if cfg.UserService.Container.NetworkNamePrefix == "" { - errMsgs = append(errMsgs, "user_service.container.network_name_prefix is required") + errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_NETWORK_NAME_PREFIX is required") } else if !ruleset.AllowedDockerPrefix(cfg.UserService.Container.NetworkNamePrefix) { - errMsgs = append(errMsgs, "user_service.container.network_name_prefix must be a valid Docker prefix") + errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_NETWORK_NAME_PREFIX must be a valid Docker prefix") } if cfg.UserService.Container.BaseSubnet16 == "" { - errMsgs = append(errMsgs, "user_service.container.base_subnet_16 is required") + errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_BASE_SUBNET_16 is required") } else if !subnet.IsValidSubnet16(cfg.UserService.Container.BaseSubnet16) { - errMsgs = append(errMsgs, "user_service.container.base_subnet_16 must be a valid /16 subnet (x.x.0.0)") + errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_BASE_SUBNET_16 must be a valid /16 subnet (x.x.0.0)") } if cfg.UserService.Runtime.UID <= 0 { - errMsgs = append(errMsgs, "user_service.runtime.uid must be greater than zero") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_UID must be greater than zero") } if cfg.UserService.Runtime.GID <= 0 { - errMsgs = append(errMsgs, "user_service.runtime.gid must be greater than zero") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_GID must be greater than zero") } if cfg.UserService.Runtime.LinuxUsername == "" { - errMsgs = append(errMsgs, "user_service.runtime.linux_username is required") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_LINUX_USERNAME is required") } else if cfg.UserService.Runtime.LinuxUsername == "root" { - errMsgs = append(errMsgs, "user_service.runtime.linux_username cannot be 'root'") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_LINUX_USERNAME cannot be 'root'") } else if !ruleset.AllowedDockerID(cfg.UserService.Runtime.LinuxUsername) { - errMsgs = append(errMsgs, "user_service.runtime.linux_username must be a valid Docker ID") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_LINUX_USERNAME must be a valid Docker ID") } if cfg.UserService.Runtime.LinuxHostname == "" { - errMsgs = append(errMsgs, "user_service.runtime.linux_hostname is required") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_LINUX_HOSTNAME is required") } else if !ruleset.AllowedDockerID(cfg.UserService.Runtime.LinuxHostname) { - errMsgs = append(errMsgs, "user_service.runtime.linux_hostname must be a valid Docker ID") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_LINUX_HOSTNAME must be a valid Docker ID") } if cfg.UserService.Runtime.Timezone == "" { - errMsgs = append(errMsgs, "user_service.runtime.timezone is required") + errMsgs = append(errMsgs, "USER_SERVICE_RUNTIME_TIMEZONE is required") } if err := validateLimits(cfg.UserService.Limits.User); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("user_service.limits.user (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("USER_SERVICE_LIMITS_USER (%v)", err)) } if err := validateLimits(cfg.UserService.Limits.Admin); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("user_service.limits.admin (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("USER_SERVICE_LIMITS_ADMIN (%v)", err)) } if cfg.AuthService.Container.Name == "" { - errMsgs = append(errMsgs, "auth_service.container.name is required") + errMsgs = append(errMsgs, "AUTH_SERVICE_CONTAINER_NAME is required") } else if !ruleset.AllowedDockerID(cfg.AuthService.Container.Name) { - errMsgs = append(errMsgs, "auth_service.container.name must be a valid Docker ID") + errMsgs = append(errMsgs, "AUTH_SERVICE_CONTAINER_NAME must be a valid Docker ID") } if cfg.AuthService.Container.ExternalPort <= 0 || cfg.AuthService.Container.ExternalPort > 65535 { - errMsgs = append(errMsgs, "auth_service.container.external_port must be a valid port number (1-65535)") + errMsgs = append(errMsgs, "AUTH_SERVICE_CONTAINER_EXTERNAL_PORT must be a valid port number (1-65535)") } if cfg.AuthService.Runtime.Timezone == "" { - errMsgs = append(errMsgs, "auth_service.runtime.timezone is required") + errMsgs = append(errMsgs, "AUTH_SERVICE_RUNTIME_TIMEZONE is required") } if err := ValidateAuthRoutes(cfg); err != nil { @@ -86,139 +86,139 @@ func ValidateConfig(cfg *Config) error { } if cfg.AuthService.Mounts.HostAuthListPath == "" { - errMsgs = append(errMsgs, "auth_service.mounts.host_auth_list_path is required") + errMsgs = append(errMsgs, "AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH is required") } else if err := UsablePath(cfg.AuthService.Mounts.HostAuthListPath); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("auth_service.mounts.host_auth_list_path (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH (%v)", err)) } if cfg.AuthService.Mounts.ContainerAuthListPath == "" { - errMsgs = append(errMsgs, "auth_service.mounts.container_auth_list_path is required") + errMsgs = append(errMsgs, "AUTH_SERVICE_MOUNTS_CONTAINER_AUTH_LIST_PATH is required") } else if !strings.HasPrefix(cfg.AuthService.Mounts.ContainerAuthListPath, "/") { - errMsgs = append(errMsgs, "auth_service.mounts.container_auth_list_path must start with '/'") + errMsgs = append(errMsgs, "AUTH_SERVICE_MOUNTS_CONTAINER_AUTH_LIST_PATH must start with '/'") } if cfg.AuthService.Security.SessionSecret == "" { - errMsgs = append(errMsgs, "auth_service.security.session_secret is required") + errMsgs = append(errMsgs, "AUTH_SERVICE_SECURITY_SESSION_SECRET is required") } if err := subnet.IsValidSubnetList(cfg.AuthService.Security.TrustedProxies); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("auth_service.security.trusted_proxies (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("AUTH_SERVICE_SECURITY_TRUSTED_PROXIES (%v)", err)) } if cfg.ManagerService.Container.Name == "" { - errMsgs = append(errMsgs, "manager_service.container.name is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_NAME is required") } else if !ruleset.AllowedDockerID(cfg.ManagerService.Container.Name) { - errMsgs = append(errMsgs, "manager_service.container.name must be a valid Docker ID") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_NAME must be a valid Docker ID") } else if cfg.ManagerService.Container.Name == cfg.AuthService.Container.Name { - errMsgs = append(errMsgs, "manager_service.container.name cannot be the same as auth_service.container.name") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_NAME cannot be the same as AUTH_SERVICE_CONTAINER_NAME") } if cfg.ManagerService.Container.Network == "" { - errMsgs = append(errMsgs, "manager_service.container.network is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_NETWORK is required") } else if !ruleset.AllowedDockerID(cfg.ManagerService.Container.Network) { - errMsgs = append(errMsgs, "manager_service.container.network must be a valid Docker ID") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_NETWORK must be a valid Docker ID") } else if cfg.ManagerService.Container.Network == cfg.UserService.Container.NetworkNamePrefix { - errMsgs = append(errMsgs, "manager_service.container.network cannot be the same as user_service.container.network_name_prefix") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_NETWORK cannot be the same as USER_SERVICE_CONTAINER_NETWORK_NAME_PREFIX") } if cfg.ManagerService.Container.Subnet == "" { - errMsgs = append(errMsgs, "manager_service.container.subnet is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_SUBNET is required") } else if !subnet.IsValidSubnet(cfg.ManagerService.Container.Subnet) { - errMsgs = append(errMsgs, "manager_service.container.subnet must be a valid subnet") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_SUBNET must be a valid subnet") } if cfg.ManagerService.Container.HomesDir == "" { - errMsgs = append(errMsgs, "manager_service.container.homes_dir is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_HOMES_DIR is required") } else if !strings.HasPrefix(cfg.ManagerService.Container.HomesDir, "/") { - errMsgs = append(errMsgs, "manager_service.container.homes_dir must start with '/'") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_HOMES_DIR must start with '/'") } if cfg.ManagerService.Container.ShareDir == "" { - errMsgs = append(errMsgs, "manager_service.container.share_dir is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_SHARE_DIR is required") } else if !strings.HasPrefix(cfg.ManagerService.Container.ShareDir, "/") { - errMsgs = append(errMsgs, "manager_service.container.share_dir must start with '/'") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_SHARE_DIR must start with '/'") } if cfg.ManagerService.Container.ReadonlyDir == "" { - errMsgs = append(errMsgs, "manager_service.container.readonly_dir is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_READONLY_DIR is required") } else if !strings.HasPrefix(cfg.ManagerService.Container.ReadonlyDir, "/") { - errMsgs = append(errMsgs, "manager_service.container.readonly_dir must start with '/'") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_READONLY_DIR must start with '/'") } if cfg.ManagerService.Container.HomesDir != "" && cfg.ManagerService.Container.ShareDir != "" && cfg.ManagerService.Container.HomesDir == cfg.ManagerService.Container.ShareDir { - errMsgs = append(errMsgs, "manager_service.container.homes_dir and manager_service.container.share_dir cannot be the same") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_HOMES_DIR and MANAGER_SERVICE_CONTAINER_SHARE_DIR cannot be the same") } if cfg.ManagerService.Container.HomesDir != "" && cfg.ManagerService.Container.ReadonlyDir != "" && cfg.ManagerService.Container.HomesDir == cfg.ManagerService.Container.ReadonlyDir { - errMsgs = append(errMsgs, "manager_service.container.homes_dir and manager_service.container.readonly_dir cannot be the same") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_HOMES_DIR and MANAGER_SERVICE_CONTAINER_READONLY_DIR cannot be the same") } if cfg.ManagerService.Container.ShareDir != "" && cfg.ManagerService.Container.ReadonlyDir != "" && cfg.ManagerService.Container.ShareDir == cfg.ManagerService.Container.ReadonlyDir { - errMsgs = append(errMsgs, "manager_service.container.share_dir and manager_service.container.readonly_dir cannot be the same") + errMsgs = append(errMsgs, "MANAGER_SERVICE_CONTAINER_SHARE_DIR and MANAGER_SERVICE_CONTAINER_READONLY_DIR cannot be the same") } if cfg.ManagerService.UserManagement.CleanupTimeout == "" { - errMsgs = append(errMsgs, "manager_service.user_management.cleanup_timeout is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_USER_MANAGEMENT_CLEANUP_TIMEOUT is required") } else if duration, err := time.ParseDuration(cfg.ManagerService.UserManagement.CleanupTimeout); err != nil || duration < 0 { - errMsgs = append(errMsgs, "manager_service.user_management.cleanup_timeout must be a non-negative duration (0 disables cleanup)") + errMsgs = append(errMsgs, "MANAGER_SERVICE_USER_MANAGEMENT_CLEANUP_TIMEOUT must be a non-negative duration (0 disables cleanup)") } if cfg.ManagerService.AuthService.ConnectionTimeout == "" { - errMsgs = append(errMsgs, "manager_service.auth_service.connection_timeout is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_AUTH_SERVICE_CONNECTION_TIMEOUT is required") } else if duration, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout); err != nil || duration <= 0 { - errMsgs = append(errMsgs, "manager_service.auth_service.connection_timeout must be a positive duration (e.g., 30s, 5m)") + errMsgs = append(errMsgs, "MANAGER_SERVICE_AUTH_SERVICE_CONNECTION_TIMEOUT must be a positive duration (e.g., 30s, 5m)") } if cfg.ManagerService.Security.SessionSecret == "" { - errMsgs = append(errMsgs, "manager_service.security.session_secret is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_SECURITY_SESSION_SECRET is required") } if cfg.ManagerService.AdminID == "" { - errMsgs = append(errMsgs, "manager_service.admin_id is required") + errMsgs = append(errMsgs, "MANAGER_SERVICE_ADMIN_ID is required") } else if !ruleset.AllowedDockerID(cfg.ManagerService.AdminID) { - errMsgs = append(errMsgs, "manager_service.admin_id must be a valid Docker ID") + errMsgs = append(errMsgs, "MANAGER_SERVICE_ADMIN_ID must be a valid Docker ID") } if cfg.Volumes.Host.Volumes == "" { - errMsgs = append(errMsgs, "volumes.host.volumes is required") + errMsgs = append(errMsgs, "VOLUMES_HOST_VOLUMES is required") } else if err := UsablePath(cfg.Volumes.Host.Volumes); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("volumes.host.volumes (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_VOLUMES (%v)", err)) } if cfg.Volumes.Host.Homes == "" { - errMsgs = append(errMsgs, "volumes.host.homes is required") + errMsgs = append(errMsgs, "VOLUMES_HOST_HOMES is required") } else if err := UsablePath(cfg.Volumes.Host.Homes); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("volumes.host.homes (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_HOMES (%v)", err)) } if cfg.Volumes.Host.Share == "" { - errMsgs = append(errMsgs, "volumes.host.share is required") + errMsgs = append(errMsgs, "VOLUMES_HOST_SHARE is required") } else if err := UsablePath(cfg.Volumes.Host.Share); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("volumes.host.share (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_SHARE (%v)", err)) } if cfg.Volumes.Host.Readonly == "" { - errMsgs = append(errMsgs, "volumes.host.readonly is required") + errMsgs = append(errMsgs, "VOLUMES_HOST_READONLY is required") } else if err := UsablePath(cfg.Volumes.Host.Readonly); err != nil { - errMsgs = append(errMsgs, fmt.Sprintf("volumes.host.readonly (%v)", err)) + errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_READONLY (%v)", err)) } if cfg.Volumes.Container.Share == "" { - errMsgs = append(errMsgs, "volumes.container.share_dir is required") + errMsgs = append(errMsgs, "VOLUMES_CONTAINER_SHARE is required") } else if !strings.HasPrefix(cfg.Volumes.Container.Share, "/") { - errMsgs = append(errMsgs, "volumes.container.share_dir must start with '/'") + errMsgs = append(errMsgs, "VOLUMES_CONTAINER_SHARE must start with '/'") } if cfg.Volumes.Container.Readonly == "" { - errMsgs = append(errMsgs, "volumes.container.readonly_dir is required") + errMsgs = append(errMsgs, "VOLUMES_CONTAINER_READONLY is required") } else if !strings.HasPrefix(cfg.Volumes.Container.Readonly, "/") { - errMsgs = append(errMsgs, "volumes.container.readonly_dir must start with '/'") + errMsgs = append(errMsgs, "VOLUMES_CONTAINER_READONLY must start with '/'") } if cfg.Volumes.DiskLimit == "" { - errMsgs = append(errMsgs, "volumes.disk_limit is required") + errMsgs = append(errMsgs, "VOLUMES_DISK_LIMIT is required") } else if size, err := convert.BytesFromString(cfg.Volumes.DiskLimit); err != nil || size <= 1024*1024 { - errMsgs = append(errMsgs, "volumes.disk_limit must be a valid size greater than 1MiB (e.g., 1g, 512m)") + errMsgs = append(errMsgs, "VOLUMES_DISK_LIMIT must be a valid size greater than 1MiB (e.g., 1g, 512m)") } if err := validateVolumePaths(cfg); err != nil { diff --git a/src/internal/common/config/validate_test.go b/src/internal/common/config/validate_test.go index 3b33b9c..3020f80 100644 --- a/src/internal/common/config/validate_test.go +++ b/src/internal/common/config/validate_test.go @@ -1,7 +1,6 @@ package config import ( - "gopkg.in/yaml.v3" "os" "path/filepath" "testing" @@ -22,10 +21,6 @@ func TestUsablePathDoesNotCreateParentDirectories(t *testing.T) { } func TestRejectUnusableRuntimeSettings(t *testing.T) { - data, err := os.ReadFile("../../../../cfg/config.yml") - if err != nil { - t.Fatal(err) - } for name, change := range map[string]func(*Config){ "zero connection timeout": func(c *Config) { c.ManagerService.AuthService.ConnectionTimeout = "0s" }, "negative connection timeout": func(c *Config) { c.ManagerService.AuthService.ConnectionTimeout = "-1s" }, @@ -39,8 +34,8 @@ func TestRejectUnusableRuntimeSettings(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + cfg, err := ParseEnv([]byte(DefaultEnv)) + if err != nil { t.Fatal(err) } root := t.TempDir() diff --git a/src/internal/ctl/app/config.go b/src/internal/ctl/app/config.go index 56a154a..8c7b5b4 100644 --- a/src/internal/ctl/app/config.go +++ b/src/internal/ctl/app/config.go @@ -1,35 +1,26 @@ package app import ( - "bytes" "fmt" - "io" "os" "path/filepath" "github.com/elecbug/linuxus/src/internal/common/config" "github.com/elecbug/linuxus/src/internal/common/user" - "gopkg.in/yaml.v3" ) -// LoadConfig reads and parses the YAML configuration file into App.Config. +// LoadConfig reads and parses the .env configuration file into App.Config. func (a *App) LoadConfig() error { data, err := os.ReadFile(a.configFile) if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config %s does not exist; run linuxusctl init: %w", a.configFile, err) + } return fmt.Errorf("read config %s: %w", a.configFile, err) } - decoder := yaml.NewDecoder(bytes.NewReader(data)) - decoder.KnownFields(true) - var parsed config.Config - if err := decoder.Decode(&parsed); err != nil { - return fmt.Errorf("failed to parse yaml config: %w", err) - } - var extra yaml.Node - if err := decoder.Decode(&extra); err != io.EOF { - if err != nil { - return fmt.Errorf("failed to parse trailing yaml: %w", err) - } - return fmt.Errorf("config must contain exactly one YAML document") + parsed, err := config.ParseEnv(data) + if err != nil { + return fmt.Errorf("parse config %s: %w", a.configFile, err) } a.Config = parsed diff --git a/src/internal/ctl/app/disk_service.go b/src/internal/ctl/app/disk_service.go index de12f1d..070f263 100644 --- a/src/internal/ctl/app/disk_service.go +++ b/src/internal/ctl/app/disk_service.go @@ -159,7 +159,7 @@ func (a *App) stopDiskService() error { // It exposes no TCP listener, command execution, path selection or deletion API. func (a *App) ServeDisks() error { if !a.Config.Volumes.AutoEnsure { - return fmt.Errorf("volumes.auto-ensure is disabled") + return fmt.Errorf("VOLUMES_AUTO_ENSURE is disabled") } if os.Geteuid() != 0 { return fmt.Errorf("host disk service requires root for mounting disks") diff --git a/src/internal/ctl/app/runtime_test.go b/src/internal/ctl/app/runtime_test.go index 68151e7..4d3ac37 100644 --- a/src/internal/ctl/app/runtime_test.go +++ b/src/internal/ctl/app/runtime_test.go @@ -18,17 +18,13 @@ import ( func TestDeploymentPathsIndependentOfWorkingDirectory(t *testing.T) { root := t.TempDir() t.Chdir(t.TempDir()) - a := &App{runtimeRoot: root, configFile: filepath.Join(root, "config.yml")} - if err := os.WriteFile(a.configFile, []byte(`auth_service: - mounts: - host_auth_list_path: data/AUTH_LIST - container_auth_list_path: /data/AUTH_LIST -volumes: - host: - volumes: volumes - homes: volumes/homes - share: /external/share - readonly: volumes/readonly + a := &App{runtimeRoot: root, configFile: filepath.Join(root, ".env")} + if err := os.WriteFile(a.configFile, []byte(`AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH=data/AUTH_LIST +AUTH_SERVICE_MOUNTS_CONTAINER_AUTH_LIST_PATH=/data/AUTH_LIST +VOLUMES_HOST_VOLUMES=volumes +VOLUMES_HOST_HOMES=volumes/homes +VOLUMES_HOST_SHARE=/external/share +VOLUMES_HOST_READONLY=volumes/readonly `), 0600); err != nil { t.Fatal(err) } @@ -83,7 +79,7 @@ func TestRuntimeBuildContextsContainOnlyDeploymentAssets(t *testing.T) { root := t.TempDir() executable := filepath.Join(root, "linuxusctl") binary := []byte("test executable") - for name, data := range map[string][]byte{"linuxusctl": binary, "AUTH_LIST": []byte("private"), "disk.img": []byte("private disk")} { + for name, data := range map[string][]byte{"linuxusctl": binary, "AUTH_LIST": []byte("private"), ".env": []byte("private settings"), "disk.img": []byte("private disk")} { if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil { t.Fatal(err) } @@ -167,21 +163,22 @@ func TestAbsoluteHostPathsAreCleanedBeforeDiskOperations(t *testing.T) { } } -func TestConfigRejectsTyposAndMultipleDocuments(t *testing.T) { +func TestConfigRejectsMalformedEnv(t *testing.T) { for _, tc := range []struct { name, contents string valid bool }{ - {"hyphenated key", "volumes:\n auto-ensure: true\n", true}, - {"misspelled key", "volumes:\n auto_ensure: true\n", false}, - {"unknown section", "unknown: true\n", false}, - {"duplicate documents", "volumes:\n auto-ensure: true\n---\nvolumes:\n auto-ensure: false\n", false}, - {"trailing empty document", "volumes: {}\n---\n", false}, + {"auto ensure", "VOLUMES_AUTO_ENSURE=true\n", true}, + {"misspelled key", "VOLUMES_AUTOENSURE=true\n", false}, + {"unknown key", "UNKNOWN=true\n", false}, + {"duplicate key", "VOLUMES_AUTO_ENSURE=true\nVOLUMES_AUTO_ENSURE=false\n", false}, + {"legacy YAML", "volumes:\n auto-ensure: true\n", false}, + {"invalid bool", "VOLUMES_AUTO_ENSURE=maybe\n", false}, {"empty file", "", false}, } { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() - a := &App{runtimeRoot: root, configFile: filepath.Join(root, "config.yml")} + a := &App{runtimeRoot: root, configFile: filepath.Join(root, ".env")} if err := os.WriteFile(a.configFile, []byte(tc.contents), 0600); err != nil { t.Fatal(err) } @@ -195,3 +192,10 @@ func TestConfigRejectsTyposAndMultipleDocuments(t *testing.T) { }) } } + +func TestMissingConfigSuggestsInit(t *testing.T) { + a := &App{configFile: filepath.Join(t.TempDir(), ".env")} + if err := a.LoadConfig(); err == nil || !strings.Contains(err.Error(), "linuxusctl init") { + t.Fatalf("missing initialization hint: %v", err) + } +} diff --git a/src/internal/manager/server.go b/src/internal/manager/server.go index 097d2a3..e998985 100644 --- a/src/internal/manager/server.go +++ b/src/internal/manager/server.go @@ -54,7 +54,7 @@ func parseConfigFromEnv() (*config.Config, error) { diskSocket := os.Getenv("DISK_SERVICE_SOCKET") if cfg.Volumes.AutoEnsure && !filepath.IsAbs(diskSocket) { - return nil, fmt.Errorf("DISK_SERVICE_SOCKET must be absolute when volumes.auto-ensure is enabled") + return nil, fmt.Errorf("DISK_SERVICE_SOCKET must be absolute when VOLUMES_AUTO_ENSURE is enabled") } managerWaitTime, err := time.ParseDuration(cfg.ManagerService.AuthService.ConnectionTimeout) if err != nil { diff --git a/src/internal/manager/server_test.go b/src/internal/manager/server_test.go index 0f1dc68..e3e7258 100644 --- a/src/internal/manager/server_test.go +++ b/src/internal/manager/server_test.go @@ -2,19 +2,13 @@ package manager import ( "encoding/json" - "os" "testing" ctlconfig "github.com/elecbug/linuxus/src/internal/common/config" "github.com/elecbug/linuxus/src/internal/common/diskservice" - "gopkg.in/yaml.v3" ) func TestAutoEnsureConfigFromEnv(t *testing.T) { - data, err := os.ReadFile("../../../cfg/config.yml") - if err != nil { - t.Fatal(err) - } for _, tc := range []struct { name string enabled bool @@ -27,8 +21,8 @@ func TestAutoEnsureConfigFromEnv(t *testing.T) { {"relative_socket", true, "disk.sock", true}, } { t.Run(tc.name, func(t *testing.T) { - var cfg ctlconfig.Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + cfg, err := ctlconfig.ParseEnv([]byte(ctlconfig.DefaultEnv)) + if err != nil { t.Fatal(err) } cfg.Volumes.AutoEnsure = tc.enabled From 740d887e252cfacff79aa0da5c9ce2906767238c Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 15:39:43 +0900 Subject: [PATCH 6/9] feat: refactor configuration handling and migration process - Update `.gitignore` to include `__pycache__/` and clarify ignored files. - Modify `README.md` to reflect changes in deployment storage. - Revise `USAGE.md` to document new configuration paths and migration steps. - Introduce `migrate_system_paths.py` script for migrating legacy deployments to new system paths. - Add tests for migration functionality in `test_migrate_system_paths.py`. - Refactor `main.go` to resolve configuration file paths correctly. - Update default environment configuration to use `/var/lib/linuxus` for persistent storage. - Implement `location.go` for managing configuration file resolution. - Ensure that the application uses the correct configuration paths regardless of executable location. - Enhance error handling and validation in configuration loading. - Add tests to verify configuration handling and migration logic. --- .gitignore | 3 + README.md | 2 +- doc/USAGE.md | 139 ++++++++++----- shell/migrate_system_paths.py | 187 ++++++++++++++++++++ shell/test_migrate_system_paths.py | 117 ++++++++++++ src/cmd/ctl/main.go | 13 +- src/internal/common/config/default.env | 12 +- src/internal/common/config/defaults.go | 4 + src/internal/common/config/defaults_test.go | 21 +++ src/internal/common/config/env_test.go | 2 +- src/internal/common/config/location.go | 39 ++++ src/internal/common/config/location_test.go | 60 +++++++ src/internal/ctl/app/app.go | 5 +- src/internal/ctl/app/config.go | 19 +- src/internal/ctl/app/disk_service.go | 11 +- src/internal/ctl/app/runtime_test.go | 73 +++++++- 16 files changed, 636 insertions(+), 71 deletions(-) create mode 100644 shell/migrate_system_paths.py create mode 100644 shell/test_migrate_system_paths.py create mode 100644 src/internal/common/config/location.go create mode 100644 src/internal/common/config/location_test.go diff --git a/.gitignore b/.gitignore index 4212111..511cefa 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ temp/ # Deployment settings may contain local secrets; defaults live in the source tree. /.env + +# Python migration helper/test bytecode. +__pycache__/ diff --git a/README.md b/README.md index 862703d..863bba6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Linuxus enables instructors to provide per-user Linux environments without requi 🌐 Web-based shell access (no SSH required) 📁 Per-user persistent storage 🔒 Resource and permission control -📦 Single binary deployment with embedded defaults, data and volumes
+📦 Single binary deployment with embedded defaults and system-wide storage
⚙️ Configure via `.env`, generated by `linuxusctl init`
--- diff --git a/doc/USAGE.md b/doc/USAGE.md index d6edc0d..30085bb 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -58,39 +58,52 @@ After enabling completion, you can use `TAB` to automatically complete commands ## 3. Run Service -Copy `linuxusctl` into a deployment directory and initialize its configuration: +The executable can live anywhere. For example, install it on the system PATH: ```bash -mkdir -p deploy -cp linuxusctl deploy/ -./deploy/linuxusctl init +sudo install -m 0755 linuxusctl /usr/local/bin/linuxusctl +sudo linuxusctl init ``` -`init` writes the defaults embedded in the binary to `.env` beside the executable, -with file permissions `0600`. It works without Docker or the source tree and -refuses to overwrite an existing file or symlink. The source template is -`src/internal/common/config/default.env`; rebuild to include changes to that -file. The deployment's `.env` is read at runtime and is not used during builds. +For an existing deployment, use the migration procedure below before running +`init`. For a new deployment, `init` writes the embedded defaults to +`/etc/linuxus/.env` with file permissions `0600`, creating its directory with +mode `0700` when needed. It refuses to overwrite an existing file or symlink. +The source template is `src/internal/common/config/default.env`; rebuild to +include template changes. Runtime configuration is not used during builds. ```text -linuxus/ -├── linuxusctl -├── .env +/etc/linuxus/ +└── .env +/var/lib/linuxus/ ├── data/ -│ └── AUTH_LIST +│ ├── AUTH_LIST +│ └── .disk-service/ └── volumes/ ``` -`data/` and `volumes/` are initialized when starting a fresh deployment. All -relative host paths in `.env` are resolved against the directory containing -the executable, even when invoked from another working directory. Absolute -paths remain absolute. When invoking a symlink to `linuxusctl`, the real -executable's directory is used for both `init` and runtime configuration. +All commands select `/etc/linuxus/.env` by default, regardless of the current +working directory or executable location. Copying, renaming, or linking the +binary does not select another installation's data. Default storage paths are +absolute paths under `/var/lib/linuxus`; `up` creates missing data directories. -For example, edit the generated `.env`, then start the deployment: +To select a separate configuration, supply an absolute `LINUXUS_CONFIG` path: ```bash -sudo ./deploy/linuxusctl up +sudo env LINUXUS_CONFIG=/srv/classroom/.env /path/to/linuxusctl init +sudo env LINUXUS_CONFIG=/srv/classroom/.env /path/to/linuxusctl up +``` + +This selects the configuration only; change its host storage settings as well +if a separate data location is desired. Relative host paths are resolved against +the selected configuration's directory (the target directory for a config +symlink), never against the working directory or executable. The disk service +inherits the exact configuration selected by the parent CLI. + +Edit `/etc/linuxus/.env`, then start services from any location: + +```bash +sudo linuxusctl up ``` The runtime host needs Linux, a local Docker daemon, `mkfs.ext4` (e2fsprogs), @@ -99,14 +112,14 @@ the executable's architecture. The first image build needs access to the base images and Ubuntu/Debian package repositories; it does not download Go modules. Before starting, set `AUTH_SERVICE_SECURITY_SESSION_SECRET` and -`MANAGER_SERVICE_SECURITY_SESSION_SECRET` in `.env` to your own values. `up` creates a missing auth file without +`MANAGER_SERVICE_SECURITY_SESSION_SECRET` in `/etc/linuxus/.env` to your own values. `up` creates a missing auth file without overwriting existing accounts, prepares shared disks and registered users' disks, and starts the services. A fresh auth file has no accounts; `MANAGER_SERVICE_ADMIN_ID` designates an account's privileges and does not create the account automatically. -Host directories can also point to an external storage location through absolute -`.env` paths or root-level `data/` and `volumes/` symlinks. Manager passes the +Host directories can also point to external storage through absolute `.env` +paths or symlinks beneath `/var/lib/linuxus`. Manager passes the resolved host paths to Docker; it does not need its own copies of the volumes. The home, shared, and readonly host paths must not overlap or resolve to the filesystem root. Keep `AUTH_LIST` outside these disk directories and images. @@ -120,7 +133,8 @@ supported. An unquoted `#` starts a comment at the beginning of a value or after whitespace. Single quotes preserve literal values; double quotes support `\n`, `\r`, `\t`, `\\`, `\"`, and `\$`. Values occupy one physical line; variable expansion and shell command execution are not performed. Process -environment variables do not override `.env` settings. Missing settings are +environment variables do not override individual `.env` settings; +`LINUXUS_CONFIG` selects which configuration file to read. Missing settings are validated normally; they are not filled from embedded defaults at runtime. Auth route paths must be distinct, clean paths @@ -150,7 +164,7 @@ into Manager. | Command | Description | | --------------------------------- | ------------------------------------------------------------------------------------------------ | -| `init` | Create `.env` beside the executable using embedded defaults; preserve existing files | +| `init` | Create `/etc/linuxus/.env` using embedded defaults; preserve existing files | | `help` | Show help message | | `up` | Build images and start services | | `down` | Stop and remove services | @@ -166,7 +180,7 @@ into Manager. ### 3.2 Example Usage ```bash -./linuxusctl init # Create .env once +sudo ./linuxusctl init # Create /etc/linuxus/.env once sudo ./linuxusctl up # Assemble images, prepare disks and start sudo ./linuxusctl restart # Restart ./linuxusctl ps network # Show network status of linuxus service @@ -183,7 +197,7 @@ sudo ./linuxusctl restart # Restart ### 4.1 Enable Signup -Edit `.env`: +Edit `/etc/linuxus/.env`: ```dotenv AUTH_SERVICE_ALLOW_SIGNUP=true @@ -212,7 +226,7 @@ With `VOLUMES_AUTO_ENSURE=true`, the first shell access prepares the newly regis ### 4.3 Activate User Environment -Enable automatic disk activation in `.env`: +Enable automatic disk activation in `/etc/linuxus/.env`: ```dotenv VOLUMES_AUTO_ENSURE=true @@ -229,7 +243,7 @@ Its socket has mode 0600 in a mode-0700 `.disk-service` directory beside `AUTH_L Only Manager receives that directory as a read-only mount. There is no TCP listener. Requests accept a registered user ID only; paths and disk limits come from the deployment configuration. Manager's container capabilities are unchanged. -The default service log is `data/.disk-service/service.log`. Run `sudo ./linuxusctl up` +The default service log is `/var/lib/linuxus/data/.disk-service/service.log`. Run `sudo ./linuxusctl up` after a host reboot to restore mounts and the host process; Docker container restart policies alone do not restart this process. As with the existing deployment, Docker must run locally on the Linux host. @@ -269,7 +283,7 @@ MANAGER_SERVICE_ADMIN_ID=alpha ## 5. Volume Structure ``` -volumes/ +/var/lib/linuxus/volumes/ ├── homes/ │ ├── alice.img # ext4 disk image │ └── alice/ # mounted home @@ -288,23 +302,58 @@ service. `down` removes containers and networks but preserves disks. than continuing deletion. Cleaning all volumes leaves unrelated files in the volume root untouched. -### Migrating from config.yml +### Migrating the previous executable-relative .env/data/volumes layout -The CLI now reads `.env` beside the executable. It no longer reads -`cfg/config.yml`. Run `linuxusctl init` once, then transfer any deployment-specific -values from the old YAML before starting services. Nested YAML names map to -uppercase underscore-separated keys, with hyphens also replaced by underscores: +The CLI now reads `/etc/linuxus/.env` rather than an adjacent `.env`. Existing +live storage must be stopped and unmounted before moving it. Keep a backup before +migration; `clean-volume` must not be used because it deletes disk contents. -| Previous YAML setting | `.env` key | -| --- | --- | -| `volumes.auto-ensure` | `VOLUMES_AUTO_ENSURE` | -| `volumes.host.homes` | `VOLUMES_HOST_HOMES` | -| `auth_service.security.session_secret` | `AUTH_SERVICE_SECURITY_SESSION_SECRET` | -| `user_service.limits.user.cpu` | `USER_SERVICE_LIMITS_USER_CPU` | +For the standard previous layout, use the provided one-time migration helper +with the newly built CLI: + +```bash +sudo python3 ./shell/migrate_system_paths.py --from /absolute/old/deployment +sudo ./linuxusctl up +``` + +If the new binary is elsewhere, add `--ctl /absolute/path/to/linuxusctl`. +The helper supports ordinary `data/` and `volumes/` directories on the same +filesystem as `/var/lib`. It refuses custom layouts, existing destination data, +and cross-filesystem moves before stopping services. Python 3 is needed only +for this migration helper, not for runtime operation. + +It uses the old `.env` to stop managed containers and the disk service, locks disk +operations, unmounts nested volumes, detaches their loop devices, and verifies +that mounts and loop attachments are gone. It then renames the directories to +`/var/lib/linuxus`, preserving images, ownership, sparse allocation and inodes. +Existing `data/` and `volumes/` paths become compatibility symlinks. Configuration +is published to `/etc/linuxus/.env` with only the five host storage paths changed; +other settings, including session secrets, are preserved. A failed configuration +publication attempts to restore the original directory locations. Services remain +stopped until the subsequent `up` command remounts storage and starts them. + +For custom storage or another filesystem, perform the migration manually: + +1. Stop services with the old configuration explicitly selected: + `sudo env LINUXUS_CONFIG=/absolute/old/.env /path/to/linuxusctl down`. +2. Unmount all managed user and shared mounts, deepest first, and detach their + loop devices. Verify that none remain before copying images. +3. Copy `data/` and `volumes/` to a fresh `/var/lib/linuxus` directory, preserving + ownership, permissions, symlinks and sparse files (for example, `cp -a --sparse=always`). + Verify the copies before removing any originals. +4. Copy the original `.env` to `/etc/linuxus/.env` with permissions `0600` and + update the five host storage paths to their new absolute locations. +5. Run `sudo /path/to/linuxusctl up` and check accounts and existing home data. + +### Migrating from config.yml -Preserve both session secrets and existing host paths when migrating to keep -account sessions and storage consistent. The embedded defaults are a build-time -snapshot; they do not import another deployment's YAML automatically. +YAML configurations are no longer loaded. For an older YAML installation, +convert its settings to `.env` and apply the same storage migration sequence. +Nested YAML names map to uppercase underscore-separated keys, with hyphens also +replaced by underscores. For example, `volumes.auto-ensure` becomes +`VOLUMES_AUTO_ENSURE`, and `auth_service.security.session_secret` becomes +`AUTH_SERVICE_SECURITY_SESSION_SECRET`. Preserve session secrets and transfer +existing images rather than starting with an empty auth list or fresh disks. ### Migrating an existing source-relative deployment @@ -317,7 +366,7 @@ storage directories even if you remove the rest of the source checkout. To relocate storage completely, stop the services, unmount the old user/shared disks, and detach their loop devices before moving the directories. Copy the -`AUTH_LIST` and `.img` files into the new root's `data/` and `volumes/`, preserving +`AUTH_LIST` and `.img` files into `/var/lib/linuxus/data/` and `/var/lib/linuxus/volumes/`, preserving ownership and permissions. Then run `sudo ./linuxusctl up` to attach and mount the existing images at the new paths. Do not use `clean-volume` for migration; it deletes the images and their contents. diff --git a/shell/migrate_system_paths.py b/shell/migrate_system_paths.py new file mode 100644 index 0000000..38d7d3a --- /dev/null +++ b/shell/migrate_system_paths.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Move a standard legacy deployment to system paths without copying disk images. + +The old data/ and volumes/ directories become compatibility symlinks. This +one-time helper requires root and a single filesystem for directory renames. +""" +import argparse +from contextlib import ExitStack +import fcntl +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import tempfile + + +STORAGE = { + "AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH": "data/AUTH_LIST", + "VOLUMES_HOST_VOLUMES": "volumes", + "VOLUMES_HOST_HOMES": "volumes/homes", + "VOLUMES_HOST_SHARE": "volumes/share", + "VOLUMES_HOST_READONLY": "volumes/readonly", +} + + +def rewrite_config(text, source, destination): + """Preserve non-storage settings verbatim; refuse custom legacy layouts.""" + seen = set() + lines = [] + for line in text.splitlines(keepends=True): + match = re.fullmatch(r"\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=(.*)", line.rstrip("\r\n")) + if match and match[1] in STORAGE: + key, value = match.groups() + if key in seen: + raise RuntimeError(f"Duplicate storage setting: {key}") + tokens = shlex.split(value, comments=True, posix=True) + if len(tokens) != 1: + raise RuntimeError(f"Unsupported storage value: {key}") + path = Path(tokens[0]) + if not path.is_absolute(): + path = source / path + if path.resolve() != (source / STORAGE[key]).resolve(): + raise RuntimeError(f"Custom storage path in {key}; use the manual migration procedure") + seen.add(key) + lines.append(f"{key}={shlex.quote(str(destination / STORAGE[key]))}\n") + else: + lines.append(line) + if seen != STORAGE.keys(): + raise RuntimeError("Legacy config must specify all five host storage paths") + return "".join(lines) + + +def run(args, **kwargs): + return subprocess.run([str(arg) for arg in args], check=True, **kwargs) + + +def mounted_paths(): + unescape = lambda text: re.sub(r"\\(040|011|012|134)", lambda m: chr(int(m[1], 8)), text) + result = [] + for line in Path("/proc/self/mountinfo").read_text().splitlines(): + fields = line.split() + if len(fields) < 10 or " - " not in line: + raise RuntimeError("Invalid kernel mount table") + result.append(Path(unescape(fields[4]))) + return result + + +def within(path, root): + return path == root or root in path.parents + + +def volume_loops(root): + result = run(["losetup", "--json", "--output", "NAME,BACK-FILE"], capture_output=True, text=True) + devices = [] + for device in json.loads(result.stdout).get("loopdevices", []): + if device.get("back-file") and within(Path(device["back-file"]).resolve(), root): + devices.append(device["name"]) + return devices + + +def migrate(source, ctl, config_file=Path("/etc/linuxus/.env"), state=Path("/var/lib/linuxus")): + source, ctl = source.resolve(strict=True), ctl.resolve(strict=True) + source_config = source / ".env" + if source_config.is_symlink(): + raise RuntimeError("Use the original deployment directory, not a linked .env") + if os.path.lexists(config_file) or os.path.lexists(state): + raise RuntimeError("System configuration/storage already exists; refusing to overwrite it") + roots = [source / name for name in ("data", "volumes")] + device = state.parent.stat().st_dev + for root in roots: + if root.is_symlink() or not root.is_dir(): + raise RuntimeError(f"Expected an ordinary legacy directory: {root}") + if root.stat().st_dev != device: + raise RuntimeError("Source and /var/lib/linuxus must share a filesystem; use the manual copy procedure") + mounts = mounted_paths() + if any(root in mounts for root in roots) or any(within(p, roots[0]) for p in mounts): + raise RuntimeError("Storage root/data contains a mount; use the manual migration procedure") + updated = rewrite_config(source_config.read_text(), source, state) + config_file.parent.mkdir(parents=True, mode=0o700, exist_ok=True) + fd, pending_name = tempfile.mkstemp(prefix=".env-migration-", dir=config_file.parent) + pending = Path(pending_name) + try: + with os.fdopen(fd, "w") as output: + output.write(updated) + output.flush() + os.fsync(output.fileno()) + env = os.environ.copy() + env["LINUXUS_CONFIG"] = str(source_config) + run([ctl, "down"], env=env) + with ExitStack() as locks: + lock_dir = roots[0] / ".disk-service" + lock_dir.mkdir(mode=0o700, exist_ok=True) + for name in ("service.lock", "ensure.lock"): + lock = locks.enter_context((lock_dir / name).open("a+")) + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + volumes = roots[1] + for mount in sorted((p for p in mounted_paths() if within(p, volumes)), key=lambda p: len(p.parts), reverse=True): + run(["umount", "--", mount]) + if any(within(p, volumes) for p in mounted_paths()): + raise RuntimeError("A volume remains mounted; storage was not moved") + for device in volume_loops(volumes): + run(["losetup", "--detach", device]) + if volume_loops(volumes): + raise RuntimeError("A volume image remains attached; storage was not moved") + state.mkdir(mode=0o700) + moved, aliases = [], [] + try: + for original in roots: + target = state / original.name + original.rename(target) + moved.append((original, target)) + for original, target in moved: + original.symlink_to(target, target_is_directory=True) + aliases.append(original) + # Publish the complete private configuration without overwriting + # a file created by another administrator during migration. + os.link(pending, config_file) + except BaseException as failure: + recovery = [] + # An interrupt may arrive immediately after the link succeeds. + # Remove only the configuration inode published by this helper. + try: + if config_file.exists() and os.path.samefile(pending, config_file): + config_file.unlink() + except OSError as err: + recovery.append(str(err)) + for alias in reversed(aliases): + try: + alias.unlink() + except OSError as err: + recovery.append(str(err)) + for original, target in reversed(moved): + try: + target.rename(original) + except OSError as err: + recovery.append(str(err)) + try: + state.rmdir() + except OSError as err: + recovery.append(str(err)) + if recovery: + raise RuntimeError(f"Migration failed; recovery also failed: {'; '.join(recovery)}. Inspect {state} before restarting.") from failure + raise + finally: + pending.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--from", dest="source", type=Path, default=Path(__file__).resolve().parent.parent, help="legacy deployment directory (default: repository root)") + parser.add_argument("--ctl", type=Path, help="new linuxusctl binary (default: /linuxusctl)") + args = parser.parse_args() + if os.geteuid() != 0: + parser.exit(1, "Run this migration helper with sudo.\n") + try: + migrate(args.source, args.ctl or args.source / "linuxusctl") + except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as err: + parser.exit(1, f"Migration stopped: {err}\nServices may be stopped; check the reported condition before restarting.\n") + print("Migrated configuration to /etc/linuxus/.env and storage to /var/lib/linuxus.") + print("Existing data/ and volumes/ links point to the new locations; disk image contents and ownership are preserved.") + print("Start services with: sudo up") + + +if __name__ == "__main__": + main() diff --git a/shell/test_migrate_system_paths.py b/shell/test_migrate_system_paths.py new file mode 100644 index 0000000..e29f1dc --- /dev/null +++ b/shell/test_migrate_system_paths.py @@ -0,0 +1,117 @@ +from pathlib import Path +import os +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import migrate_system_paths as migration + + +class MigrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="linuxus migration ") + self.addCleanup(self.temp.cleanup) + base = Path(self.temp.name) + self.source = base / "old deployment" + self.source.mkdir() + self.state = base / "state" + self.config = base / "etc" / "linuxus" / ".env" + self.ctl = self.source / "linuxusctl" + self.ctl.touch() + for name in ("data", "volumes"): + (self.source / name).mkdir() + (self.source / "data" / "AUTH_LIST").write_text("alice:unchanged\n") + self.image = self.source / "volumes" / "share.img" + with self.image.open("wb") as disk: + disk.write(b"existing filesystem") + disk.truncate(8 * 1024 * 1024) + self.before = self.image.stat() + self.original_config = "# preserve settings\nAUTH_SERVICE_SECURITY_SESSION_SECRET='private value'\n" + for key, relative in migration.STORAGE.items(): + self.original_config += f"{key}='{relative}'\n" + (self.source / ".env").write_text(self.original_config) + self.commands = [] + self.mounts = [self.source / "volumes" / "share"] + self.loops = ["/dev/fake-loop"] + + def run_command(self, args, **kwargs): + self.commands.append(args) + if str(args[0]) == str(self.ctl): + self.assertEqual(kwargs["env"]["LINUXUS_CONFIG"], str(self.source / ".env")) + elif args[0] == "umount": + self.mounts.remove(args[-1]) + elif args[0] == "losetup": + self.loops.remove(args[-1]) + return subprocess.CompletedProcess(args, 0) + + def perform(self): + with patch.object(migration, "mounted_paths", side_effect=lambda: list(self.mounts)), patch.object(migration, "volume_loops", side_effect=lambda root: list(self.loops)), patch.object(migration, "run", side_effect=self.run_command): + migration.migrate(self.source, self.ctl, self.config, self.state) + + def test_move_preserves_images_settings_and_legacy_links(self): + self.perform() + after = (self.state / "volumes" / "share.img").stat() + with (self.state / "volumes" / "share.img").open("rb") as image: + self.assertEqual(image.read(len(b"existing filesystem")), b"existing filesystem") + self.assertEqual((self.before.st_ino, self.before.st_size, self.before.st_blocks, self.before.st_uid, self.before.st_gid, self.before.st_mode), (after.st_ino, after.st_size, after.st_blocks, after.st_uid, after.st_gid, after.st_mode)) + self.assertEqual((self.source / "data" / "AUTH_LIST").read_text(), "alice:unchanged\n") + self.assertTrue((self.source / "data").is_symlink()) + self.assertTrue((self.source / "volumes").is_symlink()) + self.assertIn("AUTH_SERVICE_SECURITY_SESSION_SECRET='private value'", self.config.read_text()) + self.assertEqual(self.config.stat().st_mode & 0o777, 0o600) + self.assertEqual((self.source / ".env").read_text(), self.original_config) + self.assertEqual([str(cmd[0]) for cmd in self.commands], [str(self.ctl), "umount", "losetup"]) + + def test_existing_destination_rejected_before_stopping_services(self): + self.state.mkdir() + with self.assertRaisesRegex(RuntimeError, "already exists"): + self.perform() + self.assertEqual(self.commands, []) + self.assertTrue(self.image.is_file()) + + def test_unmount_failure_leaves_data_at_old_paths(self): + original = self.run_command + def fail(args, **kwargs): + if args[0] == "umount": + raise subprocess.CalledProcessError(1, args) + return original(args, **kwargs) + self.run_command = fail + with self.assertRaises(subprocess.CalledProcessError): + self.perform() + self.assertTrue(self.image.is_file()) + self.assertFalse(self.state.exists()) + self.assertFalse(self.config.exists()) + + def test_publish_failure_rolls_back_renames(self): + with patch.object(migration.os, "link", side_effect=PermissionError("publish failed")): + with self.assertRaises(PermissionError): + self.perform() + self.assertFalse((self.source / "data").is_symlink()) + self.assertTrue(self.image.is_file()) + self.assertEqual(self.image.stat().st_ino, self.before.st_ino) + self.assertFalse(self.state.exists()) + self.assertFalse(self.config.exists()) + + def test_interrupt_after_publication_restores_original_layout(self): + real_link = os.link + def interrupt_after_link(source, target): + real_link(source, target) + raise KeyboardInterrupt() + with patch.object(migration.os, "link", side_effect=interrupt_after_link): + with self.assertRaises(KeyboardInterrupt): + self.perform() + self.assertFalse(self.config.exists()) + self.assertFalse(self.state.exists()) + self.assertTrue(self.image.is_file()) + self.assertFalse((self.source / "data").is_symlink()) + + def test_custom_paths_rejected_before_stopping_services(self): + (self.source / ".env").write_text(self.original_config.replace("'volumes/homes'", "'/other/homes'")) + with self.assertRaisesRegex(RuntimeError, "Custom storage"): + self.perform() + self.assertEqual(self.commands, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index cc0f824..9169093 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -69,9 +69,6 @@ func run() error { return err } - runtimeRoot := filepath.Dir(execPath) - configFile := filepath.Join(runtimeRoot, ".env") - opt, err := parseArgs(os.Args[0], os.Args[1:]) if err != nil { return err @@ -82,6 +79,11 @@ func run() error { return nil } + configFile, err := config.ResolveConfigFile() + if err != nil { + return err + } + if opt.Option == INIT { if err := config.InitFile(configFile); err != nil { return err @@ -90,7 +92,7 @@ func run() error { return nil } - a, err := app.CreateApp(execPath, runtimeRoot, configFile) + a, err := app.CreateApp(execPath, configFile) if err != nil { return err } @@ -242,9 +244,10 @@ func usageText(bin string, showUsage, showExample, showLogFormat bool) string { if showUsage { result += "Usage: " + fmt.Sprintf("%s [OPTION]...\n", bin) result += "\n" + result += "Config: /etc/linuxus/.env (override with LINUXUS_CONFIG=/absolute/path/.env)\n\n" result += "Options:\n" result += "├─ General:\n" - result += fmt.Sprintf("│ ├─ %-35s# Create .env beside this executable from embedded defaults\n", "init") + result += fmt.Sprintf("│ ├─ %-35s# Create /etc/linuxus/.env from embedded defaults\n", "init") result += fmt.Sprintf("│ └─ %-35s# Show help message\n", "help") result += "│\n" result += "├─ Service Management:\n" diff --git a/src/internal/common/config/default.env b/src/internal/common/config/default.env index fe8f961..60cd08a 100644 --- a/src/internal/common/config/default.env +++ b/src/internal/common/config/default.env @@ -1,6 +1,6 @@ # Linuxus default configuration, embedded in linuxusctl at build time. # Generate a deployment .env with: linuxusctl init -# Relative host paths are resolved from the directory containing linuxusctl. +# Default persistent storage is /var/lib/linuxus. Relative host paths use the .env directory. # Values are literal: no shell execution or environment-variable expansion. # Change both session secrets before exposing a new deployment. @@ -35,7 +35,7 @@ AUTH_SERVICE_SERVICE_URL_LOGOUT='logout' AUTH_SERVICE_SERVICE_URL_SERVICE='service' AUTH_SERVICE_SERVICE_URL_TERMINAL='terminal' AUTH_SERVICE_SERVICE_URL_SIGNUP='signup' -AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH='data/AUTH_LIST' +AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH='/var/lib/linuxus/data/AUTH_LIST' AUTH_SERVICE_MOUNTS_CONTAINER_AUTH_LIST_PATH='/data/AUTH_LIST' AUTH_SERVICE_SECURITY_SESSION_SECRET='change-this-secret-before-production' AUTH_SERVICE_SECURITY_TRUSTED_PROXIES='' @@ -55,10 +55,10 @@ MANAGER_SERVICE_ADMIN_ID='alpha' # Volumes VOLUMES_AUTO_ENSURE=true -VOLUMES_HOST_VOLUMES='volumes' -VOLUMES_HOST_HOMES='volumes/homes' -VOLUMES_HOST_SHARE='volumes/share' -VOLUMES_HOST_READONLY='volumes/readonly' +VOLUMES_HOST_VOLUMES='/var/lib/linuxus/volumes' +VOLUMES_HOST_HOMES='/var/lib/linuxus/volumes/homes' +VOLUMES_HOST_SHARE='/var/lib/linuxus/volumes/share' +VOLUMES_HOST_READONLY='/var/lib/linuxus/volumes/readonly' VOLUMES_CONTAINER_SHARE='/home/share' VOLUMES_CONTAINER_READONLY='/home/readonly' VOLUMES_DISK_LIMIT='1G' diff --git a/src/internal/common/config/defaults.go b/src/internal/common/config/defaults.go index 25bab91..d7149ed 100644 --- a/src/internal/common/config/defaults.go +++ b/src/internal/common/config/defaults.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "path/filepath" ) // DefaultEnv is copied from the project's settings and embedded at build time. @@ -17,6 +18,9 @@ var DefaultEnv string // InitFile creates a private deployment configuration without overwriting an // existing file or following a symlink. It needs neither Docker nor host mounts. func InitFile(path string) (retErr error) { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("create configuration directory: %w", err) + } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { if errors.Is(err, os.ErrExist) { diff --git a/src/internal/common/config/defaults_test.go b/src/internal/common/config/defaults_test.go index 958de3b..6aced21 100644 --- a/src/internal/common/config/defaults_test.go +++ b/src/internal/common/config/defaults_test.go @@ -88,3 +88,24 @@ func TestConcurrentInitCreatesOneCompleteFile(t *testing.T) { t.Fatal("concurrent init damaged defaults") } } + +func TestInitCreatesPrivateConfigDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "etc", "linuxus", ".env") + if err := InitFile(path); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0700 { + t.Fatalf("directory mode=%v", info.Mode().Perm()) + } + cfg, err := ParseEnv([]byte(DefaultEnv)) + if err != nil { + t.Fatal(err) + } + if cfg.AuthService.Mounts.HostAuthListPath != filepath.Join(DefaultStateDir, "data", "AUTH_LIST") || cfg.Volumes.Host.Volumes != filepath.Join(DefaultStateDir, "volumes") { + t.Fatal("default state still depends on the executable directory") + } +} diff --git a/src/internal/common/config/env_test.go b/src/internal/common/config/env_test.go index 0146628..a84bbdf 100644 --- a/src/internal/common/config/env_test.go +++ b/src/internal/common/config/env_test.go @@ -21,7 +21,7 @@ func TestEmbeddedEnvCoversEverySetting(t *testing.T) { if cfg.UserService.Runtime.UID != 1000 || cfg.UserService.Runtime.GID != 1000 || fmt.Sprint(cfg.UserService.Limits.User.CPU) != "1.5" { t.Fatal("default runtime identity or resource limits changed") } - if !cfg.Volumes.AutoEnsure || !cfg.AuthService.AllowSignup || cfg.Volumes.Host.Homes != "volumes/homes" { + if !cfg.Volumes.AutoEnsure || !cfg.AuthService.AllowSignup || cfg.Volumes.Host.Homes != "/var/lib/linuxus/volumes/homes" { t.Fatal("default disk or signup behavior changed") } } diff --git a/src/internal/common/config/location.go b/src/internal/common/config/location.go new file mode 100644 index 0000000..9e04233 --- /dev/null +++ b/src/internal/common/config/location.go @@ -0,0 +1,39 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + DefaultConfigFile = "/etc/linuxus/.env" + DefaultStateDir = "/var/lib/linuxus" + ConfigFileEnv = "LINUXUS_CONFIG" +) + +// ResolveConfigFile deliberately does not search the working or executable +// directory: moving the CLI must not select a different installation's state. +func ResolveConfigFile() (string, error) { + path, exists := os.LookupEnv(ConfigFileEnv) + if !exists { + return DefaultConfigFile, nil + } + if !filepath.IsAbs(path) { + return "", fmt.Errorf("%s must be an absolute configuration file path", ConfigFileEnv) + } + return filepath.Clean(path), nil +} + +// WithConfigFile replaces existing entries so the spawned disk service reads +// the same configuration as the CLI that created it. +func WithConfigFile(environment []string, path string) []string { + result := make([]string, 0, len(environment)+1) + for _, entry := range environment { + if !strings.HasPrefix(entry, ConfigFileEnv+"=") { + result = append(result, entry) + } + } + return append(result, ConfigFileEnv+"="+path) +} diff --git a/src/internal/common/config/location_test.go b/src/internal/common/config/location_test.go new file mode 100644 index 0000000..21166bd --- /dev/null +++ b/src/internal/common/config/location_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestConfigLocationIgnoresWorkingDirectory(t *testing.T) { + old, exists := os.LookupEnv(ConfigFileEnv) + if err := os.Unsetenv(ConfigFileEnv); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if exists { + os.Setenv(ConfigFileEnv, old) + } else { + os.Unsetenv(ConfigFileEnv) + } + }) + for range 2 { + root := t.TempDir() + t.Chdir(root) + if err := os.WriteFile(".env", []byte("VOLUMES_AUTO_ENSURE=false\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := ResolveConfigFile() + if err != nil || got != DefaultConfigFile { + t.Fatalf("config=%q error=%v", got, err) + } + } +} + +func TestConfigOverrideRequiresAbsolutePath(t *testing.T) { + for _, path := range []string{"", ".env", "../.env"} { + t.Setenv(ConfigFileEnv, path) + if _, err := ResolveConfigFile(); err == nil { + t.Fatalf("accepted relative override %q", path) + } + } + absolute := filepath.Join(t.TempDir(), "config", "..", ".env") + t.Setenv(ConfigFileEnv, absolute) + got, err := ResolveConfigFile() + if err != nil || got != filepath.Clean(absolute) { + t.Fatalf("config=%q error=%v", got, err) + } +} + +func TestChildConfigReplacesInheritedOverrides(t *testing.T) { + original := []string{"LANG=C", "LINUXUS_CONFIG=/stale/.env", "LINUXUS_CONFIG=/other/.env", "KEEP=value"} + got := WithConfigFile(original, "/chosen/.env") + want := []string{"LANG=C", "KEEP=value", "LINUXUS_CONFIG=/chosen/.env"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("child env=%v", got) + } + if original[1] != "LINUXUS_CONFIG=/stale/.env" { + t.Fatal("parent environment mutated") + } +} diff --git a/src/internal/ctl/app/app.go b/src/internal/ctl/app/app.go index 303b293..a7d726f 100644 --- a/src/internal/ctl/app/app.go +++ b/src/internal/ctl/app/app.go @@ -20,8 +20,6 @@ type App struct { // execPath is the absolute executable path for the running binary. execPath string - // runtimeRoot is the deployment directory containing linuxusctl. - runtimeRoot string // configFile points to the runtime configuration file. configFile string @@ -32,7 +30,7 @@ type App struct { } // CreateApp creates an App instance and initializes the Docker client. -func CreateApp(execPath, runtimeRoot, configFile string) (*App, error) { +func CreateApp(execPath, configFile string) (*App, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("failed to create Docker client: %w", err) @@ -43,7 +41,6 @@ func CreateApp(execPath, runtimeRoot, configFile string) (*App, error) { context: context.Background(), systemAPI: system_api.NewSystemAPI(), execPath: execPath, - runtimeRoot: runtimeRoot, configFile: configFile, UserIDs: nil, } diff --git a/src/internal/ctl/app/config.go b/src/internal/ctl/app/config.go index 8c7b5b4..cba2320 100644 --- a/src/internal/ctl/app/config.go +++ b/src/internal/ctl/app/config.go @@ -11,10 +11,23 @@ import ( // LoadConfig reads and parses the .env configuration file into App.Config. func (a *App) LoadConfig() error { + // Resolve config symlinks before interpreting relative storage paths so + // both a symlink and its target select the same persistent state. + configFile, err := filepath.EvalSymlinks(a.configFile) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config %s does not exist; run sudo linuxusctl init: %w", a.configFile, err) + } + return fmt.Errorf("resolve config %s: %w", a.configFile, err) + } + a.configFile, err = filepath.Abs(configFile) + if err != nil { + return err + } data, err := os.ReadFile(a.configFile) if err != nil { if os.IsNotExist(err) { - return fmt.Errorf("config %s does not exist; run linuxusctl init: %w", a.configFile, err) + return fmt.Errorf("config %s does not exist; run sudo linuxusctl init: %w", a.configFile, err) } return fmt.Errorf("read config %s: %w", a.configFile, err) } @@ -38,7 +51,7 @@ func (a *App) LoadConfig() error { return nil } -// normalizeConfigPaths resolves host paths relative to the deployment directory. +// normalizeConfigPaths resolves host paths relative to the selected configuration directory. func (a *App) normalizeConfigPaths() { resolve := func(path string) string { if path == "" { @@ -47,7 +60,7 @@ func (a *App) normalizeConfigPaths() { if filepath.IsAbs(path) { return filepath.Clean(path) } - return filepath.Join(a.runtimeRoot, path) + return filepath.Join(filepath.Dir(a.configFile), path) } a.Config.AuthService.Mounts.HostAuthListPath = resolve(a.Config.AuthService.Mounts.HostAuthListPath) a.Config.Volumes.Host.Homes = resolve(a.Config.Volumes.Host.Homes) diff --git a/src/internal/ctl/app/disk_service.go b/src/internal/ctl/app/disk_service.go index 070f263..8854082 100644 --- a/src/internal/ctl/app/disk_service.go +++ b/src/internal/ctl/app/disk_service.go @@ -16,6 +16,7 @@ import ( "syscall" "time" + "github.com/elecbug/linuxus/src/internal/common/config" "github.com/elecbug/linuxus/src/internal/common/diskservice" "github.com/elecbug/linuxus/src/internal/common/packet" "github.com/elecbug/linuxus/src/internal/common/ruleset" @@ -43,6 +44,14 @@ func (a *App) withDiskLock(action func() error) error { return action() } +// Pin the selected configuration for the child, independently of its binary's +// location or any stale LINUXUS_CONFIG inherited from the invoking shell. +func (a *App) diskServiceCommand() *exec.Cmd { + cmd := exec.Command(a.execPath, "serve-disks") + cmd.Env = config.WithConfigFile(os.Environ(), a.configFile) + return cmd +} + // startDiskService uses the same host privileges as the invoking up command. // It does not grant the Manager any additional Docker or system capabilities. func (a *App) startDiskService() error { @@ -60,7 +69,7 @@ func (a *App) startDiskService() error { return err } defer output.Close() - cmd := exec.Command(a.execPath, "serve-disks") + cmd := a.diskServiceCommand() cmd.Stdout = output cmd.Stderr = output cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} diff --git a/src/internal/ctl/app/runtime_test.go b/src/internal/ctl/app/runtime_test.go index 4d3ac37..c3ba93b 100644 --- a/src/internal/ctl/app/runtime_test.go +++ b/src/internal/ctl/app/runtime_test.go @@ -15,10 +15,10 @@ import ( "github.com/elecbug/linuxus/src/internal/common/diskservice" ) -func TestDeploymentPathsIndependentOfWorkingDirectory(t *testing.T) { +func TestStoragePathsRelativeToConfigIndependentOfWorkingDirectory(t *testing.T) { root := t.TempDir() t.Chdir(t.TempDir()) - a := &App{runtimeRoot: root, configFile: filepath.Join(root, ".env")} + a := &App{configFile: filepath.Join(root, ".env")} if err := os.WriteFile(a.configFile, []byte(`AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH=data/AUTH_LIST AUTH_SERVICE_MOUNTS_CONTAINER_AUTH_LIST_PATH=/data/AUTH_LIST VOLUMES_HOST_VOLUMES=volumes @@ -41,7 +41,7 @@ VOLUMES_HOST_READONLY=volumes/readonly t.Fatal("absolute path changed") } if a.Config.AuthService.Mounts.HostAuthListPath != filepath.Join(root, "data/AUTH_LIST") { - t.Fatal("auth path is not deployment-relative") + t.Fatal("auth path is not config-relative") } if _, err := os.Stat(filepath.Join(root, "data")); !os.IsNotExist(err) { t.Fatal("loading configuration changed disk state") @@ -154,7 +154,7 @@ func TestAutoEnsureManagerSpec(t *testing.T) { } func TestAbsoluteHostPathsAreCleanedBeforeDiskOperations(t *testing.T) { - a := &App{runtimeRoot: t.TempDir()} + a := &App{configFile: filepath.Join(t.TempDir(), ".env")} a.Config.Volumes.Host.Share = "/storage/share/" a.Config.Volumes.Host.Readonly = "/storage/unused/../readonly/" a.normalizeConfigPaths() @@ -178,7 +178,7 @@ func TestConfigRejectsMalformedEnv(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() - a := &App{runtimeRoot: root, configFile: filepath.Join(root, ".env")} + a := &App{configFile: filepath.Join(root, ".env")} if err := os.WriteFile(a.configFile, []byte(tc.contents), 0600); err != nil { t.Fatal(err) } @@ -199,3 +199,66 @@ func TestMissingConfigSuggestsInit(t *testing.T) { t.Fatalf("missing initialization hint: %v", err) } } + +func TestMovingExecutableKeepsSameConfigAndStorage(t *testing.T) { + root := t.TempDir() + cfgPath := filepath.Join(root, "settings", ".env") + if err := config.InitFile(cfgPath); err != nil { + t.Fatal(err) + } + // Override only storage paths so this test never accesses host production data. + data := strings.ReplaceAll(config.DefaultEnv, "/var/lib/linuxus/", "../state/") + if err := os.WriteFile(cfgPath, []byte(data), 0600); err != nil { + t.Fatal(err) + } + var first config.Config + for i := 0; i < 2; i++ { + a := &App{execPath: filepath.Join(t.TempDir(), "linuxusctl"), configFile: cfgPath} + t.Chdir(t.TempDir()) + if err := a.LoadConfig(); err != nil { + t.Fatal(err) + } + if a.Config.Volumes.Host.Homes != filepath.Join(root, "state", "volumes", "homes") { + t.Fatal("storage followed the executable") + } + if i == 0 { + first = a.Config + } else if !reflect.DeepEqual(first, a.Config) { + t.Fatal("moving executable changed settings") + } + t.Setenv(config.ConfigFileEnv, "/unrelated/.env") + cmd := a.diskServiceCommand() + var configs []string + for _, entry := range cmd.Env { + if strings.HasPrefix(entry, config.ConfigFileEnv+"=") { + configs = append(configs, entry) + } + } + if !reflect.DeepEqual(configs, []string{config.ConfigFileEnv + "=" + cfgPath}) { + t.Fatal("disk service selected another config") + } + } +} + +func TestConfigSymlinkUsesTargetStorageDirectory(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "configuration", ".env") + if err := config.InitFile(target); err != nil { + t.Fatal(err) + } + data := strings.ReplaceAll(config.DefaultEnv, "/var/lib/linuxus/", "../state/") + if err := os.WriteFile(target, []byte(data), 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), ".env") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + a := &App{configFile: alias} + if err := a.LoadConfig(); err != nil { + t.Fatal(err) + } + if a.configFile != target || a.Config.AuthService.Mounts.HostAuthListPath != filepath.Join(root, "state", "data", "AUTH_LIST") { + t.Fatal("config symlink redirected storage") + } +} From 9a25df96f53537018ba4f5a05f20b7587ddf1b0e Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 15:57:47 +0900 Subject: [PATCH 7/9] temp: temporary commit --- .gitignore | 4 +- shell/linuxus-completion.bash | 2 +- src/cmd/ctl/main.go | 104 +++++++- src/cmd/ctl/main_test.go | 4 +- src/docker/start.sh | 14 +- src/internal/auth/handler/app.go | 11 +- src/internal/auth/handler/handle_login.go | 3 +- src/internal/auth/handler/handle_terminal.go | 10 + src/internal/auth/handler/session_cookie.go | 2 +- src/internal/auth/handler/session_sync.go | 73 ++++++ src/internal/common/backup/archive.go | 153 +++++++++++ src/internal/common/config/config.go | 9 + src/internal/common/config/default.env | 8 + src/internal/common/config/paths.go | 3 + src/internal/common/config/templates.go | 72 +++++ src/internal/common/config/validate.go | 31 ++- src/internal/common/config/validate_test.go | 22 ++ src/internal/common/packet/auth_to_manager.go | 6 + src/internal/common/system_api/space.go | 23 ++ src/internal/common/user/account.go | 133 ++++++++++ src/internal/common/user/file_type_test.go | 28 ++ src/internal/common/user/user.go | 22 +- src/internal/ctl/app/accounts.go | 155 +++++++++++ src/internal/ctl/app/app.go | 1 + src/internal/ctl/app/backup.go | 246 ++++++++++++++++++ src/internal/ctl/app/config.go | 21 +- src/internal/ctl/app/diagnostics.go | 185 +++++++++++++ src/internal/ctl/app/diagnostics_test.go | 150 +++++++++++ src/internal/ctl/app/disk.go | 3 + src/internal/ctl/app/disk_diagnostics.go | 70 +++++ src/internal/ctl/app/disk_service.go | 6 +- src/internal/ctl/app/docker_inspect.go | 20 +- src/internal/ctl/app/docker_inspect_test.go | 62 +++++ src/internal/ctl/app/service.go | 18 +- src/internal/ctl/app/spec.go | 11 +- src/internal/ctl/app/supervisor.go | 174 +++++++++++++ src/internal/ctl/cli/cli.go | 17 +- src/internal/manager/config/config.go | 12 +- .../manager/handler/handle_session.go | 5 +- src/internal/manager/handler/handle_up.go | 28 +- src/internal/manager/handler/operations.go | 207 +++++++++++++++ src/internal/manager/handler/server.go | 11 + src/internal/manager/handler/session_sync.go | 72 +++++ src/internal/manager/server.go | 16 ++ 44 files changed, 2173 insertions(+), 54 deletions(-) create mode 100644 src/internal/auth/handler/session_sync.go create mode 100644 src/internal/common/backup/archive.go create mode 100644 src/internal/common/config/templates.go create mode 100644 src/internal/common/system_api/space.go create mode 100644 src/internal/common/user/account.go create mode 100644 src/internal/common/user/file_type_test.go create mode 100644 src/internal/ctl/app/accounts.go create mode 100644 src/internal/ctl/app/backup.go create mode 100644 src/internal/ctl/app/diagnostics.go create mode 100644 src/internal/ctl/app/diagnostics_test.go create mode 100644 src/internal/ctl/app/disk_diagnostics.go create mode 100644 src/internal/ctl/app/docker_inspect_test.go create mode 100644 src/internal/ctl/app/supervisor.go create mode 100644 src/internal/manager/handler/operations.go create mode 100644 src/internal/manager/handler/session_sync.go diff --git a/.gitignore b/.gitignore index 511cefa..eaa4485 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,10 @@ linuxusctl debug/ # Data file directory likely authentication info, user data, etc. -data/ +/data # User volumes for services, e.g., home directories, shared directories, etc. -volumes/ +/volumes # Logs and temporary files. logs/ diff --git a/shell/linuxus-completion.bash b/shell/linuxus-completion.bash index 2cacded..dac5302 100644 --- a/shell/linuxus-completion.bash +++ b/shell/linuxus-completion.bash @@ -5,7 +5,7 @@ _linuxus_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="init up down restart ps add-user remove-user clean-volume ensure-disk help" + commands="init config-check doctor up down restart ps add-user remove-user clean-volume ensure-disk help" if [[ ${COMP_CWORD} -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index 9169093..8be9fcc 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -39,12 +39,23 @@ const ( HELP SERVE_DISKS INIT + CONFIG_CHECK + DOCTOR + ACCOUNTS + LIST_USERS + TEMPLATES + BACKUP + RESTORE + VERIFY_BACKUP + SYSTEMD_UNIT + SUPERVISE ) // Options encapsulates the selected operations and their parameters. type Options struct { - Option Opt - Params *cli.Parameters + Option Opt + Command string + Params *cli.Parameters } // run initializes the application and executes selected runtime operations. @@ -92,6 +103,19 @@ func run() error { return nil } + if opt.Option == CONFIG_CHECK { + return app.CheckConfig(configFile, os.Stdout) + } + if opt.Option == DOCTOR { + return app.Doctor(configFile, os.Stdout) + } + + if opt.Option == SYSTEMD_UNIT { + return app.WriteSystemdUnit(execPath, configFile, os.Stdout) + } + if opt.Option == VERIFY_BACKUP { + return app.VerifyBackup(opt.Params.Params["file"], os.Stdout) + } a, err := app.CreateApp(execPath, configFile) if err != nil { return err @@ -108,6 +132,18 @@ func run() error { } switch opt.Option { + case LIST_USERS: + return a.ListUsers(os.Stdout) + case TEMPLATES: + return a.ListTemplates(os.Stdout) + case ACCOUNTS: + return a.ServiceAccount(opt.Command, opt.Params) + case BACKUP: + return a.BackupUser(opt.Params.Params["user"], opt.Params.Params["output"]) + case RESTORE: + return a.RestoreUser(opt.Params.Params["user"], opt.Params.Params["file"], opt.Params.Params["replace"] == cli.TRUE_STR) + case SUPERVISE: + return a.Supervise() case SERVE_DISKS: return a.ServeDisks() case UP: @@ -164,8 +200,28 @@ func parseArgs(bin string, args []string) (Options, error) { } switch args[0] { + case "list-users": + result.Option = LIST_USERS + case "templates": + result.Option = TEMPLATES + case "lock-user", "unlock-user", "reset-password", "disconnect-user", "assign-template", "assign-class": + result.Option = ACCOUNTS + case "backup-user": + result.Option = BACKUP + case "restore-user": + result.Option = RESTORE + case "verify-backup": + result.Option = VERIFY_BACKUP + case "systemd-unit": + result.Option = SYSTEMD_UNIT + case "supervise": + result.Option = SUPERVISE case "init": result.Option = INIT + case "config-check": + result.Option = CONFIG_CHECK + case "doctor": + result.Option = DOCTOR case "up": result.Option = UP case "down": @@ -190,6 +246,7 @@ func parseArgs(bin string, args []string) (Options, error) { return result, fmt.Errorf("invalid parameter: '%s'\n\n%s", args[0], usageText(bin, true, true, false)) } + result.Command = args[0] params := make([]string, 0) if len(args) > 1 { @@ -202,10 +259,49 @@ func parseArgs(bin string, args []string) (Options, error) { } result.Params = parsed switch result.Option { - case UP, DOWN, RESTART, HELP, SERVE_DISKS, INIT: + case UP, DOWN, RESTART, HELP, SERVE_DISKS, INIT, CONFIG_CHECK, DOCTOR, LIST_USERS, TEMPLATES, SYSTEMD_UNIT, SUPERVISE: if len(params) != 0 { return result, fmt.Errorf("%s does not accept arguments", args[0]) } + case ACCOUNTS, BACKUP, RESTORE, VERIFY_BACKUP: + allowed := map[string]bool{} + if result.Option != VERIFY_BACKUP { + allowed["user"] = true + } + switch result.Option { + case BACKUP: + allowed["output"] = true + case RESTORE: + allowed["file"] = true + case VERIFY_BACKUP: + allowed["file"] = true + case ACCOUNTS: + if args[0] == "assign-template" { + allowed["template"] = true + } + if args[0] == "assign-class" { + allowed["class"] = true + } + } + for key := range allowed { + if parsed.Params[key] == "" { + return result, fmt.Errorf("%s requires --%s", args[0], key) + } + } + if result.Option == RESTORE { + allowed["replace"] = true + } + for key := range parsed.Params { + if !allowed[key] { + return result, fmt.Errorf("unsupported --%s for %s", key, args[0]) + } + } + if parsed.MainParam != "" { + return result, fmt.Errorf("unexpected positional argument") + } + if id := parsed.Params["user"]; id != "" && !ruleset.AllowedUserID(id) { + return result, fmt.Errorf("invalid user ID") + } case PS: if len(parsed.Params) != 0 { return result, fmt.Errorf("ps only accepts container, network, all, c, n, or a") @@ -248,6 +344,8 @@ func usageText(bin string, showUsage, showExample, showLogFormat bool) string { result += "Options:\n" result += "├─ General:\n" result += fmt.Sprintf("│ ├─ %-35s# Create /etc/linuxus/.env from embedded defaults\n", "init") + result += fmt.Sprintf("│ ├─ %-35s# Validate settings without Docker or service changes\n", "config-check") + result += fmt.Sprintf("│ ├─ %-35s# Diagnose host prerequisites without changing state\n", "doctor") result += fmt.Sprintf("│ └─ %-35s# Show help message\n", "help") result += "│\n" result += "├─ Service Management:\n" diff --git a/src/cmd/ctl/main_test.go b/src/cmd/ctl/main_test.go index 588965a..973c252 100644 --- a/src/cmd/ctl/main_test.go +++ b/src/cmd/ctl/main_test.go @@ -8,7 +8,7 @@ func TestRejectAmbiguousArguments(t *testing.T) { {"clean-volume", "--user", "alice", "--user", "bob"}, {"ensure-disk", "--user", "alice", "-u", "bob"}, {"ensure-disk", "--all", "--user", "alice"}, - {"ps", "unknown"}, {"ps", "container", "extra"}, {"init", "extra"}, {"init", "--all"}, {"up", "extra"}, {"help", "extra"}, + {"config-check", "--all"}, {"doctor", "extra"}, {"ps", "unknown"}, {"ps", "container", "extra"}, {"init", "extra"}, {"init", "--all"}, {"up", "extra"}, {"help", "extra"}, {"serve-disks", "--all"}, {"add-user", "--unknown", "alice"}, {"clean-volume", "--all=false"}, {"add-user", "--user", "../outside"}, } { @@ -20,7 +20,7 @@ func TestRejectAmbiguousArguments(t *testing.T) { func TestValidArguments(t *testing.T) { for _, args := range [][]string{ - {"init"}, {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, + {"init"}, {"config-check"}, {"doctor"}, {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, {"ensure-disk", "--all"}, {"clean-volume", "-a"}, {"add-user", "--user", "true"}, {"remove-user", "-u", "alice"}, {"ensure-disk", "--user=alice"}, diff --git a/src/docker/start.sh b/src/docker/start.sh index b7d0ab9..d6b3b41 100755 --- a/src/docker/start.sh +++ b/src/docker/start.sh @@ -2,14 +2,20 @@ set -e -echo "ENVIRONMENT VARIABLES:" -env | sort - HOME_DIR="/home/${CONTAINER_RUNTIME_USER}" +# Seed only missing files; changing templates never overwrites student work. +if [ -n "${LINUXUS_TEMPLATE_SEED:-}" ]; then + if [ ! -d "$LINUXUS_TEMPLATE_SEED" ]; then + echo "Template seed directory is missing" >&2 + exit 1 + fi + cp -an -- "$LINUXUS_TEMPLATE_SEED/." "$HOME_DIR/" +fi + BASHRC="$HOME_DIR/.bashrc" if ! grep -q 'Welcome to the linuxus service shell' "$BASHRC" 2>/dev/null; then - cat > "$BASHRC" <> "$BASHRC" < 0 { + snapshot.Sessions[id] = count + } + } + requests := make(map[*http.Request]context.CancelFunc, len(a.sessionCancels)) + for request, cancel := range a.sessionCancels { + requests[request] = cancel + } + a.sessionMu.Unlock() + for request, cancel := range requests { + if _, ok := a.getSessionID(request); !ok { + cancel() + } + } + if a.managerBaseURL == "" { + return nil + } + data, err := json.Marshal(snapshot) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), a.sessionReportTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.managerBaseURL+"/user/session-snapshot", bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Manager-Session-Secret", a.managerSessionSecret) + resp, err := a.managerClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("session synchronization returned HTTP %d", resp.StatusCode) + } + return nil +} + +func (a *App) sessionReporter() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + if err := a.reportAllSessions(); err != nil { + log.Printf("session synchronization: %v", err) + } + select { + case <-a.done: + return + case <-ticker.C: + } + } +} diff --git a/src/internal/common/backup/archive.go b/src/internal/common/backup/archive.go new file mode 100644 index 0000000..8d74f2e --- /dev/null +++ b/src/internal/common/backup/archive.go @@ -0,0 +1,153 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/elecbug/linuxus/src/internal/common/ruleset" +) + +// The archive contains exactly disk.img and manifest.json; archive paths are +// never used as host paths. A manifest records identity, ownership and checksum. +type Manifest struct { + Version int `json:"version"` + UserID string `json:"user_id"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + UID int `json:"uid"` + GID int `json:"gid"` + Created time.Time `json:"created"` +} + +func Write(output io.Writer, image *os.File, manifest Manifest) error { + info, err := image.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() <= 1024*1024 || !ruleset.AllowedUserID(manifest.UserID) { + return fmt.Errorf("invalid disk image or user") + } + if _, err := image.Seek(0, io.SeekStart); err != nil { + return err + } + manifest.Version = 1 + manifest.Size = info.Size() + manifest.Created = time.Now().UTC() + gz := gzip.NewWriter(output) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "disk.img", Typeflag: tar.TypeReg, Mode: 0600, Size: manifest.Size}); err != nil { + return err + } + hash := sha256.New() + if n, err := io.Copy(io.MultiWriter(tw, hash), image); err != nil || n != manifest.Size { + return fmt.Errorf("copy disk image (%d bytes): %w", n, err) + } + manifest.SHA256 = hex.EncodeToString(hash.Sum(nil)) + data, err := json.Marshal(manifest) + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{Name: "manifest.json", Typeflag: tar.TypeReg, Mode: 0600, Size: int64(len(data))}); err != nil { + return err + } + if _, err := tw.Write(data); err != nil { + return err + } + if err := tw.Close(); err != nil { + return err + } + return gz.Close() +} + +// Read verifies every archive entry and the full disk checksum before returning. +// Destination must be an unpublished temporary file (nil for verification). +func Read(input io.Reader, destination *os.File, maxSize int64) (Manifest, error) { + var manifest Manifest + gz, err := gzip.NewReader(input) + if err != nil { + return manifest, err + } + defer gz.Close() + tr := tar.NewReader(gz) + header, err := tr.Next() + if err != nil { + return manifest, err + } + if header.Name != "disk.img" || header.Typeflag != tar.TypeReg || header.Size <= 1024*1024 || (maxSize > 0 && header.Size > maxSize) { + return manifest, fmt.Errorf("invalid disk image entry or insufficient restore space") + } + size := header.Size + hash := sha256.New() + var output io.Writer = hash + if destination != nil { + output = io.MultiWriter(hash, &sparseWriter{file: destination}) + } + if _, err := io.CopyBuffer(output, tr, make([]byte, 64<<10)); err != nil { + return manifest, err + } + header, err = tr.Next() + if err != nil { + return manifest, err + } + if header.Name != "manifest.json" || header.Typeflag != tar.TypeReg || header.Size > 16<<10 { + return manifest, fmt.Errorf("invalid backup manifest") + } + data, err := io.ReadAll(tr) + if err != nil { + return manifest, err + } + if err := json.Unmarshal(data, &manifest); err != nil { + return manifest, err + } + if manifest.Version != 1 || !ruleset.AllowedUserID(manifest.UserID) || manifest.Size != size || manifest.SHA256 != hex.EncodeToString(hash.Sum(nil)) || manifest.UID <= 0 || manifest.GID <= 0 { + return manifest, fmt.Errorf("backup identity, size, ownership or checksum mismatch") + } + if _, err := tr.Next(); err != io.EOF { + return manifest, fmt.Errorf("unexpected extra backup entry") + } + // Reading through gzip's EOF also validates its checksum and rejects payload + // hidden after the tar terminator, including additional gzip members. + trailing, err := io.Copy(io.Discard, gz) + if err != nil { + return manifest, err + } + if trailing != 0 { + return manifest, fmt.Errorf("trailing backup payload") + } + if destination != nil { + if err := destination.Truncate(size); err != nil { + return manifest, err + } + if err := destination.Sync(); err != nil { + return manifest, err + } + } + return manifest, nil +} + +type sparseWriter struct{ file *os.File } + +func (w *sparseWriter) Write(data []byte) (int, error) { + zero := true + for _, b := range data { + if b != 0 { + zero = false + break + } + } + if zero { + _, err := w.file.Seek(int64(len(data)), io.SeekCurrent) + if err != nil { + return 0, err + } + return len(data), nil + } + return w.file.Write(data) +} diff --git a/src/internal/common/config/config.go b/src/internal/common/config/config.go index 13a4aa6..3e4a0a2 100644 --- a/src/internal/common/config/config.go +++ b/src/internal/common/config/config.go @@ -2,8 +2,17 @@ package config // Config defines the runtime settings and .env key components consumed by linuxusctl. type Config struct { + Capacity struct { + MaxRunning int `env:"MAX_RUNNING"` + MaxPending int `env:"MAX_PENDING"` + MinFreeSpace string `env:"MIN_FREE_SPACE"` + } `env:"CAPACITY"` + // UserService configures user image and runtime settings. UserService struct { + Templates string `env:"TEMPLATES"` + Classes string `env:"CLASSES"` + // Container groups naming, runtime, and user limit settings. Container struct { // NamePrefix is prefixed to generated user container names. diff --git a/src/internal/common/config/default.env b/src/internal/common/config/default.env index 60cd08a..020f5e6 100644 --- a/src/internal/common/config/default.env +++ b/src/internal/common/config/default.env @@ -62,3 +62,11 @@ VOLUMES_HOST_READONLY='/var/lib/linuxus/volumes/readonly' VOLUMES_CONTAINER_SHARE='/home/share' VOLUMES_CONTAINER_READONLY='/home/readonly' VOLUMES_DISK_LIMIT='1G' + +# Optional host-wide admission limits; zero/empty preserves existing limits. +CAPACITY_MAX_RUNNING=0 +CAPACITY_MAX_PENDING=0 +CAPACITY_MIN_FREE_SPACE='' +# Custom images extend the generated user image and retain its ttyd entrypoint. +USER_SERVICE_TEMPLATES='{}' +USER_SERVICE_CLASSES='{}' diff --git a/src/internal/common/config/paths.go b/src/internal/common/config/paths.go index 85fdac1..f2ad1ca 100644 --- a/src/internal/common/config/paths.go +++ b/src/internal/common/config/paths.go @@ -91,3 +91,6 @@ func validateVolumePaths(cfg *Config) error { } return nil } + +// CanonicalStoragePath also resolves existing parents of not-yet-created paths. +func CanonicalStoragePath(path string) (string, error) { return resolveStoragePath(path) } diff --git a/src/internal/common/config/templates.go b/src/internal/common/config/templates.go new file mode 100644 index 0000000..306c29c --- /dev/null +++ b/src/internal/common/config/templates.go @@ -0,0 +1,72 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + "path" + "strings" + + "github.com/elecbug/linuxus/src/internal/common/convert" + "github.com/elecbug/linuxus/src/internal/common/ruleset" +) + +type Template struct { + Image string `json:"image"` + Seed string `json:"seed,omitempty"` +} + +func ParseTemplates(cfg *Config) (map[string]Template, map[string]string, error) { + templates, classes := map[string]Template{}, map[string]string{} + decode := func(value string, target any) error { + if strings.TrimSpace(value) == "" { + return nil + } + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return fmt.Errorf("expected one JSON object") + } + return nil + } + if err := decode(cfg.UserService.Templates, &templates); err != nil { + return nil, nil, fmt.Errorf("USER_SERVICE_TEMPLATES: %w", err) + } + if err := decode(cfg.UserService.Classes, &classes); err != nil { + return nil, nil, fmt.Errorf("USER_SERVICE_CLASSES: %w", err) + } + for name, template := range templates { + if name == "default" || !ruleset.AllowedUserID(name) || strings.TrimSpace(template.Image) == "" || strings.ContainsAny(template.Image, " \t\r\n\x00") { + return nil, nil, fmt.Errorf("invalid template %q", name) + } + if template.Seed != "" && (!path.IsAbs(template.Seed) || path.Clean(template.Seed) != template.Seed || template.Seed == "/" || strings.ContainsAny(template.Seed, "\r\n\x00")) { + return nil, nil, fmt.Errorf("invalid template seed path for %s", name) + } + } + for class, template := range classes { + if !ruleset.AllowedUserID(class) { + return nil, nil, fmt.Errorf("invalid class name") + } + if _, ok := templates[template]; !ok && template != "default" { + return nil, nil, fmt.Errorf("class %s selects unknown template %s", class, template) + } + } + return templates, classes, nil +} + +func validateOperations(cfg *Config) error { + if cfg.Capacity.MaxRunning < 0 || cfg.Capacity.MaxPending < 0 { + return fmt.Errorf("capacity limits must be non-negative") + } + if cfg.Capacity.MinFreeSpace != "" { + if size, err := convert.BytesFromString(cfg.Capacity.MinFreeSpace); err != nil || size < 0 { + return fmt.Errorf("CAPACITY_MIN_FREE_SPACE must be a non-negative size") + } + } + _, _, err := ParseTemplates(cfg) + return err +} diff --git a/src/internal/common/config/validate.go b/src/internal/common/config/validate.go index 903c578..878560e 100644 --- a/src/internal/common/config/validate.go +++ b/src/internal/common/config/validate.go @@ -14,6 +14,9 @@ import ( // ValidateConfig validates required config values before runtime operations. func ValidateConfig(cfg *Config) error { errMsgs := []string{} + if err := validateOperations(cfg); err != nil { + errMsgs = append(errMsgs, err.Error()) + } if cfg.UserService.Container.NamePrefix == "" { errMsgs = append(errMsgs, "USER_SERVICE_CONTAINER_NAME_PREFIX is required") @@ -87,7 +90,7 @@ func ValidateConfig(cfg *Config) error { if cfg.AuthService.Mounts.HostAuthListPath == "" { errMsgs = append(errMsgs, "AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH is required") - } else if err := UsablePath(cfg.AuthService.Mounts.HostAuthListPath); err != nil { + } else if err := usableStoragePath(cfg.AuthService.Mounts.HostAuthListPath, false); err != nil { errMsgs = append(errMsgs, fmt.Sprintf("AUTH_SERVICE_MOUNTS_HOST_AUTH_LIST_PATH (%v)", err)) } @@ -181,25 +184,25 @@ func ValidateConfig(cfg *Config) error { if cfg.Volumes.Host.Volumes == "" { errMsgs = append(errMsgs, "VOLUMES_HOST_VOLUMES is required") - } else if err := UsablePath(cfg.Volumes.Host.Volumes); err != nil { + } else if err := usableStoragePath(cfg.Volumes.Host.Volumes, true); err != nil { errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_VOLUMES (%v)", err)) } if cfg.Volumes.Host.Homes == "" { errMsgs = append(errMsgs, "VOLUMES_HOST_HOMES is required") - } else if err := UsablePath(cfg.Volumes.Host.Homes); err != nil { + } else if err := usableStoragePath(cfg.Volumes.Host.Homes, true); err != nil { errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_HOMES (%v)", err)) } if cfg.Volumes.Host.Share == "" { errMsgs = append(errMsgs, "VOLUMES_HOST_SHARE is required") - } else if err := UsablePath(cfg.Volumes.Host.Share); err != nil { + } else if err := usableStoragePath(cfg.Volumes.Host.Share, true); err != nil { errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_SHARE (%v)", err)) } if cfg.Volumes.Host.Readonly == "" { errMsgs = append(errMsgs, "VOLUMES_HOST_READONLY is required") - } else if err := UsablePath(cfg.Volumes.Host.Readonly); err != nil { + } else if err := usableStoragePath(cfg.Volumes.Host.Readonly, true); err != nil { errMsgs = append(errMsgs, fmt.Sprintf("VOLUMES_HOST_READONLY (%v)", err)) } @@ -241,6 +244,24 @@ func UsablePath(path string) error { return err } +// Missing paths are prepared by up. Existing paths must have the right type. +func usableStoragePath(path string, directory bool) error { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if directory && !info.IsDir() { + return fmt.Errorf("must be a directory") + } + if !directory && !info.Mode().IsRegular() { + return fmt.Errorf("must be a regular file") + } + return nil +} + // validateLimits checks if at least one limit value is non-zero. func validateLimits(l Limits) error { errMsgs := []string{} diff --git a/src/internal/common/config/validate_test.go b/src/internal/common/config/validate_test.go index 3020f80..ca6de18 100644 --- a/src/internal/common/config/validate_test.go +++ b/src/internal/common/config/validate_test.go @@ -88,3 +88,25 @@ func TestAuthListCannotAliasSymlinkedShareImage(t *testing.T) { t.Fatal("auth list would be deleted as a shared image") } } + +func TestStoragePathTypes(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "file") + if err := os.WriteFile(file, nil, 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + path string + directory, valid bool + }{ + {root, true, true}, {file, false, true}, + {root, false, false}, {file, true, false}, + {filepath.Join(root, "missing"), true, true}, + {filepath.Join(root, "missing"), false, true}, + {filepath.Join(file, "invalid-parent"), true, false}, + } { + if err := usableStoragePath(tc.path, tc.directory); (err == nil) != tc.valid { + t.Fatalf("path=%s directory=%t error=%v", tc.path, tc.directory, err) + } + } +} diff --git a/src/internal/common/packet/auth_to_manager.go b/src/internal/common/packet/auth_to_manager.go index a088458..f34996e 100644 --- a/src/internal/common/packet/auth_to_manager.go +++ b/src/internal/common/packet/auth_to_manager.go @@ -35,3 +35,9 @@ type SessionStateReport struct { // ObservedAt is the timestamp when the session state was observed. ObservedAt time.Time `json:"observed_at"` } + +// SessionSnapshot is authoritative for the single Auth gateway at ObservedAt. +type SessionSnapshot struct { + ObservedAt time.Time `json:"observed_at"` + Sessions map[string]int `json:"sessions"` +} diff --git a/src/internal/common/system_api/space.go b/src/internal/common/system_api/space.go new file mode 100644 index 0000000..9ed52e2 --- /dev/null +++ b/src/internal/common/system_api/space.go @@ -0,0 +1,23 @@ +package system_api + +import ( + "os" + "path/filepath" + "syscall" +) + +// DiskSpace reports the nearest existing filesystem without creating paths. +func DiskSpace(path string) (total, available uint64, err error) { + for { + var stat syscall.Statfs_t + err = syscall.Statfs(path, &stat) + if err == nil { + return stat.Blocks * uint64(stat.Bsize), stat.Bavail * uint64(stat.Bsize), nil + } + parent := filepath.Dir(path) + if !os.IsNotExist(err) || parent == path { + return 0, 0, err + } + path = parent + } +} diff --git a/src/internal/common/user/account.go b/src/internal/common/user/account.go new file mode 100644 index 0000000..eef7653 --- /dev/null +++ b/src/internal/common/user/account.go @@ -0,0 +1,133 @@ +package user + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "syscall" + + "github.com/elecbug/linuxus/src/internal/common/ruleset" + "golang.org/x/crypto/bcrypt" +) + +// Legacy bcrypt entries remain readable. Updated entries also carry account +// state and a random generation so unlocking never revives old session cookies. +type Account struct { + Hash string `json:"hash"` + Locked bool `json:"locked,omitempty"` + Generation string `json:"generation,omitempty"` + Template string `json:"template,omitempty"` + Class string `json:"class,omitempty"` +} + +const accountPrefix = "!linuxus1!" + +func ParseAccount(value string) (Account, error) { + if !strings.HasPrefix(value, accountPrefix) { + return Account{Hash: value}, nil + } + data, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, accountPrefix)) + var account Account + if err == nil { + err = json.Unmarshal(data, &account) + } + if err != nil || account.Hash == "" || account.Generation == "" { + return Account{}, fmt.Errorf("invalid account record") + } + return account, nil +} + +func IsLocked(value string) bool { + account, err := ParseAccount(value) + return err != nil || account.Locked +} + +func CheckPassword(value, password string) error { + account, err := ParseAccount(value) + if err != nil { + return err + } + err = bcrypt.CompareHashAndPassword([]byte(account.Hash), []byte(password)) + if account.Locked { + return fmt.Errorf("account locked") + } + return err +} + +// UpdateAccount preserves the auth inode and unrelated lines under the same +// cross-process lock as signup. Every successful change revokes old cookies. +func UpdateAccount(path, id, operation, value string) error { + if !ruleset.AllowedUserID(id) { + return fmt.Errorf("invalid user ID") + } + if operation == "password" { + if value == "" { + return fmt.Errorf("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost) + if err != nil { + return err + } + value = string(hash) + } + file, err := openLocked(path, os.O_RDWR, syscall.LOCK_EX) + if err != nil { + return err + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + return err + } + users, err := readUsers(bytes.NewReader(data)) + if err != nil { + return err + } + record, ok := users[id] + if !ok { + return fmt.Errorf("user %q does not exist", id) + } + account, err := ParseAccount(record) + if err != nil { + return err + } + switch operation { + case "lock": + account.Locked = true + case "unlock": + account.Locked = false + case "password": + account.Hash = value + case "disconnect": // Rotate the cookie generation only. + case "template": + account.Template = value + case "class": + account.Class = value + account.Template = "" + default: + return fmt.Errorf("unsupported account operation") + } + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return err + } + account.Generation = hex.EncodeToString(nonce[:]) + encoded, err := json.Marshal(account) + if err != nil { + return err + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + key, _, ok := strings.Cut(strings.TrimSpace(line), ":") + if ok && strings.TrimSpace(key) == id { + lines[i] = id + ":" + accountPrefix + base64.RawURLEncoding.EncodeToString(encoded) + } + } + return replaceAuthData(file, data, []byte(strings.Join(lines, "\n"))) +} diff --git a/src/internal/common/user/file_type_test.go b/src/internal/common/user/file_type_test.go new file mode 100644 index 0000000..68cac37 --- /dev/null +++ b/src/internal/common/user/file_type_test.go @@ -0,0 +1,28 @@ +package user + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestRejectNonregularAuthLists(t *testing.T) { + root := t.TempDir() + pipe := filepath.Join(root, "pipe") + if err := syscall.Mkfifo(pipe, 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(pipe, alias); err != nil { + t.Fatal(err) + } + for _, path := range []string{root, pipe, alias, "/dev/null"} { + if _, err := LoadUsers(path); err == nil { + t.Fatalf("read nonregular auth list %s", path) + } + if err := EnsureFile(path); err == nil { + t.Fatalf("initialized nonregular auth list %s", path) + } + } +} diff --git a/src/internal/common/user/user.go b/src/internal/common/user/user.go index 7cedbb1..d1a55d4 100644 --- a/src/internal/common/user/user.go +++ b/src/internal/common/user/user.go @@ -18,7 +18,7 @@ import ( // openLocked locks the auth file itself so host commands and the container's // bind mount synchronize on the same inode. Closing the file releases the lock. func openLocked(path string, flags, operation int) (*os.File, error) { - file, err := os.OpenFile(path, flags, 0600) + file, err := openRegular(path, flags) if err != nil { return nil, err } @@ -29,6 +29,24 @@ func openLocked(path string, flags, operation int) (*os.File, error) { return file, nil } +// Nonblocking open lets us reject a FIFO before waiting for a writer/reader. +// Validate the opened inode, including when the path is a symlink. +func openRegular(path string, flags int) (*os.File, error) { + file, err := os.OpenFile(path, flags|syscall.O_NONBLOCK, 0600) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err == nil && !info.Mode().IsRegular() { + err = fmt.Errorf("auth list must be a regular file") + } + if err != nil { + file.Close() + return nil, err + } + return file, nil +} + // LoadUsers reads a complete credential snapshot, waiting for active writers. func LoadUsers(path string) (map[string]string, error) { file, err := openLocked(path, os.O_RDONLY, syscall.LOCK_SH) @@ -195,7 +213,7 @@ func EnsureFile(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return fmt.Errorf("create auth directory: %w", err) } - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) + file, err := openRegular(path, os.O_CREATE|os.O_WRONLY) if err != nil { return fmt.Errorf("initialize auth list: %w", err) } diff --git a/src/internal/ctl/app/accounts.go b/src/internal/ctl/app/accounts.go new file mode 100644 index 0000000..44b4d3b --- /dev/null +++ b/src/internal/ctl/app/accounts.go @@ -0,0 +1,155 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" + "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/user" + "github.com/elecbug/linuxus/src/internal/ctl/cli" + "github.com/elecbug/linuxus/src/internal/ctl/log" +) + +func (a *App) ListUsers(output io.Writer) error { + ids := make([]string, 0, len(a.UserIDs)) + for id := range a.UserIDs { + ids = append(ids, id) + } + sort.Strings(ids) + fmt.Fprintln(output, "USER\tLOCKED\tCLASS\tTEMPLATE") + for _, id := range ids { + record, err := user.ParseAccount(a.UserIDs[id]) + if err != nil { + return err + } + fmt.Fprintf(output, "%s\t%t\t%s\t%s\n", id, record.Locked, record.Class, record.Template) + } + return nil +} + +func (a *App) ListTemplates(output io.Writer) error { + templates, classes, err := config.ParseTemplates(&a.Config) + if err != nil { + return err + } + fmt.Fprintf(output, "default\t%s\n", a.userImageName()) + names := make([]string, 0, len(templates)) + for name := range templates { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + entry := templates[name] + fmt.Fprintf(output, "%s\t%s\t%s\n", name, entry.Image, entry.Seed) + } + names = nil + for name := range classes { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintf(output, "class %s -> %s\n", name, classes[name]) + } + return nil +} + +func (a *App) ServiceAccount(command string, params *cli.Parameters) error { + id := params.Params["user"] + actions := map[string]string{"lock-user": "lock", "unlock-user": "unlock", "reset-password": "password", "disconnect-user": "disconnect", "assign-template": "template", "assign-class": "class"} + action := actions[command] + value := "" + if action == "password" { + password, err := log.InputPassword("New password for %s: ", id) + if err != nil { + return err + } + confirmation, err := log.InputPassword("Confirm password: ") + if err != nil { + return err + } + if password != confirmation { + return fmt.Errorf("passwords do not match") + } + value = password + } + if action == "template" || action == "class" { + templates, classes, err := config.ParseTemplates(&a.Config) + if err != nil { + return err + } + value = params.Params[action] + if action == "template" { + if _, ok := templates[value]; !ok && value != "default" { + return fmt.Errorf("unknown template %s", value) + } + } else if _, ok := classes[value]; !ok { + return fmt.Errorf("unknown class %s", value) + } + } + if err := user.UpdateAccount(a.Config.AuthService.Mounts.HostAuthListPath, id, action, value); err != nil { + return err + } + if action != "unlock" { + if err := a.stopUserRuntime(id); err != nil { + return fmt.Errorf("account updated; could not terminate runtime: %w", err) + } + } + log.Log(log.DETAIL_PREFIX, "%s completed for %s; home data preserved.", command, id) + return nil +} + +// Stop through Manager's preparation lock so in-flight startup cannot recreate +// a container after a maintenance operation has stopped it. +func (a *App) stopUserRuntime(id string) error { + ctx, cancel := context.WithTimeout(a.context, 50*time.Second) + defer cancel() + manager, err := a.dockerClient.ContainerInspect(ctx, a.Config.ManagerService.Container.Name) + if err == nil && manager.State != nil && manager.State.Running { + if manager.NetworkSettings == nil { + return fmt.Errorf("Manager has no network settings") + } + network := manager.NetworkSettings.Networks[a.Config.ManagerService.Container.Network] + if network == nil || network.IPAddress == "" { + return fmt.Errorf("Manager IP is unavailable") + } + data, _ := json.Marshal(map[string]string{"user_id": id, "action": "stop"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+network.IPAddress+":5959/admin/user", bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Manager-Session-Secret", a.Config.ManagerService.Security.SessionSecret) + client := &http.Client{Timeout: 50 * time.Second, Transport: &http.Transport{Proxy: nil}} + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("Manager stop returned %d: %s", resp.StatusCode, body) + } + return nil + } + if err != nil && !errdefs.IsNotFound(err) { + return err + } + name := a.Config.UserService.Container.NamePrefix + id + timeout := 10 + if err := a.dockerClient.ContainerStop(ctx, name, container.StopOptions{Timeout: &timeout}); err != nil && !errdefs.IsNotFound(err) { + return err + } + if err := a.dockerClient.ContainerRemove(ctx, name, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) { + return err + } + return nil +} diff --git a/src/internal/ctl/app/app.go b/src/internal/ctl/app/app.go index a7d726f..d49c99f 100644 --- a/src/internal/ctl/app/app.go +++ b/src/internal/ctl/app/app.go @@ -22,6 +22,7 @@ type App struct { execPath string // configFile points to the runtime configuration file. configFile string + supervised bool // Config stores the parsed application configuration. Config config.Config diff --git a/src/internal/ctl/app/backup.go b/src/internal/ctl/app/backup.go new file mode 100644 index 0000000..0087ed8 --- /dev/null +++ b/src/internal/ctl/app/backup.go @@ -0,0 +1,246 @@ +package app + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + archive "github.com/elecbug/linuxus/src/internal/common/backup" + "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/system_api" + "github.com/elecbug/linuxus/src/internal/common/user" + "github.com/elecbug/linuxus/src/internal/ctl/log" +) + +func VerifyBackup(path string, output io.Writer) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("backup must be a regular file") + } + manifest, err := archive.Read(file, nil, 0) + if err != nil { + return err + } + _, err = fmt.Fprintf(output, "Backup valid: user=%s bytes=%d uid=%d gid=%d created=%s SHA256=%s\n", manifest.UserID, manifest.Size, manifest.UID, manifest.GID, manifest.Created, manifest.SHA256) + return err +} + +func (a *App) outsideManagedStorage(path string) error { + resolved, err := config.CanonicalStoragePath(path) + if err != nil { + return err + } + for _, root := range []string{a.Config.Volumes.Host.Homes, a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly} { + base, err := config.CanonicalStoragePath(root) + if err != nil { + return err + } + rel, err := filepath.Rel(base, resolved) + if err != nil { + return err + } + if rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("backup must be outside managed user/shared disks") + } + if resolved == base+".img" { + return fmt.Errorf("backup cannot replace a managed image") + } + } + return nil +} + +// Maintenance locks the account before crossing Manager's runtime lock, then +// takes the disk lock. Failure leaves the account locked for safe manual repair. +func (a *App) maintainUser(id string, operation func() error) error { + if os.Geteuid() != 0 { + return fmt.Errorf("disk backup/restore requires root") + } + users, err := user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + if err != nil { + return err + } + record, ok := users[id] + if !ok { + return fmt.Errorf("user does not exist") + } + wasLocked := user.IsLocked(record) + if err := user.UpdateAccount(a.Config.AuthService.Mounts.HostAuthListPath, id, "lock", ""); err != nil { + return err + } + if err := a.stopUserRuntime(id); err != nil { + return fmt.Errorf("account remains locked; stop runtime: %w", err) + } + err = a.withDiskLock(func() error { + if err := a.detachUserImage(id); err != nil { + return err + } + return operation() + }) + if err != nil { + return fmt.Errorf("account %s remains locked; data retained for recovery: %w", id, err) + } + if !wasLocked { + if err := user.UpdateAccount(a.Config.AuthService.Mounts.HostAuthListPath, id, "unlock", ""); err != nil { + return err + } + } + log.Log(log.DETAIL_PREFIX, "Maintenance complete for %s. Runtime is stopped; log in again to resume.", id) + return nil +} + +func (a *App) detachUserImage(id string) error { + mount := filepath.Join(a.Config.Volumes.Host.Homes, id) + if err := a.unmountDiskTree(mount); err != nil { + return err + } + devices, err := a.findLoopDevicesForImages(mount + ".img") + if err != nil { + return err + } + for _, device := range devices { + if err := a.detachLoopDevice(device); err != nil { + return err + } + } + return nil +} + +func (a *App) BackupUser(id, output string) error { + output, err := filepath.Abs(output) + if err != nil { + return err + } + if err := a.outsideManagedStorage(output); err != nil { + return err + } + if _, err := os.Lstat(output); err == nil { + return fmt.Errorf("backup already exists") + } else if !os.IsNotExist(err) { + return err + } + temp, err := os.CreateTemp(filepath.Dir(output), ".linuxus-backup-*") + if err != nil { + return err + } + defer os.Remove(temp.Name()) + defer temp.Close() + return a.maintainUser(id, func() error { + image, err := os.Open(filepath.Join(a.Config.Volumes.Host.Homes, id+".img")) + if err != nil { + return err + } + defer image.Close() + if err := archive.Write(temp, image, archive.Manifest{UserID: id, UID: a.Config.UserService.Runtime.UID, GID: a.Config.UserService.Runtime.GID}); err != nil { + return err + } + if err := temp.Sync(); err != nil { + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Link(temp.Name(), output); err != nil { + return err + } + // Mount again even when automatic activation is disabled. + return a.createUserDisk(id, id == a.Config.ManagerService.AdminID) + }) +} + +func (a *App) RestoreUser(id, input string, replace bool) error { + if err := a.outsideManagedStorage(input); err != nil { + return err + } + imagePath := filepath.Join(a.Config.Volumes.Host.Homes, id+".img") + if _, err := os.Lstat(imagePath); err == nil && !replace { + return fmt.Errorf("existing image preserved; use --replace to restore over it") + } else if err != nil && !os.IsNotExist(err) { + return err + } + file, err := os.Open(input) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("backup must be a regular file") + } + if err := os.MkdirAll(a.Config.Volumes.Host.Homes, 0755); err != nil { + return err + } + if err := a.checkFreeSpace(); err != nil { + return err + } + _, available, err := system_api.DiskSpace(a.Config.Volumes.Host.Homes) + if err != nil { + return err + } + temp, err := os.CreateTemp(a.Config.Volumes.Host.Homes, ".restore-*") + if err != nil { + return err + } + defer os.Remove(temp.Name()) + defer temp.Close() + manifest, err := archive.Read(file, temp, int64(available)) + if err != nil { + return err + } + if manifest.UserID != id || manifest.UID != a.Config.UserService.Runtime.UID || manifest.GID != a.Config.UserService.Runtime.GID { + return fmt.Errorf("backup user or UID/GID does not match this deployment") + } + if err := temp.Close(); err != nil { + return err + } + return a.maintainUser(id, func() error { + previous := temp.Name() + ".previous" + hasPrevious := false + if _, err := os.Lstat(imagePath); err == nil { + if !replace { + return fmt.Errorf("image appeared during verification; use --replace") + } + if err := os.Rename(imagePath, previous); err != nil { + return err + } + hasPrevious = true + } else if !os.IsNotExist(err) { + return err + } + if err := os.Rename(temp.Name(), imagePath); err != nil { + if hasPrevious { + err = errors.Join(err, os.Rename(previous, imagePath)) + } + return err + } + if err := a.createUserDisk(id, id == a.Config.ManagerService.AdminID); err != nil { + if cleanupErr := a.detachUserImage(id); cleanupErr != nil { + return errors.Join(err, fmt.Errorf("retain previous image at %s: %w", previous, cleanupErr)) + } + if hasPrevious { + if restoreErr := os.Rename(previous, imagePath); restoreErr != nil { + return errors.Join(err, fmt.Errorf("previous image retained at %s: %w", previous, restoreErr)) + } + return errors.Join(err, a.createUserDisk(id, id == a.Config.ManagerService.AdminID)) + } + return err + } + if hasPrevious { + return os.Remove(previous) + } + return nil + }) +} diff --git a/src/internal/ctl/app/config.go b/src/internal/ctl/app/config.go index cba2320..af5e74d 100644 --- a/src/internal/ctl/app/config.go +++ b/src/internal/ctl/app/config.go @@ -9,8 +9,8 @@ import ( "github.com/elecbug/linuxus/src/internal/common/user" ) -// LoadConfig reads and parses the .env configuration file into App.Config. -func (a *App) LoadConfig() error { +// loadSettings reads settings independently of Docker and the account database. +func (a *App) loadSettings() error { // Resolve config symlinks before interpreting relative storage paths so // both a symlink and its target select the same persistent state. configFile, err := filepath.EvalSymlinks(a.configFile) @@ -24,6 +24,13 @@ func (a *App) LoadConfig() error { if err != nil { return err } + info, err := os.Stat(a.configFile) + if err != nil { + return fmt.Errorf("stat config %s: %w", a.configFile, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("config %s must be a regular file", a.configFile) + } data, err := os.ReadFile(a.configFile) if err != nil { if os.IsNotExist(err) { @@ -38,8 +45,15 @@ func (a *App) LoadConfig() error { a.Config = parsed a.normalizeConfigPaths() + return nil +} - a.UserIDs, err = user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) +// LoadConfig also loads credentials for commands that operate on accounts. +func (a *App) LoadConfig() error { + if err := a.loadSettings(); err != nil { + return err + } + users, err := user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) if os.IsNotExist(err) { a.UserIDs = make(map[string]string) return nil @@ -48,6 +62,7 @@ func (a *App) LoadConfig() error { return fmt.Errorf("failed to load user IDs from auth list: %w", err) } + a.UserIDs = users return nil } diff --git a/src/internal/ctl/app/diagnostics.go b/src/internal/ctl/app/diagnostics.go new file mode 100644 index 0000000..de71001 --- /dev/null +++ b/src/internal/ctl/app/diagnostics.go @@ -0,0 +1,185 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "time" + + "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/diskservice" +) + +func checkedSettings(configFile string) (*App, error) { + a := &App{configFile: configFile} + if err := a.loadSettings(); err != nil { + return nil, err + } + if err := config.ValidateConfig(&a.Config); err != nil { + return nil, err + } + return a, nil +} + +// CheckConfig validates settings without a Docker client or credential reads. +// Only paths are printed; session secrets and account hashes stay private. +func CheckConfig(configFile string, output io.Writer) error { + a, err := checkedSettings(configFile) + if err != nil { + return err + } + _, err = fmt.Fprintf(output, "Configuration valid: %s\nAuth list: %s\nVolumes: %s\nHomes: %s\nShare: %s\nReadonly: %s\nAuto-ensure: %t\n", + a.configFile, a.Config.AuthService.Mounts.HostAuthListPath, + a.Config.Volumes.Host.Volumes, a.Config.Volumes.Host.Homes, + a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly, a.Config.Volumes.AutoEnsure) + return err +} + +type doctorChecks struct { + goos string + euid int + lookPath func(string) (string, error) + docker func(context.Context) error + disk func(context.Context, string) error +} + +// Doctor reports independent prerequisite failures without changing host state. +func Doctor(configFile string, output io.Writer) error { + return runDoctor(configFile, output, doctorChecks{ + goos: runtime.GOOS, euid: os.Geteuid(), lookPath: exec.LookPath, + docker: probeDocker, disk: probeDiskService, + }) +} + +func runDoctor(configFile string, output io.Writer, checks doctorChecks) error { + failures, warnings := 0, 0 + var writeErr error + report := func(status, name, detail string) { + switch status { + case "FAIL": + failures++ + case "WARN": + warnings++ + } + if writeErr == nil { + _, writeErr = fmt.Fprintf(output, "[%s] %s: %s\n", status, name, detail) + } + } + a, err := checkedSettings(configFile) + if err != nil { + report("FAIL", "configuration", err.Error()) + } else { + report("OK", "configuration", a.configFile) + info, err := os.Stat(a.configFile) + if err != nil { + report("FAIL", "configuration permissions", err.Error()) + } else if info.Mode().Perm()&0077 != 0 { + report("WARN", "configuration permissions", "other users can access settings; use chmod 600 on the configuration file") + } + paths := []struct { + name, path string + }{ + {"auth list", a.Config.AuthService.Mounts.HostAuthListPath}, + {"volumes", a.Config.Volumes.Host.Volumes}, + {"homes", a.Config.Volumes.Host.Homes}, + {"share", a.Config.Volumes.Host.Share}, + {"readonly", a.Config.Volumes.Host.Readonly}, + } + for _, entry := range paths { + info, err := os.Stat(entry.path) + switch { + case os.IsNotExist(err): + report("WARN", entry.name, entry.path+" does not exist yet; up prepares missing storage") + case err != nil: + report("FAIL", entry.name, err.Error()) + case entry.name == "auth list" && info.Mode().Perm()&0077 != 0: + report("WARN", entry.name, entry.path+" is accessible to other users; use chmod 600") + default: + report("OK", entry.name, entry.path) + } + } + } + if checks.goos != "linux" { + report("FAIL", "host OS", "disk operations require Linux") + } else { + report("OK", "host OS", "Linux") + } + if checks.euid != 0 { + report("WARN", "privileges", "disk operations require root; run up and ensure-disk with sudo") + } else { + report("OK", "privileges", "root (mount capability is required separately in restricted environments)") + } + for _, name := range []string{"mkfs.ext4", "losetup"} { + path, err := checks.lookPath(name) + if err != nil { + report("FAIL", name, "executable not found in PATH") + } else { + report("OK", name, path) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = checks.docker(ctx) + cancel() + if err != nil { + report("FAIL", "Docker", err.Error()) + } else { + report("OK", "Docker", "daemon reachable") + } + if a != nil { + if a.Config.Volumes.AutoEnsure { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + err := checks.disk(ctx, a.diskSocket()) + cancel() + if err != nil { + report("FAIL", "auto-ensure", fmt.Sprintf("%v; run sudo linuxusctl up and inspect %s/service.log", err, a.diskServiceDir())) + } else { + report("OK", "auto-ensure", "host disk service reachable") + } + } else { + report("OK", "auto-ensure", "disabled; new users require ensure-disk") + } + } + if writeErr != nil { + return writeErr + } + if _, err := fmt.Fprintf(output, "Checks complete: %d failure(s), %d warning(s).\n", failures, warnings); err != nil { + return err + } + if failures > 0 { + return fmt.Errorf("doctor found %d failed check(s)", failures) + } + return nil +} + +func probeDocker(ctx context.Context) error { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return err + } + defer cli.Close() + _, err = cli.Ping(ctx) + return err +} + +func probeDiskService(ctx context.Context, socket string) error { + cli := diskservice.NewClient(socket, 2*time.Second) + defer cli.CloseIdleConnections() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://disk-service/healthz", nil) + if err != nil { + return err + } + resp, err := cli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("health check returned HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/src/internal/ctl/app/diagnostics_test.go b/src/internal/ctl/app/diagnostics_test.go new file mode 100644 index 0000000..04c0832 --- /dev/null +++ b/src/internal/ctl/app/diagnostics_test.go @@ -0,0 +1,150 @@ +package app + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/elecbug/linuxus/src/internal/common/config" +) + +func diagnosticConfig(t *testing.T, autoEnsure bool) string { + t.Helper() + root := t.TempDir() + text := strings.ReplaceAll(config.DefaultEnv, "/var/lib/linuxus/", "./state/") + lines := strings.Split(text, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "VOLUMES_AUTO_ENSURE=") { + lines[i] = "VOLUMES_AUTO_ENSURE=false" + if autoEnsure { + lines[i] = "VOLUMES_AUTO_ENSURE=true" + } + } + } + path := filepath.Join(root, ".env") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestConfigCheckIgnoresDockerAndAccountContents(t *testing.T) { + path := diagnosticConfig(t, false) + auth := filepath.Join(filepath.Dir(path), "state", "data", "AUTH_LIST") + if err := os.MkdirAll(filepath.Dir(auth), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(auth, []byte("invalid private account contents"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("DOCKER_HOST", "invalid://no-docker") + t.Chdir(t.TempDir()) + var output bytes.Buffer + if err := CheckConfig(path, &output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), auth) || strings.Contains(output.String(), "private account") { + t.Fatal("configuration check did not print resolved paths safely") + } + a, err := checkedSettings(path) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{a.Config.AuthService.Security.SessionSecret, a.Config.ManagerService.Security.SessionSecret} { + if strings.Contains(output.String(), secret) { + t.Fatal("configuration check printed a secret") + } + } + if _, err := os.Stat(a.Config.Volumes.Host.Volumes); !os.IsNotExist(err) { + t.Fatal("configuration check created storage") + } +} + +func TestDoctorAggregatesFailuresAndDoesNotCreateStorage(t *testing.T) { + path := diagnosticConfig(t, true) + var output bytes.Buffer + var probes []string + err := runDoctor(path, &output, doctorChecks{ + goos: "linux", euid: 1000, + lookPath: func(name string) (string, error) { + probes = append(probes, name) + return "", errors.New("missing") + }, + docker: func(ctx context.Context) error { + probes = append(probes, "Docker") + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 5*time.Second { + t.Fatal("Docker probe has no bounded deadline") + } + return errors.New("daemon unavailable") + }, + disk: func(ctx context.Context, socket string) error { + probes = append(probes, "disk") + if !strings.HasPrefix(socket, filepath.Dir(path)) { + t.Fatal("disk service probe ignored selected configuration") + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 2*time.Second { + t.Fatal("disk probe has no bounded deadline") + } + return errors.New("not started") + }, + }) + if err == nil || !strings.Contains(err.Error(), "4 failed") || len(probes) != 4 { + t.Fatalf("not all checks ran: %v, %v", probes, err) + } + if !strings.Contains(output.String(), "[WARN] privileges") || !strings.Contains(output.String(), "service.log") { + t.Fatalf("missing repair hints: %s", output.String()) + } + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil || len(entries) != 1 || entries[0].Name() != ".env" { + t.Fatal("doctor changed disk state") + } +} + +func TestDoctorStillChecksHostWhenConfigIsInvalid(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + var output bytes.Buffer + calls := 0 + err := runDoctor(path, &output, doctorChecks{ + goos: "linux", euid: 0, + lookPath: func(name string) (string, error) { calls++; return "/usr/bin/" + name, nil }, + docker: func(context.Context) error { calls++; return nil }, + disk: func(context.Context, string) error { t.Fatal("disk probe used invalid config"); return nil }, + }) + if err == nil || calls != 3 || !strings.Contains(output.String(), "[OK] Docker") { + t.Fatalf("independent checks were skipped: calls=%d error=%v output=%s", calls, err, output.String()) + } +} + +func TestDoctorWarningsDoNotFailAndDisabledDiskServiceIsSkipped(t *testing.T) { + var output bytes.Buffer + err := runDoctor(diagnosticConfig(t, false), &output, doctorChecks{ + goos: "linux", euid: 1000, + lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil }, + docker: func(context.Context) error { return nil }, + disk: func(context.Context, string) error { t.Fatal("disabled disk service probed"); return nil }, + }) + if err != nil || !strings.Contains(output.String(), "0 failure(s)") || !strings.Contains(output.String(), "[WARN]") { + t.Fatalf("warnings treated as errors: %v; %s", err, output.String()) + } +} + +func TestConfigCheckRejectsNonregularConfiguration(t *testing.T) { + root := t.TempDir() + pipe := filepath.Join(root, "config.pipe") + if err := syscall.Mkfifo(pipe, 0600); err != nil { + t.Fatal(err) + } + for _, path := range []string{root, pipe} { + if err := CheckConfig(path, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("did not reject nonregular configuration: %v", err) + } + } +} diff --git a/src/internal/ctl/app/disk.go b/src/internal/ctl/app/disk.go index 2831f43..a4b6030 100644 --- a/src/internal/ctl/app/disk.go +++ b/src/internal/ctl/app/disk.go @@ -344,6 +344,9 @@ func (a *App) createUserDisk(userID string, isAdmin bool) (retErr error) { // createFormattedImage removes only the new image when formatting fails, so a // retry can initialize it again. Existing images never pass through this path. func (a *App) createFormattedImage(img string, size int64) error { + if err := a.checkFreeSpace(); err != nil { + return err + } if err := a.systemAPI.CreateEmptyFile(img, size); err != nil { return err } diff --git a/src/internal/ctl/app/disk_diagnostics.go b/src/internal/ctl/app/disk_diagnostics.go new file mode 100644 index 0000000..42d44ba --- /dev/null +++ b/src/internal/ctl/app/disk_diagnostics.go @@ -0,0 +1,70 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + "github.com/elecbug/linuxus/src/internal/common/convert" + "github.com/elecbug/linuxus/src/internal/common/system_api" + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func (a *App) checkFreeSpace() error { + if a.Config.Capacity.MinFreeSpace == "" { + return nil + } + minimum, err := convert.BytesFromString(a.Config.Capacity.MinFreeSpace) + if err != nil { + return err + } + for _, path := range []string{a.Config.Volumes.Host.Homes, filepath.Dir(a.Config.Volumes.Host.Share), filepath.Dir(a.Config.Volumes.Host.Readonly)} { + _, available, err := system_api.DiskSpace(path) + if err != nil { + return err + } + if available < uint64(minimum) { + return fmt.Errorf("insufficient host free space at %s: %d bytes available, %d required", path, available, minimum) + } + } + return nil +} + +func (a *App) registerDiskDiagnostics(mux *http.ServeMux) { + mux.HandleFunc("GET /capacity", func(w http.ResponseWriter, r *http.Request) { + if err := a.checkFreeSpace(); err != nil { + http.Error(w, err.Error(), 503) + return + } + w.WriteHeader(200) + }) + mux.HandleFunc("GET /usage", func(w http.ResponseWriter, r *http.Request) { + users, err := user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + if err != nil { + http.Error(w, "cannot load accounts", 500) + return + } + rows := make(map[string]any) + for id := range users { + path := filepath.Join(a.Config.Volumes.Host.Homes, id) + mounted, err := a.systemAPI.IsMountPoint(path) + if err != nil { + rows[id] = map[string]any{"error": err.Error()} + continue + } + row := map[string]any{"mounted": mounted} + if mounted { + total, free, err := system_api.DiskSpace(path) + if err == nil { + row["total_bytes"] = total + row["available_bytes"] = free + row["used_bytes"] = total - free + } + } + rows[id] = row + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(rows) + }) +} diff --git a/src/internal/ctl/app/disk_service.go b/src/internal/ctl/app/disk_service.go index 8854082..49e0a24 100644 --- a/src/internal/ctl/app/disk_service.go +++ b/src/internal/ctl/app/disk_service.go @@ -167,9 +167,6 @@ func (a *App) stopDiskService() error { // ServeDisks is the private host mode launched by up when auto-ensure is true. // It exposes no TCP listener, command execution, path selection or deletion API. func (a *App) ServeDisks() error { - if !a.Config.Volumes.AutoEnsure { - return fmt.Errorf("VOLUMES_AUTO_ENSURE is disabled") - } if os.Geteuid() != 0 { return fmt.Errorf("host disk service requires root for mounting disks") } @@ -243,6 +240,7 @@ func (a *App) serveDisks(ctx context.Context) error { func (a *App) diskServiceHandler(shutdown func()) http.Handler { mux := http.NewServeMux() + a.registerDiskDiagnostics(mux) var mu sync.Mutex mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("POST /shutdown", func(w http.ResponseWriter, r *http.Request) { @@ -267,7 +265,7 @@ func (a *App) diskServiceHandler(shutdown func()) http.Handler { if err != nil { return fmt.Errorf("cannot load registered users") } - if !user.ExistsUser(users, request.UserID) { + if !user.ExistsUser(users, request.UserID) || user.IsLocked(users[request.UserID]) { status = http.StatusForbidden return fmt.Errorf("user is not registered") } diff --git a/src/internal/ctl/app/docker_inspect.go b/src/internal/ctl/app/docker_inspect.go index daf8dbc..56f9563 100644 --- a/src/internal/ctl/app/docker_inspect.go +++ b/src/internal/ctl/app/docker_inspect.go @@ -54,7 +54,10 @@ func (a *App) showContainerInfos() error { status = convert.ContainerInspectToStatusText(info) } - image := info.Config.Image + image := "-" + if info.Config != nil { + image = info.Config.Image + } ports := convert.ContainerInspectToPortSummary(info) containerInfos = append(containerInfos, spec.ContainerInfo{ @@ -102,7 +105,7 @@ func (a *App) showNetworkInfos() error { info := spec.NetworkInfo{ Name: net.Name, ID: convert.ShortenNetworkID(net.ID), - Subnet: net.IPAM.Config[0].Subnet, + Subnet: networkSubnets(net.IPAM.Config), } networkInfos = append(networkInfos, info) } @@ -116,3 +119,16 @@ func (a *App) showNetworkInfos() error { return nil } + +func networkSubnets(ranges []network.IPAMConfig) string { + var subnets []string + for _, allocation := range ranges { + if allocation.Subnet != "" { + subnets = append(subnets, allocation.Subnet) + } + } + if len(subnets) == 0 { + return "-" + } + return strings.Join(subnets, ", ") +} diff --git a/src/internal/ctl/app/docker_inspect_test.go b/src/internal/ctl/app/docker_inspect_test.go new file mode 100644 index 0000000..5fe2948 --- /dev/null +++ b/src/internal/ctl/app/docker_inspect_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" +) + +func TestNetworkSubnetsHandlesEmptyAndDualStackRanges(t *testing.T) { + for _, tc := range []struct { + ranges []network.IPAMConfig + want string + }{ + {nil, "-"}, + {[]network.IPAMConfig{{}}, "-"}, + {[]network.IPAMConfig{{Subnet: "172.20.0.0/24"}, {}, {Subnet: "fd00::/64"}}, "172.20.0.0/24, fd00::/64"}, + } { + if got := networkSubnets(tc.ranges); got != tc.want { + t.Fatalf("subnets=%q, want %q", got, tc.want) + } + } +} + +func TestStatusHandlesPartialDockerInspection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch path := strings.TrimPrefix(r.URL.Path, "/v1.47"); path { + case "/containers/json": + w.Write([]byte(`[]`)) + case "/containers/auth/json", "/containers/manager/json": + w.Write([]byte(`{"Id":"test","Config":null,"State":null}`)) + case "/networks": + w.Write([]byte(`[{"Name":"manager-net","Id":"abc","IPAM":{"Config":[]}}]`)) + default: + t.Errorf("unexpected API: %s", path) + w.WriteHeader(500) + } + })) + defer server.Close() + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(server.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(server.Client())) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + a := &App{dockerClient: cli, context: context.Background()} + a.Config.AuthService.Container.Name = "auth" + a.Config.ManagerService.Container.Name = "manager" + a.Config.ManagerService.Container.Network = "manager-net" + a.Config.UserService.Container.NamePrefix = "user-" + a.Config.UserService.Container.NetworkNamePrefix = "user-net-" + if err := a.showContainerInfos(); err != nil { + t.Fatal(err) + } + if err := a.showNetworkInfos(); err != nil { + t.Fatal(err) + } +} diff --git a/src/internal/ctl/app/service.go b/src/internal/ctl/app/service.go index 5b043e0..8c8f571 100644 --- a/src/internal/ctl/app/service.go +++ b/src/internal/ctl/app/service.go @@ -18,7 +18,14 @@ func (a *App) ServiceUp(params *cli.Parameters) error { log.Log(log.RUN_PREFIX, "Starting runtime-managed containers...") - if err := a.buildRuntimeImages(); err != nil { + if err := a.checkSupervisor(); err != nil { + return err + } + build := a.buildRuntimeImages + if a.supervised { + build = a.ensureCachedImages + } + if err := build(); err != nil { return err } if err := user.EnsureFile(a.Config.AuthService.Mounts.HostAuthListPath); err != nil { @@ -27,11 +34,7 @@ func (a *App) ServiceUp(params *cli.Parameters) error { if err := a.ensureDiskAll(); err != nil { return err } - if a.Config.Volumes.AutoEnsure { - if err := a.startDiskService(); err != nil { - return err - } - } else if err := a.stopDiskService(); err != nil { + if err := a.startDiskService(); err != nil { return err } if err := a.ensureRuntimeNetworks(); err != nil { @@ -50,6 +53,9 @@ func (a *App) ServiceUp(params *cli.Parameters) error { // ServiceDown stops and removes all runtime-managed services. func (a *App) ServiceDown(params *cli.Parameters) error { + if err := a.checkSupervisor(); err != nil { + return err + } if params != nil && (len(params.Params) > 0 || params.MainParam != "") { return fmt.Errorf("service down option does not accept any parameters, please remove any provided parameters") } diff --git a/src/internal/ctl/app/spec.go b/src/internal/ctl/app/spec.go index 42aaca0..50477f8 100644 --- a/src/internal/ctl/app/spec.go +++ b/src/internal/ctl/app/spec.go @@ -28,7 +28,7 @@ func (a *App) buildAuthRuntimeSpec() (spec.RuntimeContainerSpec, error) { Ports: []string{ fmt.Sprintf("%d:8080", a.Config.AuthService.Container.ExternalPort), }, - Restart: "unless-stopped", + Restart: a.serviceRestartPolicy(), Networks: []string{ a.Config.ManagerService.Container.Network, }, @@ -42,9 +42,10 @@ func (a *App) buildManagerRuntimeSpec() (spec.RuntimeContainerSpec, error) { if err != nil { return spec.RuntimeContainerSpec{}, fmt.Errorf("failed to marshal config to JSON: %w", err) } - volumes := []string{"/var/run/docker.sock:/var/run/docker.sock:rw"} - if a.Config.Volumes.AutoEnsure { - volumes = append(volumes, a.diskServiceDir()+":"+diskservice.ContainerDir+":ro") + volumes := []string{ + "/var/run/docker.sock:/var/run/docker.sock:rw", + a.diskServiceDir() + ":" + diskservice.ContainerDir + ":ro", + a.Config.AuthService.Mounts.HostAuthListPath + ":/run/linuxus-accounts:ro", } return spec.RuntimeContainerSpec{ @@ -56,7 +57,7 @@ func (a *App) buildManagerRuntimeSpec() (spec.RuntimeContainerSpec, error) { "DISK_SERVICE_SOCKET=" + diskservice.ContainerSocket, }, Volumes: volumes, - Restart: "unless-stopped", + Restart: a.serviceRestartPolicy(), Networks: []string{ a.Config.ManagerService.Container.Network, }, diff --git a/src/internal/ctl/app/supervisor.go b/src/internal/ctl/app/supervisor.go new file mode 100644 index 0000000..fd72eec --- /dev/null +++ b/src/internal/ctl/app/supervisor.go @@ -0,0 +1,174 @@ +package app + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/ctl/log" +) + +func systemdQuote(value string) (string, error) { + if strings.ContainsAny(value, "\x00\r\n") { + return "", fmt.Errorf("systemd paths must not contain line breaks") + } + value = strings.NewReplacer("\\", "\\\\", "\"", "\\\"", "%", "%%", "$", "$$").Replace(value) + return "\"" + value + "\"", nil +} + +// WriteSystemdUnit generates a reviewable unit; it does not install or enable it. +func WriteSystemdUnit(executable, configFile string, output io.Writer) error { + if !filepath.IsAbs(executable) || !filepath.IsAbs(configFile) { + return fmt.Errorf("systemd paths must be absolute") + } + binary, err := systemdQuote(executable) + if err != nil { + return err + } + // Environment= does not expand dollars, unlike ExecStart=. + setting, err := systemdQuote("LINUXUS_CONFIG=" + configFile) + if err != nil { + return err + } + setting = strings.ReplaceAll(setting, "$$", "$") + _, err = fmt.Fprintf(output, `[Unit] +Description=Linuxus runtime supervisor +Requires=docker.service +After=docker.service local-fs.target network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +User=root +UMask=0077 +Environment=%s +ExecStart=%s supervise +Restart=on-failure +RestartSec=5s +TimeoutStopSec=120s +KillMode=control-group + +[Install] +WantedBy=multi-user.target +`, setting, binary) + return err +} + +func (a *App) supervisorLock() string { return filepath.Join(a.diskServiceDir(), "supervisor.lock") } + +func (a *App) checkSupervisor() error { + if a.supervised { + return nil + } + file, err := os.OpenFile(a.supervisorLock(), os.O_RDWR, 0600) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer file.Close() + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fmt.Errorf("a supervisor is active; use systemctl stop/restart linuxus instead") + } + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +} + +func (a *App) ensureCachedImages() error { + for _, name := range []string{a.authImageName(), a.managerImageName(), a.userImageName()} { + if _, err := a.dockerClient.ImageInspect(a.context, name, client.ImageInspectWithRawResponse(nil)); err != nil { + if !errdefs.IsNotFound(err) { + return err + } + return a.buildRuntimeImages() + } + } + return nil +} + +// Supervise mounts disks before starting services, then recovers failed host +// and container processes. systemd owns this process and all of its children. +func (a *App) Supervise() error { + if os.Geteuid() != 0 { + return fmt.Errorf("supervise requires root") + } + if err := os.MkdirAll(a.diskServiceDir(), 0700); err != nil { + return err + } + file, err := os.OpenFile(a.supervisorLock(), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer file.Close() + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fmt.Errorf("another supervisor is active: %w", err) + } + defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + a.supervised = true + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + a.context = ctx + defer func() { + shutdown, cancel := context.WithTimeout(context.Background(), 100*time.Second) + defer cancel() + a.context = shutdown + if err := a.ServiceDown(nil); err != nil { + log.Log(log.ERROR_PREFIX, "supervisor shutdown: %v", err) + } + }() + if err := a.ServiceUp(nil); err != nil { + return err + } + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + probe, cancel := context.WithTimeout(ctx, 2*time.Second) + err := probeDiskService(probe, a.diskSocket()) + cancel() + if err != nil { + if err := a.startDiskService(); err != nil { + return fmt.Errorf("recover disk service: %w", err) + } + } + for _, name := range []string{a.Config.ManagerService.Container.Name, a.Config.AuthService.Container.Name} { + probe, cancel := context.WithTimeout(ctx, 5*time.Second) + info, err := a.dockerClient.ContainerInspect(probe, name) + cancel() + if err != nil && !errdefs.IsNotFound(err) { + return err + } + if err == nil && info.State != nil && info.State.Running { + continue + } + if name == a.Config.ManagerService.Container.Name { + err = a.ensureManagerContainer() + } else { + err = a.ensureAuthContainer() + } + if err != nil { + return fmt.Errorf("recover %s: %w", name, err) + } + } + } + } +} + +func (a *App) serviceRestartPolicy() string { + if a.supervised { + return "no" + } + return "unless-stopped" +} diff --git a/src/internal/ctl/cli/cli.go b/src/internal/ctl/cli/cli.go index 1f8efd6..dfc3c19 100644 --- a/src/internal/ctl/cli/cli.go +++ b/src/internal/ctl/cli/cli.go @@ -46,23 +46,32 @@ func ParseParams(params []string) (*Parameters, error) { } key, value, hasValue := strings.Cut(param, "=") switch key { + case "--replace": + key = "replace" + if hasValue { + return nil, fmt.Errorf("--replace does not accept a value") + } + value = TRUE_STR case "--all", "-a": key = "all" if hasValue { return nil, fmt.Errorf("--all does not accept a value") } value = TRUE_STR - case "--user", "-u": - key = "user" + case "--user", "-u", "--file", "--output", "--template", "--class": + key = strings.TrimPrefix(key, "--") + if key == "-u" { + key = "user" + } if !hasValue { if i+1 == len(params) || strings.HasPrefix(params[i+1], "-") { - return nil, fmt.Errorf("--user requires a username") + return nil, fmt.Errorf("--%s requires a value", key) } i++ value = params[i] } if value == "" { - return nil, fmt.Errorf("--user requires a username") + return nil, fmt.Errorf("--%s requires a value", key) } default: return nil, fmt.Errorf("unknown option %q", key) diff --git a/src/internal/manager/config/config.go b/src/internal/manager/config/config.go index 89055bb..31507f7 100644 --- a/src/internal/manager/config/config.go +++ b/src/internal/manager/config/config.go @@ -1,6 +1,9 @@ package config -import "time" +import ( + commonconfig "github.com/elecbug/linuxus/src/internal/common/config" + "time" +) // ResourceLimits defines Docker resource constraints for a runtime container. type ResourceLimits struct { @@ -18,6 +21,13 @@ type ResourceLimits struct { // Config contains all runtime settings for the manager service. type Config struct { + AuthListFile string + MaxRunning int + MaxPending int + MinFreeBytes int64 + Templates map[string]commonconfig.Template + Classes map[string]string + AutoEnsure bool DiskServiceSocket string // ListenAddr is the HTTP server bind address. diff --git a/src/internal/manager/handler/handle_session.go b/src/internal/manager/handler/handle_session.go index b9851e7..2e43ea5 100644 --- a/src/internal/manager/handler/handle_session.go +++ b/src/internal/manager/handler/handle_session.go @@ -31,7 +31,7 @@ func (s *Server) HandleUserSessionState(w http.ResponseWriter, r *http.Request) } var req packet.SessionStateReport - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { http.Error(w, "invalid json body", http.StatusBadRequest) return } @@ -60,6 +60,9 @@ func (s *Server) updateSessionState(userID string, active int, observedAt time.T s.mu.Lock() defer s.mu.Unlock() + if s.runtimes == nil { + s.runtimes = make(map[string]*RuntimeState) + } rt, ok := s.runtimes[userID] if !ok { rt = &RuntimeState{ diff --git a/src/internal/manager/handler/handle_up.go b/src/internal/manager/handler/handle_up.go index c0f6a99..e6e2769 100644 --- a/src/internal/manager/handler/handle_up.go +++ b/src/internal/manager/handler/handle_up.go @@ -91,6 +91,13 @@ func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (res return nil, fmt.Errorf("user_id contains invalid characters") } + if s.cfg != nil && s.cfg.MaxPending > 0 { + if s.pending.Add(1) > int32(s.cfg.MaxPending) { + s.pending.Add(-1) + return nil, fmt.Errorf("preparation queue is full; retry shortly") + } + defer s.pending.Add(-1) + } if err := s.prepareMu.LockContext(ctx); err != nil { return nil, err } @@ -102,8 +109,12 @@ func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (res }() containerName := s.cfg.UserContainerNamePrefix + userID - if _, err := s.docker.ImageInspect(ctx, s.cfg.UserImage, client.ImageInspectWithRawResponse(nil)); err != nil { - return nil, fmt.Errorf("inspect user image %s: %w", s.cfg.UserImage, err) + _, template, err := s.userTemplate(userID) + if err != nil { + return nil, err + } + if _, err := s.docker.ImageInspect(ctx, template.Image, client.ImageInspectWithRawResponse(nil)); err != nil { + return nil, fmt.Errorf("inspect user image %s: %w", template.Image, err) } exists, running, err := s.inspectContainerState(ctx, containerName) @@ -111,6 +122,11 @@ func (s *Server) ensureUserRuntimeReady(ctx context.Context, userID string) (res return nil, err } + if !running { + if err := s.checkAdmission(ctx); err != nil { + return nil, err + } + } if s.cfg.AutoEnsure && !running { if err := diskservice.Ensure(ctx, s.diskClient, userID); err != nil { return nil, fmt.Errorf("disk preparation failed: %w", err) @@ -268,13 +284,19 @@ func (s *Server) createUserContainer(ctx context.Context, containerName, userID, return "", fmt.Errorf("invalid user_id: path traversal detected") } + templateName, template, err := s.userTemplate(userID) + if err != nil { + return "", err + } cfg := &container.Config{ - Image: s.cfg.UserImage, + Image: template.Image, Hostname: s.cfg.ContainerHostname, User: s.cfg.RuntimeUser, WorkingDir: s.cfg.WorkingDir, Env: []string{ "TZ=" + s.cfg.Timezone, + "LINUXUS_TEMPLATE=" + templateName, + "LINUXUS_TEMPLATE_SEED=" + template.Seed, "CONTAINER_RUNTIME_USER=" + s.cfg.ContainerRuntimeUser, "USER_ID=" + userID, "SHARED_DIR=" + s.cfg.ContainerShareDir, diff --git a/src/internal/manager/handler/operations.go b/src/internal/manager/handler/operations.go new file mode 100644 index 0000000..1fd4e58 --- /dev/null +++ b/src/internal/manager/handler/operations.go @@ -0,0 +1,207 @@ +package handler + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + commonconfig "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/http_helper" + "github.com/elecbug/linuxus/src/internal/common/ruleset" + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func (s *Server) account(id string) (user.Account, error) { + if s.cfg.AuthListFile == "" { + return user.Account{}, nil + } // Unit/in-process configurations. + users, err := user.LoadUsers(s.cfg.AuthListFile) + if err != nil { + return user.Account{}, fmt.Errorf("cannot read account database: %w", err) + } + value, ok := users[id] + if !ok { + return user.Account{}, fmt.Errorf("account does not exist") + } + account, err := user.ParseAccount(value) + if err != nil || account.Locked { + return user.Account{}, fmt.Errorf("account is locked or invalid") + } + return account, nil +} + +func (s *Server) userTemplate(id string) (string, commonconfig.Template, error) { + account, err := s.account(id) + if err != nil { + return "", commonconfig.Template{}, err + } + name := account.Template + if name == "" && account.Class != "" { + var ok bool + name, ok = s.cfg.Classes[account.Class] + if !ok { + return "", commonconfig.Template{}, fmt.Errorf("unknown class %s", account.Class) + } + } + if name == "" || name == "default" { + return "default", commonconfig.Template{Image: s.cfg.UserImage}, nil + } + template, ok := s.cfg.Templates[name] + if !ok { + return "", commonconfig.Template{}, fmt.Errorf("unknown environment template %s", name) + } + return name, template, nil +} + +func (s *Server) checkAdmission(ctx context.Context) error { + if s.cfg.MaxRunning > 0 { + containers, err := s.docker.ContainerList(ctx, container.ListOptions{}) + if err != nil { + return err + } + running := 0 + for _, info := range containers { + for _, name := range info.Names { + if strings.HasPrefix(strings.TrimPrefix(name, "/"), s.cfg.UserContainerNamePrefix) { + running++ + break + } + } + } + if running >= s.cfg.MaxRunning { + return fmt.Errorf("maximum running environments reached (%d); disconnect an idle environment or increase CAPACITY_MAX_RUNNING", s.cfg.MaxRunning) + } + } + if s.cfg.MinFreeBytes > 0 { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://disk-service/capacity", nil) + resp, err := s.diskClient.Do(req) + if err != nil { + return fmt.Errorf("check host storage: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("host storage reserve is unavailable; inspect doctor and CAPACITY_MIN_FREE_SPACE") + } + } + return nil +} + +func (s *Server) authorizeAdmin(w http.ResponseWriter, r *http.Request, method string) bool { + if r.Method != method { + http.Error(w, "method not allowed", 405) + return false + } + if s.cfg.ManagerSessionSecret == "" || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Manager-Session-Secret")), []byte(s.cfg.ManagerSessionSecret)) != 1 { + http.Error(w, "unauthorized", 401) + return false + } + return true +} + +func (s *Server) HandleAdminUser(w http.ResponseWriter, r *http.Request) { + if !s.authorizeAdmin(w, r, http.MethodPost) { + return + } + var request struct { + UserID string `json:"user_id"` + Action string `json:"action"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil || !ruleset.AllowedUserID(request.UserID) || (request.Action != "stop" && request.Action != "restart") { + http.Error(w, "invalid operation", 400) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + if err := s.prepareMu.LockContext(ctx); err != nil { + http.Error(w, err.Error(), 503) + return + } + err := s.stopAndRemoveUserContainerAndNetwork(ctx, request.UserID) + if err == nil { + s.mu.Lock() + delete(s.runtimes, request.UserID) + s.mu.Unlock() + } + s.prepareMu.Unlock() + if err == nil && request.Action == "restart" { + _, err = s.ensureUserRuntimeReady(ctx, request.UserID) + } + if err != nil { + http.Error(w, err.Error(), 503) + return + } + http_helper.WriteJSONViaHTTP(w, 200, map[string]bool{"ok": true}) +} + +func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { + if !s.authorizeAdmin(w, r, http.MethodGet) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + containers, err := s.docker.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + http.Error(w, err.Error(), 503) + return + } + rows := make(map[string]map[string]any) + s.mu.Lock() + for id, state := range s.runtimes { + if state != nil { + rows[id] = map[string]any{"sessions": state.ActiveSessions, "last_observed": state.LastObservedAt} + } + } + s.mu.Unlock() + for _, info := range containers { + for _, name := range info.Names { + name = strings.TrimPrefix(name, "/") + if strings.HasPrefix(name, s.cfg.UserContainerNamePrefix) { + id := strings.TrimPrefix(name, s.cfg.UserContainerNamePrefix) + if !ruleset.AllowedUserID(id) { + continue + } + if rows[id] == nil { + rows[id] = map[string]any{"sessions": 0} + } + rows[id]["state"] = info.State + rows[id]["image"] = info.Image + } + } + } + http_helper.WriteJSONViaHTTP(w, 200, rows) +} + +// Reconcile discovers runtimes after Manager restarts and gives Auth a grace +// period to replay its complete session snapshot before idle cleanup is enabled. +func (s *Server) reconcileRuntimes(ctx context.Context) error { + containers, err := s.docker.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.runtimes == nil { + s.runtimes = make(map[string]*RuntimeState) + } + for _, info := range containers { + for _, name := range info.Names { + name = strings.TrimPrefix(name, "/") + if !strings.HasPrefix(name, s.cfg.UserContainerNamePrefix) { + continue + } + id := strings.TrimPrefix(name, s.cfg.UserContainerNamePrefix) + if !ruleset.AllowedUserID(id) { + continue + } + if s.runtimes[id] == nil { + s.runtimes[id] = &RuntimeState{UserID: id, IdleSince: time.Now().Add(90 * time.Second)} + } + } + } + return nil +} diff --git a/src/internal/manager/handler/server.go b/src/internal/manager/handler/server.go index 3bd0e94..31c9ffc 100644 --- a/src/internal/manager/handler/server.go +++ b/src/internal/manager/handler/server.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "sync" + "sync/atomic" "syscall" "time" @@ -28,6 +29,7 @@ type Server struct { cfg *config.Config // prepareMu serializes disk preparation, runtime allocation and idle cleanup. + pending atomic.Int32 prepareMu runtimeLock diskClient *http.Client @@ -72,8 +74,11 @@ func NewServer(cfg *config.Config) (*Server, error) { func (s *Server) RegisterRoutes() { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.HandleHealthz) + mux.HandleFunc("/admin/user", s.HandleAdminUser) + mux.HandleFunc("/admin/status", s.HandleAdminStatus) mux.HandleFunc("/user/up", s.HandleUserUp) mux.HandleFunc("/user/session-state", s.HandleUserSessionState) + mux.HandleFunc("/user/session-snapshot", s.HandleSessionSnapshot) s.mux = mux } @@ -82,7 +87,13 @@ func (s *Server) RegisterRoutes() { func (s *Server) Start() { ctx, cancel := context.WithCancel(context.Background()) + reconcileCtx, stopReconcile := context.WithTimeout(ctx, 10*time.Second) + if err := s.reconcileRuntimes(reconcileCtx); err != nil { + log.Printf("runtime reconciliation: %v", err) + } + stopReconcile() s.StartIdleReaper(ctx) + s.startReconciliation(ctx) srv := &http.Server{ Addr: s.cfg.ListenAddr, diff --git a/src/internal/manager/handler/session_sync.go b/src/internal/manager/handler/session_sync.go new file mode 100644 index 0000000..06e4432 --- /dev/null +++ b/src/internal/manager/handler/session_sync.go @@ -0,0 +1,72 @@ +package handler + +import ( + "context" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/elecbug/linuxus/src/internal/common/packet" + "github.com/elecbug/linuxus/src/internal/common/ruleset" +) + +func (s *Server) HandleSessionSnapshot(w http.ResponseWriter, r *http.Request) { + if !s.authorizeAdmin(w, r, http.MethodPost) { + return + } + var snapshot packet.SessionSnapshot + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&snapshot); err != nil || snapshot.ObservedAt.IsZero() || snapshot.ObservedAt.After(time.Now().Add(time.Minute)) { + http.Error(w, "invalid session snapshot", 400) + return + } + for id, count := range snapshot.Sessions { + if !ruleset.AllowedUserID(id) || count < 0 { + http.Error(w, "invalid session state", 400) + return + } + } + s.mu.Lock() + ids := make([]string, 0, len(s.runtimes)) + for id := range s.runtimes { + ids = append(ids, id) + } + s.mu.Unlock() + for id, count := range snapshot.Sessions { + s.updateSessionState(id, count, snapshot.ObservedAt) + } + for _, id := range ids { + if _, ok := snapshot.Sessions[id]; !ok { + s.updateSessionState(id, 0, snapshot.ObservedAt) + } + } + w.WriteHeader(200) +} + +func (s *Server) startReconciliation(ctx context.Context) { + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + if err := s.reconcileRuntimes(checkCtx); err != nil { + log.Printf("runtime reconciliation: %v", err) + } + cancel() + // No positive count may survive forever after Auth disappears. + s.mu.Lock() + for _, state := range s.runtimes { + if state != nil && state.ActiveSessions > 0 && time.Since(state.LastObservedAt) > 2*time.Minute { + state.ActiveSessions = 0 + state.IdleSince = time.Now() + } + } + s.mu.Unlock() + } + } + }() +} diff --git a/src/internal/manager/server.go b/src/internal/manager/server.go index e998985..344d63b 100644 --- a/src/internal/manager/server.go +++ b/src/internal/manager/server.go @@ -88,7 +88,23 @@ func parseConfigFromEnv() (*config.Config, error) { return nil, fmt.Errorf("invalid UserService.Limits.Admin.Memory: %v", err) } + templates, classes, err := ctl_config.ParseTemplates(&cfg) + if err != nil { + return nil, err + } + var minFree int64 + if cfg.Capacity.MinFreeSpace != "" { + minFree, err = convert.BytesFromString(cfg.Capacity.MinFreeSpace) + if err != nil { + return nil, err + } + } return &config.Config{ + AuthListFile: "/run/linuxus-accounts", + MaxRunning: cfg.Capacity.MaxRunning, + MaxPending: cfg.Capacity.MaxPending, + MinFreeBytes: minFree, + Templates: templates, Classes: classes, AutoEnsure: cfg.Volumes.AutoEnsure, DiskServiceSocket: diskSocket, ListenAddr: ":5959", From 836e83f158dae0fdb9633c02d82c256bffe9d822 Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 16:50:15 +0900 Subject: [PATCH 8/9] feat: Implement maintenance checks and improve disk management - Added maintenance account checks to prevent operations on users under maintenance in cleanVolumesAll and cleanVolumeUser. - Enhanced ensureDiskAll and ensureDiskUser to skip users with maintenance status. - Introduced checkPendingRestore to validate restore images before cleanup. - Updated disk diagnostics to provide clearer error reporting. - Added runtime diagnostics to check disk service health and report status. - Implemented session state updates to reject delayed reports and future timestamps. - Improved error handling in account management and session synchronization. - Added tests for maintenance account behavior and session snapshot handling. --- .github/workflows/pr-title-check.yml | 3 +- README.md | 6 +- doc/OPERATIONS.md | 217 +++++++++++++ doc/USAGE.md | 12 +- shell/linuxus-completion.bash | 18 +- src/cmd/ctl/main.go | 20 +- src/cmd/ctl/main_test.go | 11 +- src/docker/assets.go | 4 +- src/docker/templates/c/hello.c | 7 + src/docker/templates/linux/README.txt | 8 + src/docker/templates/python/hello.py | 3 + src/docker/user.Dockerfile | 1 + src/internal/auth/handler/admin.go | 215 +++++++++++++ src/internal/auth/handler/admin_test.go | 146 +++++++++ src/internal/auth/handler/app.go | 12 + src/internal/auth/handler/handle_service.go | 6 +- src/internal/auth/page/html.go | 1 + src/internal/auth/server.go | 5 + src/internal/common/backup/archive.go | 6 +- src/internal/common/backup/archive_test.go | 91 ++++++ src/internal/common/config/routes.go | 2 +- src/internal/common/config/templates.go | 11 +- src/internal/common/config/templates_test.go | 20 ++ src/internal/common/user/account.go | 25 +- src/internal/common/user/account_test.go | 131 ++++++++ src/internal/common/user/user.go | 18 ++ src/internal/ctl/app/accounts.go | 8 +- src/internal/ctl/app/backup.go | 286 +++++++++++++++--- src/internal/ctl/app/backup_test.go | 162 ++++++++++ src/internal/ctl/app/diagnostics.go | 5 + src/internal/ctl/app/disk.go | 86 +++++- src/internal/ctl/app/disk_diagnostics.go | 4 +- src/internal/ctl/app/disk_service_test.go | 56 ++++ src/internal/ctl/app/docker_build.go | 16 + src/internal/ctl/app/doctor_runtime.go | 87 ++++++ src/internal/ctl/app/runtime_test.go | 20 +- src/internal/ctl/app/service.go | 23 +- src/internal/ctl/app/supervisor.go | 21 +- src/internal/ctl/app/supervisor_test.go | 97 ++++++ .../manager/handler/handle_session.go | 16 +- .../manager/handler/handle_up_test.go | 6 +- src/internal/manager/handler/operations.go | 48 ++- .../manager/handler/operations_test.go | 172 +++++++++++ src/internal/manager/handler/server.go | 2 + src/internal/manager/handler/session_sync.go | 21 +- .../manager/handler/session_sync_test.go | 75 +++++ 46 files changed, 2094 insertions(+), 116 deletions(-) create mode 100644 doc/OPERATIONS.md create mode 100644 src/docker/templates/c/hello.c create mode 100644 src/docker/templates/linux/README.txt create mode 100644 src/docker/templates/python/hello.py create mode 100644 src/internal/auth/handler/admin.go create mode 100644 src/internal/auth/handler/admin_test.go create mode 100644 src/internal/common/backup/archive_test.go create mode 100644 src/internal/common/config/templates_test.go create mode 100644 src/internal/common/user/account_test.go create mode 100644 src/internal/ctl/app/backup_test.go create mode 100644 src/internal/ctl/app/doctor_runtime.go create mode 100644 src/internal/ctl/app/supervisor_test.go create mode 100644 src/internal/manager/handler/operations_test.go create mode 100644 src/internal/manager/handler/session_sync_test.go diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 9bbc7d7..61dd6c1 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -19,7 +19,7 @@ jobs: run: | echo "Checking PR title: $PR_TITLE" - if [[ "$PR_TITLE" =~ ^\[(CHORE|REFACTOR|DOCUMENT|FEATURE|GENESIS|BUG|DUPLICATE)\]\ ]]; then + if [[ "$PR_TITLE" =~ ^\[(CHORE|REFACTOR|DOCUMENT|FEATURE|GENESIS|BUG|DUPLICATE|BOT)\]\ ]]; then echo "PR title format is valid." exit 0 fi @@ -33,4 +33,5 @@ jobs: echo " [GENESIS] ..." echo " [BUG] ..." echo " [DUPLICATE] ..." + echo " [BOT] ..." exit 1 \ No newline at end of file diff --git a/README.md b/README.md index 863bba6..b148397 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Linuxus enables instructors to provide per-user Linux environments without requi ## 🚀 Usage -Refer to the [Usage documentation](./doc/USAGE.md) for instructions on how to use this program. +Refer to the [Usage documentation](./doc/USAGE.md) for installation and the [Operations guide](./doc/OPERATIONS.md) for diagnostics, recovery, accounts, backups, classroom templates, and the admin interface. --- @@ -51,6 +51,6 @@ Please refer to [SECURITY.md](./SECURITY.md) for responsible disclosure instruct --- -## 🚧 Upcoming Features +## Operations -Nothing TODO. \ No newline at end of file +Operational diagnostics, systemd recovery, account management, backups and restores, resource limits, classroom templates, and an administrator interface are available. See [Operations](./doc/OPERATIONS.md). diff --git a/doc/OPERATIONS.md b/doc/OPERATIONS.md new file mode 100644 index 0000000..cb5cba5 --- /dev/null +++ b/doc/OPERATIONS.md @@ -0,0 +1,217 @@ +# Operations + +Existing `.env` files remain supported. Omitting the new settings leaves the global +runtime and queue limits disabled and uses the default user image. Before using +these features, run `up` with the new `linuxusctl` to rebuild the Auth and Manager +images. Updating running services disconnects active terminals; schedule upgrades +outside class hours. + +## Configuration checks and diagnostics + +```bash +sudo linuxusctl config-check +sudo linuxusctl doctor +``` + +`config-check` validates configuration syntax, values, and paths without connecting +to Docker or reading account records. It prints the selected configuration and +storage paths without exposing secrets. `doctor` checks configuration, the account +file, Docker connectivity, required tools, disk mounts, filesystem free space, +Auth and Manager containers, and the host disk service. Failures return exit code +1; warnings alone return 0. Missing directories or mounts before startup are +reported as warnings. It does not test the external HTTPS proxy or browser login. +Both commands leave files, mounts, and containers unchanged. + +## Recovery after reboots and process failures + +`supervise` prepares disks before starting Manager and Auth. Every five seconds, +it checks the disk service and service containers and restarts exited processes. +systemd also restarts the supervisor after an abnormal exit. Cached images avoid +rebuilding on every boot. Supervised containers do not use Docker restart +policies, ensuring services cannot start before their disks are ready. + +For the initial installation, prepare `/etc/linuxus/.env`, then run: + +```bash +sudo install -m 0755 ./linuxusctl /usr/local/bin/linuxusctl +sudo linuxusctl up # Build service images containing the new binary first. +sudo linuxusctl down +linuxusctl systemd-unit > /tmp/linuxus.service +sudo install -m 0644 /tmp/linuxus.service /etc/systemd/system/linuxus.service +sudo systemctl daemon-reload +sudo systemctl enable --now linuxus.service +``` + +To use another configuration, set `LINUXUS_CONFIG=/absolute/path/.env` when +generating the unit. The generated unit records absolute configuration and binary +paths. Regenerate it if the binary moves. The host must provide Docker and systemd. + +```bash +sudo systemctl status linuxus +sudo journalctl -u linuxus -f +sudo systemctl restart linuxus +sudo systemctl stop linuxus +``` + +While supervised, use systemctl for service startup, shutdown, and restart. +To upgrade: stop `linuxus` with systemctl, install the binary, run `linuxusctl up` +and `linuxusctl down`, then start `linuxus` with systemctl. Account and disk +commands remain available while supervised. The host disk service runs regardless +of automatic activation settings to support diagnostics, usage, and capacity +checks. With `VOLUMES_AUTO_ENSURE=false`, new user disks still require manual +preparation. + +## Session resynchronization + +In addition to reporting session starts and stops, Auth sends a complete session +snapshot every 15 seconds. The next snapshot repairs missed disconnect reports. +Snapshots apply atomically, and delayed reports cannot revive sessions absent +from a newer snapshot. Manager discovers existing user containers at startup and every 30 seconds, +granting newly discovered environments a 90-second synchronization grace period. +Session counts expire after two minutes without an update; the configured idle +cleanup timeout then applies. Setting that timeout to `0s` still disables idle +cleanup. + +## Account operations that preserve data + +```bash +sudo linuxusctl list-users +sudo linuxusctl lock-user --user alice +sudo linuxusctl unlock-user --user alice +sudo linuxusctl reset-password --user alice +sudo linuxusctl disconnect-user --user alice +``` + +Locking blocks new logins and runtime starts and stops the running environment. +Unlocking allows access again. Disconnecting invalidates existing cookies and +terminal connections while allowing subsequent logins. Password resets use hidden +terminal input with confirmation; passwords are not passed as command arguments. +These commands preserve home images and exercise files. Use `lock-user` to suspend +access; `remove-user` deletes both the account and its disk. + +Every account change records a new session generation, so unlocking does not +reactivate old cookies. Legacy bcrypt records remain readable; only updated rows +receive versioned account metadata. Auth and the CLI share file locks and preserve +the inode used by Docker's bind mount. Older binaries cannot read the new record +format; do not downgrade after updating accounts. If a CLI stop request fails, the +account change remains in effect and the error explains that partial result. +Auth also periodically revalidates existing connections. + +## Per-user backups and restores + +```bash +sudo linuxusctl backup-user --user alice --output /srv/backups/alice.tar.gz +linuxusctl verify-backup --file /srv/backups/alice.tar.gz +sudo linuxusctl restore-user --user alice --file /srv/backups/alice.tar.gz --replace +``` + +Create the backup directory first. Backups contain the user's ext4 home image and +a manifest recording its SHA-256 checksum, user ID, UID/GID, size, and creation +time. Shared disks, account passwords, and deployment settings are not included. +Store backups outside managed disks on the host. Existing backup files are never +overwritten. + +Backup maintenance marks the account unavailable, stops its environment, and +removes its mounts and loop attachments. Success remounts the disk and restores +the original account lock state. The container remains stopped; the user logs in +again to resume. Unlock and password-change requests are rejected during +maintenance. Concurrent backups, restores, and volume cleanup are also rejected. Restore +verification holds this lock before allocating the temporary image. + +Restores validate a temporary image before stopping the user's environment. +Validation checks the two permitted archive entries, checksum, size, user ID, +and UID/GID; archive paths are never used as host paths. Empty regions are restored +as sparse files. The target account must already exist with matching UID/GID. +Replacing an existing image requires `--replace`. The original inode is preserved +at `homes/.restore-.previous` before the replacement is published atomically. +If mounting the replacement fails, the original image is restored. Recovery errors +identify any retained previous image. Startup skips accounts under maintenance +and never initializes a missing home for them. + +After a failed or interrupted operation, the account remains under maintenance. +Inspect the error, retained images, and `doctor` output, then recover the account. +`recover-user` stops the environment, restores a retained original when present, +and mounts the existing image before clearing maintenance. It refuses missing or +invalid images and leaves account access locked until explicitly unlocked: + +```bash +sudo linuxusctl recover-user --user alice # Recover only when maintenance is inactive; leave the account locked. +sudo linuxusctl ensure-disk --user alice +sudo linuxusctl unlock-user --user alice +``` + +Checksums detect corruption but do not authenticate the backup's author. +Administrators must control backup files and their storage locations. Restore +space checks conservatively use the image's logical size. + +## Global resource limits + +The following values are examples. New installations default to no global limits. + +```dotenv +CAPACITY_MAX_RUNNING=30 +CAPACITY_MAX_PENDING=20 +CAPACITY_MIN_FREE_SPACE='5g' +``` + +The runtime limit counts running user containers, including the administrator's +environment. Reconnecting to an already running environment does not consume +another slot. Preparation requests run serially; the pending limit includes the +request currently being prepared. Excess requests receive HTTP 503 with retry +guidance. Cancelled or expired requests leave the queue. + +Host free space is checked on the filesystems containing home and shared images +before new environments, new images, and restores are prepared. This does not +reserve space for every subsequent write or enforce a continuous write limit; +continue monitoring disk usage. Restart services after changing settings. + +## Classroom environment templates + +The binary includes `linux`, `c`, and `python` templates with introductory Linux +exercises, `hello.c`, and `hello.py`. They use tools in the default user image. +Assign one directly with `assign-template --user alice --template python`. + +For additional packages, first build an image based on the default user image +with the required packages and seed files. Run `linuxusctl templates` to see the +default image name. Preserve the existing user UID/GID and `start.sh`/ttyd startup +contract. Configure image tags explicitly; arbitrary images are not pulled +automatically. + +```dotenv +USER_SERVICE_TEMPLATES='{"python":{"image":"classroom-python:2026","seed":"/opt/classroom/python"},"c":{"image":"classroom-c:2026","seed":"/opt/classroom/c"}}' +USER_SERVICE_CLASSES='{"class-a":"python","class-b":"c"}' +``` + +```bash +sudo linuxusctl templates +sudo linuxusctl assign-class --user alice --class class-a +sudo linuxusctl assign-template --user bob --template c +sudo linuxusctl assign-template --user bob --template default +``` + +Assigning a class clears the individual template selection. An individual +selection takes precedence over the class. Assignment changes stop the existing +environment; the next connection creates it using the selected image. Home data +is preserved. Files in the image's `seed` directory are copied into the home +without overwriting existing files. `default` selects the original default image. +Restart an environment to apply a changed template image. + +## Administrator web interface + +When the account named by `MANAGER_SERVICE_ADMIN_ID` logs in, the service page +shows an **Admin** link. The interface is also available at `/admin`. Create +accounts through registration or `add-user`; assigning an administrator ID does +not create the account automatically. + +The interface shows users, account locks, session counts, container states, and +usage for mounted home disks. Actions include locking, unlocking, password resets, +disconnecting, restarting environments, and assigning classes or templates. +Status refreshes every 15 seconds. Disk service failures produce an explicit +warning while retaining available container and session status. The web interface rejects locking your own +administrator account to prevent accidental lockout. + +Every administrator API request rechecks the account and administrator role. +Mutation requests require a session-bound CSRF token. Responses exclude account +hashes and secrets. Administrator and `healthz` paths are reserved. Manager's +operations API uses the existing shared secret; the host disk API is exposed only +through its permission-restricted Unix socket. diff --git a/doc/USAGE.md b/doc/USAGE.md index 30085bb..abda0df 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -233,7 +233,7 @@ VOLUMES_AUTO_ENSURE=true ``` Apply the setting with `sudo ./linuxusctl restart`. `up` starts the private host -`linuxusctl serve-disks` process; `down` stops it. Manager requests disk preparation +`linuxusctl serve-disks` process for disk diagnostics and preparation; `down` stops it. Manager requests disk preparation over a Unix socket before creating or restarting a user container. Disk errors prevent the shell from starting, and existing images are reused rather than formatted again. Manual `ensure-disk` remains available. @@ -244,7 +244,8 @@ Only Manager receives that directory as a read-only mount. There is no TCP listener. Requests accept a registered user ID only; paths and disk limits come from the deployment configuration. Manager's container capabilities are unchanged. The default service log is `/var/lib/linuxus/data/.disk-service/service.log`. Run `sudo ./linuxusctl up` -after a host reboot to restore mounts and the host process; Docker container +after a host reboot to restore mounts and the host process, or enable the +[systemd supervisor](OPERATIONS.md) for automatic recovery. Docker container restart policies alone do not restart this process. As with the existing deployment, Docker must run locally on the Linux host. @@ -406,3 +407,10 @@ it deletes the images and their contents. > ![](./fig/03-shell_2.png) > Shell Page - Test GCC + +## Extended operations + +See [Operations](OPERATIONS.md) for `config-check`, `doctor`, systemd recovery, +account locks and password changes, backups, capacity settings, class templates, +and the administrator interface at `/admin`. New optional `.env` keys may be +added to an existing file; rerunning `init` does not overwrite existing settings. diff --git a/shell/linuxus-completion.bash b/shell/linuxus-completion.bash index dac5302..49a9670 100644 --- a/shell/linuxus-completion.bash +++ b/shell/linuxus-completion.bash @@ -5,7 +5,7 @@ _linuxus_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="init config-check doctor up down restart ps add-user remove-user clean-volume ensure-disk help" + commands="init config-check doctor systemd-unit list-users templates lock-user unlock-user reset-password disconnect-user recover-user assign-template assign-class backup-user restore-user verify-backup up down restart ps add-user remove-user clean-volume ensure-disk help" if [[ ${COMP_CWORD} -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) @@ -18,13 +18,27 @@ _linuxus_completion() { return 0 ;; - add-user|remove-user) + add-user|remove-user|lock-user|unlock-user|reset-password|disconnect-user|recover-user) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--user -u" -- "$cur") ) fi return 0 ;; + backup-user|restore-user|verify-backup|assign-template|assign-class) + if [[ "$prev" == --file || "$prev" == --output ]]; then + COMPREPLY=( $(compgen -f -- "$cur") ) + elif [[ "$cur" == -* ]]; then + case "${COMP_WORDS[1]}" in + backup-user) COMPREPLY=( $(compgen -W "--user --output" -- "$cur") ) ;; + restore-user) COMPREPLY=( $(compgen -W "--user --file --replace" -- "$cur") ) ;; + verify-backup) COMPREPLY=( $(compgen -W "--file" -- "$cur") ) ;; + assign-template) COMPREPLY=( $(compgen -W "--user --template" -- "$cur") ) ;; + assign-class) COMPREPLY=( $(compgen -W "--user --class" -- "$cur") ) ;; + esac + fi + return 0 + ;; clean-volume|ensure-disk) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--all -a --user -u" -- "$cur") ) diff --git a/src/cmd/ctl/main.go b/src/cmd/ctl/main.go index 8be9fcc..db22835 100644 --- a/src/cmd/ctl/main.go +++ b/src/cmd/ctl/main.go @@ -90,6 +90,9 @@ func run() error { return nil } + if opt.Option == VERIFY_BACKUP { + return app.VerifyBackup(opt.Params.Params["file"], os.Stdout) + } configFile, err := config.ResolveConfigFile() if err != nil { return err @@ -113,9 +116,6 @@ func run() error { if opt.Option == SYSTEMD_UNIT { return app.WriteSystemdUnit(execPath, configFile, os.Stdout) } - if opt.Option == VERIFY_BACKUP { - return app.VerifyBackup(opt.Params.Params["file"], os.Stdout) - } a, err := app.CreateApp(execPath, configFile) if err != nil { return err @@ -204,7 +204,7 @@ func parseArgs(bin string, args []string) (Options, error) { result.Option = LIST_USERS case "templates": result.Option = TEMPLATES - case "lock-user", "unlock-user", "reset-password", "disconnect-user", "assign-template", "assign-class": + case "recover-user", "lock-user", "unlock-user", "reset-password", "disconnect-user", "assign-template", "assign-class": result.Option = ACCOUNTS case "backup-user": result.Option = BACKUP @@ -319,7 +319,7 @@ func parseArgs(bin string, args []string) (Options, error) { if !ruleset.AllowedUserID(id) { return result, fmt.Errorf("invalid user ID: %q", id) } - } else if result.Option == ADD_USER || result.Option == REMOVE_USER { + } else if _, all := parsed.Params["all"]; !all || result.Option == ADD_USER || result.Option == REMOVE_USER { return result, fmt.Errorf("%s requires --user ", args[0]) } } @@ -355,6 +355,16 @@ func usageText(bin string, showUsage, showExample, showLogFormat bool) string { result += fmt.Sprintf("│ └─ %-35s# Show status about linuxus service\n", "ps [OPTION]") result += fmt.Sprintf("│ %-35s - OPTION can be one of container, network, all or their shorthand c, n, a. If not specified, defaults to all.\n", "") result += "│\n" + result += "├─ Operations:\n" + result += "│ systemd-unit | list-users | templates\n" + result += "│ lock-user | unlock-user | reset-password | disconnect-user --user \n" + result += "│ assign-template --user --template \n" + result += "│ assign-class --user --class \n" + result += "│ backup-user --user --output \n" + result += "│ restore-user --user --file [--replace]\n" + result += "│ verify-backup --file \n" + result += "│ recover-user --user (clear abandoned maintenance; keep locked)\n" + result += "│\n" result += "├─ User Management:\n" result += fmt.Sprintf("│ ├─ %-35s# Add a new user\n", "add-user --user ") result += fmt.Sprintf("│ └─ %-35s# Remove an existing user\n", "remove-user --user ") diff --git a/src/cmd/ctl/main_test.go b/src/cmd/ctl/main_test.go index 973c252..6d84d04 100644 --- a/src/cmd/ctl/main_test.go +++ b/src/cmd/ctl/main_test.go @@ -4,7 +4,9 @@ import "testing" func TestRejectAmbiguousArguments(t *testing.T) { for _, args := range [][]string{ - {"ensure-disk", "--user"}, {"clean-volume", "--user"}, {"remove-user", "--user"}, + {"ensure-disk", "--user"}, {"ensure-disk", "--file", "anything"}, + {"backup-user", "--user", "alice"}, {"restore-user", "--user", "alice", "--file", "a.tar.gz", "--all"}, + {"assign-class", "--user", "alice"}, {"lock-user", "--all"}, {"verify-backup", "--user", "alice", "--file", "a"}, {"clean-volume", "--user"}, {"remove-user", "--user"}, {"clean-volume", "--user", "alice", "--user", "bob"}, {"ensure-disk", "--user", "alice", "-u", "bob"}, {"ensure-disk", "--all", "--user", "alice"}, @@ -20,7 +22,12 @@ func TestRejectAmbiguousArguments(t *testing.T) { func TestValidArguments(t *testing.T) { for _, args := range [][]string{ - {"init"}, {"config-check"}, {"doctor"}, {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, + {"init"}, {"systemd-unit"}, {"list-users"}, {"templates"}, + {"lock-user", "--user", "alice"}, {"recover-user", "--user", "alice"}, + {"assign-class", "--user", "alice", "--class", "class-a"}, + {"backup-user", "--user", "alice", "--output", "/backups/a.tar.gz"}, + {"restore-user", "--user", "alice", "--file", "/backups/a.tar.gz", "--replace"}, + {"verify-backup", "--file", "a.tar.gz"}, {"config-check"}, {"doctor"}, {"up"}, {"down"}, {"restart"}, {"help"}, {"ps"}, {"ps", "n"}, {"ps", "NETWORK"}, {"ensure-disk", "--all"}, {"clean-volume", "-a"}, {"add-user", "--user", "true"}, {"remove-user", "-u", "alice"}, {"ensure-disk", "--user=alice"}, diff --git a/src/docker/assets.go b/src/docker/assets.go index 94f8767..4dc5813 100644 --- a/src/docker/assets.go +++ b/src/docker/assets.go @@ -3,7 +3,7 @@ package docker import "embed" -// Files contains only runtime image definitions and the user startup script. +// Files contains runtime image definitions, the startup script, and classroom seeds. // -//go:embed *.Dockerfile start.sh +//go:embed *.Dockerfile start.sh templates var Files embed.FS diff --git a/src/docker/templates/c/hello.c b/src/docker/templates/c/hello.c new file mode 100644 index 0000000..38c7649 --- /dev/null +++ b/src/docker/templates/c/hello.c @@ -0,0 +1,7 @@ +/* Build: gcc -Wall -Wextra -o hello hello.c && ./hello */ +#include + +int main(void) { + puts("Hello, Linuxus!"); + return 0; +} diff --git a/src/docker/templates/linux/README.txt b/src/docker/templates/linux/README.txt new file mode 100644 index 0000000..6750f67 --- /dev/null +++ b/src/docker/templates/linux/README.txt @@ -0,0 +1,8 @@ +Linux basics + +1. Use pwd and ls -la to inspect your location and files. +2. Run mkdir practice && cd practice to create a working directory. +3. Run printf 'hello\n' > hello.txt to create a file, then read it with cat. +4. Read the help pages for cp, mv, find, wc, and chmod. + +Files in your home directory are preserved when the environment stops. diff --git a/src/docker/templates/python/hello.py b/src/docker/templates/python/hello.py new file mode 100644 index 0000000..8736c58 --- /dev/null +++ b/src/docker/templates/python/hello.py @@ -0,0 +1,3 @@ +"""Run with: python3 hello.py""" + +print("Hello, Linuxus!") diff --git a/src/docker/user.Dockerfile b/src/docker/user.Dockerfile index af681ac..41a4989 100644 --- a/src/docker/user.Dockerfile +++ b/src/docker/user.Dockerfile @@ -23,6 +23,7 @@ ENV LC_ALL=en_US.UTF-8 RUN groupadd -g "$CONTAINER_GID" "$CONTAINER_RUNTIME_USER" && useradd -m -u "$CONTAINER_UID" -g "$CONTAINER_GID" -s /bin/bash "$CONTAINER_RUNTIME_USER" COPY start.sh /start.sh +COPY templates /opt/linuxus/templates RUN chmod +x /start.sh CMD ["/start.sh"] \ No newline at end of file diff --git a/src/internal/auth/handler/admin.go b/src/internal/auth/handler/admin.go new file mode 100644 index 0000000..8cfa0c8 --- /dev/null +++ b/src/internal/auth/handler/admin.go @@ -0,0 +1,215 @@ +package handler + +import ( + "bytes" + "context" + "crypto/hmac" + "encoding/json" + "fmt" + "html/template" + "io" + "net/http" + "sort" + "time" + + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + id, ok := a.getSessionID(r) + if !ok { + http.Error(w, "authentication required", 401) + return false + } + if a.adminID == "" || id != a.adminID { + http.Error(w, "administrator access required", 403) + return false + } + w.Header().Set("Cache-Control", "no-store") + return true +} + +func (a *App) adminToken(r *http.Request) string { + cookie, err := r.Cookie("session") + if err != nil { + return "" + } + return a.sign("admin-csrf|" + cookie.Value) +} + +func (a *App) managerOperation(ctx context.Context, path, method string, payload any, result any) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, a.managerBaseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Manager-Session-Secret", a.managerSessionSecret) + resp, err := a.managerClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("Manager operation failed (HTTP %d)", resp.StatusCode) + } + if result != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(result); err != nil { + return err + } + if warning := resp.Header.Get("X-Linuxus-Disk-Error"); warning != "" { + return fmt.Errorf("%s", warning) + } + } + return nil +} + +func (a *App) handleAdmin(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/admin" && r.URL.Path != "/admin/" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + if !a.requireAdmin(w, r) { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Frame-Options", "DENY") + if err := adminTemplate.Execute(w, map[string]string{"Token": a.adminToken(r)}); err != nil { + return + } +} + +func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + if r.Method == http.MethodGet { + accounts, err := user.LoadUsers(a.authListFile) + if err != nil { + http.Error(w, "cannot read accounts", 500) + return + } + states := map[string]map[string]any{} + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + stateErr := a.managerOperation(ctx, "/admin/status", http.MethodGet, nil, &states) + ids := make([]string, 0, len(accounts)) + for id := range accounts { + ids = append(ids, id) + } + sort.Strings(ids) + rows := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + account, err := user.ParseAccount(accounts[id]) + if err != nil { + http.Error(w, "invalid account record", 500) + return + } + row := states[id] + if row == nil { + row = map[string]any{} + } + row["id"] = id + row["locked"] = account.Locked || account.Maintenance + row["maintenance"] = account.Maintenance + row["class"] = account.Class + row["template"] = account.Template + rows = append(rows, row) + } + result := map[string]any{"users": rows, "templates": a.templates, "classes": a.classes} + if stateErr != nil { + result["warning"] = stateErr.Error() + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + if token := r.Header.Get("X-CSRF-Token"); token == "" || !hmac.Equal([]byte(token), []byte(a.adminToken(r))) { + http.Error(w, "invalid CSRF token", 403) + return + } + var request struct { + UserID string `json:"user_id"` + Action string `json:"action"` + Value string `json:"value"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil { + http.Error(w, "invalid request", 400) + return + } + action := request.Action + switch action { + case "lock": + if request.UserID == a.adminID { + http.Error(w, "cannot lock the current administrator", 400) + return + } + case "unlock", "password", "disconnect", "restart": + case "template": + if _, ok := a.templates[request.Value]; !ok && request.Value != "default" { + http.Error(w, "unknown template", 400) + return + } + case "class": + if _, ok := a.classes[request.Value]; !ok { + http.Error(w, "unknown class", 400) + return + } + default: + http.Error(w, "unsupported operation", 400) + return + } + if action == "restart" { + action = "disconnect" + } + if err := user.UpdateAccount(a.authListFile, request.UserID, action, request.Value); err != nil { + http.Error(w, err.Error(), 400) + return + } + if action != "unlock" { + ctx, cancel := context.WithTimeout(r.Context(), 50*time.Second) + defer cancel() + operation := "stop" + if request.Action == "restart" { + operation = "restart" + } + if err := a.managerOperation(ctx, "/admin/user", http.MethodPost, map[string]string{"user_id": request.UserID, "action": operation}, nil); err != nil { + http.Error(w, "Account updated; "+err.Error(), 503) + return + } + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"ok":true}`) +} + +var adminTemplate = template.Must(template.New("admin").Parse(` +Linuxus Administration + +

Linuxus Administration

Locking accounts or disconnecting sessions preserves exercise data.

UserAccount and sessionsDisk usageClassroom environmentActions
+

Reset password

`)) diff --git a/src/internal/auth/handler/admin_test.go b/src/internal/auth/handler/admin_test.go new file mode 100644 index 0000000..6d06328 --- /dev/null +++ b/src/internal/auth/handler/admin_test.go @@ -0,0 +1,146 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func adminFixture(t *testing.T) (*App, *http.Cookie, *http.Cookie) { + t.Helper() + path := filepath.Join(t.TempDir(), "AUTH_LIST") + os.WriteFile(path, []byte("admin:admin-hash\nalice:alice-hash\n"), 0600) + a := NewApp(&AppConfig{AuthListFile: path, AdminID: "admin", SessionKey: []byte("test-secret"), LoginPath: "login", LogoutPath: "logout", ServicePath: "service", TerminalPath: "terminal", SignupPath: "signup"}) + t.Cleanup(a.Stop) + a.RegisterRoutes() + cookies := []*http.Cookie{} + for _, id := range []string{"admin", "alice"} { + w := httptest.NewRecorder() + a.setSessionCookie(w, httptest.NewRequest("POST", "/login", nil), id, id+"-hash") + cookies = append(cookies, w.Result().Cookies()[0]) + } + return a, cookies[0], cookies[1] +} + +func TestAdminRequiresPrivilegeAndCSRF(t *testing.T) { + a, admin, alice := adminFixture(t) + for _, tc := range []struct { + cookie *http.Cookie + method, token string + want int + }{{nil, "GET", "", 401}, {alice, "GET", "", 403}, {admin, "GET", "", 200}, {admin, "POST", "", 403}, {admin, "POST", "wrong", 403}} { + r := httptest.NewRequest(tc.method, "/admin", strings.NewReader(`{"user_id":"alice","action":"lock"}`)) + if tc.cookie != nil { + r.AddCookie(tc.cookie) + } + r.Header.Set("X-CSRF-Token", tc.token) + w := httptest.NewRecorder() + if tc.method == "GET" { + a.handleAdmin(w, r) + } else { + a.handleAdminUsers(w, r) + } + if w.Code != tc.want { + t.Fatalf("status=%d want=%d body=%s", w.Code, tc.want, w.Body.String()) + } + } + users, _ := user.LoadUsers(a.authListFile) + if user.IsLocked(users["alice"]) { + t.Fatal("unauthorized request mutated account") + } +} + +func TestLockUnlockNeverRevivesOldCookies(t *testing.T) { + a, _, cookie := adminFixture(t) + r := httptest.NewRequest("GET", "/terminal/", nil) + r.AddCookie(cookie) + for _, operation := range []string{"lock", "unlock"} { + if err := user.UpdateAccount(a.authListFile, "alice", operation, ""); err != nil { + t.Fatal(err) + } + if _, ok := a.getSessionID(r); ok { + t.Fatalf("%s revived a previous session", operation) + } + } +} + +func TestSessionSnapshotCancelsRevokedConnections(t *testing.T) { + a, _, cookie := adminFixture(t) + a.managerBaseURL = "" + r := httptest.NewRequest("GET", "/terminal/", nil) + r.AddCookie(cookie) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + a.sessionCancels[r] = cancel + a.activeSessions["alice"] = 1 + user.UpdateAccount(a.authListFile, "alice", "lock", "") + if err := a.reportAllSessions(); err != nil { + t.Fatal(err) + } + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("revoked connection stayed open") + } +} + +func TestAdminLockChangesAccountAndStopsRuntimeWithoutExposingCredentials(t *testing.T) { + a, cookie, _ := adminFixture(t) + calls := 0 + manager := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("X-Manager-Session-Secret") != "manager-secret" { + t.Error("missing Manager credential") + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{}`)) + })) + defer manager.Close() + a.managerBaseURL = manager.URL + a.managerSessionSecret = "manager-secret" + r := httptest.NewRequest("POST", "/admin/api/users", strings.NewReader(`{"user_id":"alice","action":"lock"}`)) + r.AddCookie(cookie) + r.Header.Set("X-CSRF-Token", a.adminToken(r)) + w := httptest.NewRecorder() + a.handleAdminUsers(w, r) + if w.Code != 200 || calls != 1 { + t.Fatalf("lock failed: %d %s", w.Code, w.Body.String()) + } + users, _ := user.LoadUsers(a.authListFile) + if !user.IsLocked(users["alice"]) { + t.Fatal("account not locked") + } + list := httptest.NewRequest("GET", "/admin/api/users", nil) + list.AddCookie(cookie) + w = httptest.NewRecorder() + a.handleAdminUsers(w, list) + if w.Code != 200 || strings.Contains(w.Body.String(), "admin-hash") || strings.Contains(w.Body.String(), "manager-secret") || strings.Contains(w.Body.String(), "alice-hash") { + t.Fatal("account listing leaked credentials or failed") + } +} + +func TestAdminKeepsPartialStatusAndShowsDiskWarning(t *testing.T) { + a, cookie, _ := adminFixture(t) + manager := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Linuxus-Disk-Error", "Disk usage is unavailable; check the host disk service.") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"alice":{"state":"running","sessions":2}}`)) + })) + defer manager.Close() + a.managerBaseURL = manager.URL + a.managerClient = manager.Client() + r := httptest.NewRequest(http.MethodGet, "/admin/api/users", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + a.handleAdminUsers(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"warning":"Disk usage is unavailable`) || !strings.Contains(w.Body.String(), `"state":"running"`) { + t.Fatalf("partial status or warning lost: %d %s", w.Code, w.Body) + } +} diff --git a/src/internal/auth/handler/app.go b/src/internal/auth/handler/app.go index 7e4183d..a1695dd 100644 --- a/src/internal/auth/handler/app.go +++ b/src/internal/auth/handler/app.go @@ -11,10 +11,14 @@ import ( "time" "github.com/elecbug/linuxus/src/internal/auth/page" + commonconfig "github.com/elecbug/linuxus/src/internal/common/config" ) // App holds auth server state, templates, and manager integration clients. type App struct { + adminID string + templates map[string]commonconfig.Template + classes map[string]string // users maps user IDs to bcrypt password hashes. users map[string]string // usersMu protects credential reloads, lookups and signup writes. @@ -83,6 +87,9 @@ type App struct { // AppConfig defines all configuration values required to initialize an App. type AppConfig struct { + AdminID string + Templates map[string]commonconfig.Template + Classes map[string]string // Users maps user IDs to bcrypt password hashes. Users map[string]string // AuthListFile is the path to the auth list file in container. @@ -148,6 +155,7 @@ func NewApp(config *AppConfig) *App { config.Users = make(map[string]string) } app := &App{ + adminID: config.AdminID, templates: config.Templates, classes: config.Classes, users: config.Users, authListFile: config.AuthListFile, sessionKey: config.SessionKey, @@ -260,6 +268,10 @@ func (a *App) RegisterRoutes() { a.signupTmpl = signupTmpl a.mux.HandleFunc("/", a.handleRoot) + a.mux.HandleFunc("/admin", a.handleAdmin) + a.mux.HandleFunc("/admin/", a.handleAdmin) + a.mux.HandleFunc("/admin/api/users", a.handleAdminUsers) + a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) a.mux.HandleFunc("/"+a.loginPath, a.handleLogin) a.mux.HandleFunc("/"+a.logoutPath, a.handleLogout) a.mux.HandleFunc("/"+a.signupPath, a.handleSignup) diff --git a/src/internal/auth/handler/handle_service.go b/src/internal/auth/handler/handle_service.go index 1be613f..268bcbe 100644 --- a/src/internal/auth/handler/handle_service.go +++ b/src/internal/auth/handler/handle_service.go @@ -24,9 +24,11 @@ func (a *App) handleServicePage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Pragma", "no-cache") data := struct { - ID string + ID string + IsAdmin bool }{ - ID: id, + ID: id, + IsAdmin: a.adminID != "" && id == a.adminID, } if err := a.serviceTmpl.Execute(w, data); err != nil { diff --git a/src/internal/auth/page/html.go b/src/internal/auth/page/html.go index 948a761..19ddf46 100644 --- a/src/internal/auth/page/html.go +++ b/src/internal/auth/page/html.go @@ -109,6 +109,7 @@ func GetServicePage(terminalPath, logoutPath string) string { html.NewHTML( "div", html.NewAttributes("class", "right"), + html.NewHTML("a", html.NewAttributes("class", "btn", "href", "/admin"), "Admin").AddPrefix("{{if .IsAdmin}}").AddSuffix("{{end}}"), html.NewHTML( "a", html.NewAttributes( diff --git a/src/internal/auth/server.go b/src/internal/auth/server.go index d736d5f..30843d0 100644 --- a/src/internal/auth/server.go +++ b/src/internal/auth/server.go @@ -56,7 +56,12 @@ func parseConfig() (*handler.AppConfig, error) { return nil, fmt.Errorf("failed to parse manager connection timeout: %v", err) } + templates, classes, err := config.ParseTemplates(&cfg) + if err != nil { + return nil, err + } return &handler.AppConfig{ + AdminID: cfg.ManagerService.AdminID, Templates: templates, Classes: classes, Users: users, AuthListFile: cfg.AuthService.Mounts.ContainerAuthListPath, SessionKey: []byte(cfg.AuthService.Security.SessionSecret), diff --git a/src/internal/common/backup/archive.go b/src/internal/common/backup/archive.go index 8d74f2e..9a72e48 100644 --- a/src/internal/common/backup/archive.go +++ b/src/internal/common/backup/archive.go @@ -46,8 +46,10 @@ func Write(output io.Writer, image *os.File, manifest Manifest) error { return err } hash := sha256.New() - if n, err := io.Copy(io.MultiWriter(tw, hash), image); err != nil || n != manifest.Size { - return fmt.Errorf("copy disk image (%d bytes): %w", n, err) + if n, err := io.Copy(io.MultiWriter(tw, hash), image); err != nil { + return fmt.Errorf("copy disk image: %w", err) + } else if n != manifest.Size { + return fmt.Errorf("disk image size changed during backup") } manifest.SHA256 = hex.EncodeToString(hash.Sum(nil)) data, err := json.Marshal(manifest) diff --git a/src/internal/common/backup/archive_test.go b/src/internal/common/backup/archive_test.go new file mode 100644 index 0000000..87242e0 --- /dev/null +++ b/src/internal/common/backup/archive_test.go @@ -0,0 +1,91 @@ +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" +) + +func testArchive(t *testing.T) ([]byte, []byte) { + t.Helper() + data := make([]byte, 2<<20) + copy(data, "student files") + data[1080] = 0x53 + data[1081] = 0xef + copy(data[len(data)-100:], "last block") + path := filepath.Join(t.TempDir(), "disk.img") + os.WriteFile(path, data, 0600) + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var output bytes.Buffer + if err := Write(&output, file, Manifest{UserID: "alice", UID: 1000, GID: 1000}); err != nil { + t.Fatal(err) + } + return output.Bytes(), data +} + +func TestArchiveRoundTripAndSparseRestore(t *testing.T) { + encoded, want := testArchive(t) + manifest, err := Read(bytes.NewReader(encoded), nil, 0) + if err != nil { + t.Fatal(err) + } + if manifest.UserID != "alice" { + t.Fatal(manifest) + } + path := filepath.Join(t.TempDir(), "restored.img") + output, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if _, err := Read(bytes.NewReader(encoded), output, 4<<20); err != nil { + t.Fatal(err) + } + output.Close() + got, _ := os.ReadFile(path) + if !bytes.Equal(got, want) { + t.Fatal("restored bytes differ") + } + if _, err := Read(bytes.NewReader(encoded), nil, 1<<20); err == nil { + t.Fatal("ignored restore space limit") + } +} + +func TestRejectTruncatedCorruptAndUnexpectedEntries(t *testing.T) { + encoded, _ := testArchive(t) + if _, err := Read(bytes.NewReader(encoded[:len(encoded)-5]), nil, 0); err == nil { + t.Fatal("accepted truncated backup") + } + reader, _ := gzip.NewReader(bytes.NewReader(encoded)) + raw, _ := io.ReadAll(reader) + reader.Close() + raw[512] ^= 0xff + var corrupt bytes.Buffer + gz := gzip.NewWriter(&corrupt) + gz.Write(raw) + gz.Close() + if _, err := Read(bytes.NewReader(corrupt.Bytes()), nil, 0); err == nil { + t.Fatal("accepted checksum mismatch") + } + for _, name := range []string{"../../outside", "manifest.json", "disk.img"} { + var output bytes.Buffer + gz := gzip.NewWriter(&output) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeSymlink, Linkname: "/etc/shadow"}) + tw.Close() + gz.Close() + if _, err := Read(bytes.NewReader(output.Bytes()), nil, 0); err == nil { + t.Fatal("accepted unsafe archive entry") + } + } + if _, err := Read(bytes.NewReader(append(append([]byte{}, encoded...), encoded...)), nil, 0); err == nil { + t.Fatal("accepted extra compressed member") + } +} diff --git a/src/internal/common/config/routes.go b/src/internal/common/config/routes.go index 2aacf27..b707afa 100644 --- a/src/internal/common/config/routes.go +++ b/src/internal/common/config/routes.go @@ -29,7 +29,7 @@ func ValidateAuthRoutes(cfg *Config) error { if !valid { return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s must be a clean URL path without leading/trailing slashes, query, escapes, or wildcard patterns", strings.ToUpper(route.name)) } - if value == "static" || strings.HasPrefix(value, "static/") || value == "favicon.ico" { + if value == "admin" || strings.HasPrefix(value, "admin/") || value == "healthz" || value == "static" || strings.HasPrefix(value, "static/") || value == "favicon.ico" { return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s conflicts with a reserved asset route", strings.ToUpper(route.name)) } if previous, exists := seen[value]; exists { diff --git a/src/internal/common/config/templates.go b/src/internal/common/config/templates.go index 306c29c..16ec603 100644 --- a/src/internal/common/config/templates.go +++ b/src/internal/common/config/templates.go @@ -17,7 +17,11 @@ type Template struct { } func ParseTemplates(cfg *Config) (map[string]Template, map[string]string, error) { - templates, classes := map[string]Template{}, map[string]string{} + templates, classes := map[string]Template{ + "linux": {Image: "default", Seed: "/opt/linuxus/templates/linux"}, + "c": {Image: "default", Seed: "/opt/linuxus/templates/c"}, + "python": {Image: "default", Seed: "/opt/linuxus/templates/python"}, + }, map[string]string{} decode := func(value string, target any) error { if strings.TrimSpace(value) == "" { return nil @@ -39,6 +43,9 @@ func ParseTemplates(cfg *Config) (map[string]Template, map[string]string, error) if err := decode(cfg.UserService.Classes, &classes); err != nil { return nil, nil, fmt.Errorf("USER_SERVICE_CLASSES: %w", err) } + if templates == nil || classes == nil { + return nil, nil, fmt.Errorf("templates and classes must be JSON objects") + } for name, template := range templates { if name == "default" || !ruleset.AllowedUserID(name) || strings.TrimSpace(template.Image) == "" || strings.ContainsAny(template.Image, " \t\r\n\x00") { return nil, nil, fmt.Errorf("invalid template %q", name) @@ -59,7 +66,7 @@ func ParseTemplates(cfg *Config) (map[string]Template, map[string]string, error) } func validateOperations(cfg *Config) error { - if cfg.Capacity.MaxRunning < 0 || cfg.Capacity.MaxPending < 0 { + if cfg.Capacity.MaxRunning < 0 || cfg.Capacity.MaxPending < 0 || cfg.Capacity.MaxPending > 1<<30 { return fmt.Errorf("capacity limits must be non-negative") } if cfg.Capacity.MinFreeSpace != "" { diff --git a/src/internal/common/config/templates_test.go b/src/internal/common/config/templates_test.go new file mode 100644 index 0000000..5547334 --- /dev/null +++ b/src/internal/common/config/templates_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +func TestRejectInvalidOperationalSettings(t *testing.T) { + for _, change := range []func(*Config){ + func(c *Config) { c.Capacity.MaxPending = -1 }, func(c *Config) { c.Capacity.MinFreeSpace = "invalid" }, + func(c *Config) { c.UserService.Templates = `{"python":{"image":"image","sead":"/opt"}}` }, + func(c *Config) { c.UserService.Templates = `{"default":{"image":"image"}}` }, + func(c *Config) { c.UserService.Classes = `{"class-a":"unknown"}` }, + func(c *Config) { c.UserService.Templates = `{"python":{"image":"image","seed":"../outside"}}` }, + func(c *Config) { c.UserService.Templates = `{} {}` }, + } { + var cfg Config + change(&cfg) + if err := validateOperations(&cfg); err == nil { + t.Fatal("invalid operational settings accepted") + } + } +} diff --git a/src/internal/common/user/account.go b/src/internal/common/user/account.go index eef7653..50ad839 100644 --- a/src/internal/common/user/account.go +++ b/src/internal/common/user/account.go @@ -19,11 +19,12 @@ import ( // Legacy bcrypt entries remain readable. Updated entries also carry account // state and a random generation so unlocking never revives old session cookies. type Account struct { - Hash string `json:"hash"` - Locked bool `json:"locked,omitempty"` - Generation string `json:"generation,omitempty"` - Template string `json:"template,omitempty"` - Class string `json:"class,omitempty"` + Maintenance bool `json:"maintenance,omitempty"` + Hash string `json:"hash"` + Locked bool `json:"locked,omitempty"` + Generation string `json:"generation,omitempty"` + Template string `json:"template,omitempty"` + Class string `json:"class,omitempty"` } const accountPrefix = "!linuxus1!" @@ -45,7 +46,7 @@ func ParseAccount(value string) (Account, error) { func IsLocked(value string) bool { account, err := ParseAccount(value) - return err != nil || account.Locked + return err != nil || account.Locked || account.Maintenance } func CheckPassword(value, password string) error { @@ -54,7 +55,7 @@ func CheckPassword(value, password string) error { return err } err = bcrypt.CompareHashAndPassword([]byte(account.Hash), []byte(password)) - if account.Locked { + if account.Locked || account.Maintenance { return fmt.Errorf("account locked") } return err @@ -97,7 +98,17 @@ func UpdateAccount(path, id, operation, value string) error { if err != nil { return err } + if account.Maintenance && operation != "maintenance-end" && operation != "maintenance-failed" { + return fmt.Errorf("account is under disk maintenance; wait or use recover-user after the operation exits") + } switch operation { + case "maintenance": + account.Maintenance = true + case "maintenance-end": + account.Maintenance = false + case "maintenance-failed": + account.Maintenance = false + account.Locked = true case "lock": account.Locked = true case "unlock": diff --git a/src/internal/common/user/account_test.go b/src/internal/common/user/account_test.go new file mode 100644 index 0000000..276a3f2 --- /dev/null +++ b/src/internal/common/user/account_test.go @@ -0,0 +1,131 @@ +package user + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAccountOperationsPreserveDataAndRevokeGenerations(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + users := map[string]string{} + if err := AddUser(path, users, "alice", "initial-password"); err != nil { + t.Fatal(err) + } + before, _ := os.Stat(path) + previous := users["alice"] + for _, op := range []struct { + action, value string + locked bool + }{ + {"lock", "", true}, {"unlock", "", false}, {"password", "new-password", false}, {"template", "python", false}, {"class", "class-a", false}, {"disconnect", "", false}, + } { + if err := UpdateAccount(path, "alice", op.action, op.value); err != nil { + t.Fatal(err) + } + current, err := LoadUsers(path) + if err != nil { + t.Fatal(err) + } + if current["alice"] == previous { + t.Fatal("operation revived a prior session generation") + } + previous = current["alice"] + account, err := ParseAccount(previous) + if err != nil { + t.Fatal(err) + } + if account.Locked != op.locked { + t.Fatal("lock state lost") + } + after, _ := os.Stat(path) + if !os.SameFile(before, after) { + t.Fatal("account update replaced Docker bind inode") + } + } + if err := CheckPassword(previous, "new-password"); err != nil { + t.Fatal(err) + } + if err := CheckPassword(previous, "initial-password"); err == nil { + t.Fatal("old password still accepted") + } + account, _ := ParseAccount(previous) + if account.Class != "class-a" || account.Template != "" { + t.Fatal("class assignment did not clear direct template override") + } +} + +func TestAccountFailurePreservesOriginalAndComments(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + original := "# operator note\nalice:legacy\nbob:untouched\n" + os.WriteFile(path, []byte(original), 0600) + for _, op := range []string{"unknown", "password"} { + if err := UpdateAccount(path, "alice", op, ""); err == nil { + t.Fatal("accepted invalid update") + } + } + data, _ := os.ReadFile(path) + if string(data) != original { + t.Fatal("failed update changed data") + } + if err := UpdateAccount(path, "alice", "lock", ""); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(path) + if !strings.Contains(string(data), "# operator note\n") || !strings.Contains(string(data), "bob:untouched\n") { + t.Fatal("unrelated records lost") + } + if !IsLocked("!linuxus1!bad-record") { + t.Fatal("corrupt account was enabled") + } +} + +func TestMaintenanceCannotBeUnlockedOrOverlapped(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + os.WriteFile(path, []byte("alice:legacy\n"), 0600) + if err := UpdateAccount(path, "alice", "maintenance", ""); err != nil { + t.Fatal(err) + } + for _, action := range []string{"maintenance", "unlock", "password", "class", "template"} { + if err := UpdateAccount(path, "alice", action, "value"); err == nil { + t.Fatalf("%s bypassed disk maintenance", action) + } + } + if err := UpdateAccount(path, "alice", "maintenance-end", ""); err != nil { + t.Fatal(err) + } + users, _ := LoadUsers(path) + if IsLocked(users["alice"]) { + t.Fatal("successful maintenance changed an unlocked account") + } + UpdateAccount(path, "alice", "maintenance", "") + UpdateAccount(path, "alice", "maintenance-failed", "") + users, _ = LoadUsers(path) + record, _ := ParseAccount(users["alice"]) + if !record.Locked || record.Maintenance { + t.Fatal("failed maintenance did not leave recoverable lock") + } +} + +func TestRemovalCannotDiscardMaintenanceAccount(t *testing.T) { + path := filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:legacy\n"), 0600); err != nil { + t.Fatal(err) + } + stale := map[string]string{"alice": "legacy"} + if err := UpdateAccount(path, "alice", "maintenance", ""); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := RemoveUser(path, stale, "alice"); err == nil { + t.Fatal("removal bypassed active maintenance") + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(before) { + t.Fatal("failed removal changed the account record") + } +} diff --git a/src/internal/common/user/user.go b/src/internal/common/user/user.go index d1a55d4..76bfe08 100644 --- a/src/internal/common/user/user.go +++ b/src/internal/common/user/user.go @@ -57,6 +57,16 @@ func LoadUsers(path string) (map[string]string, error) { return readUsers(file) } +// TryLoadUsers avoids waiting indefinitely during read-only diagnostics. +func TryLoadUsers(path string) (map[string]string, error) { + file, err := openLocked(path, os.O_RDONLY, syscall.LOCK_SH|syscall.LOCK_NB) + if err != nil { + return nil, err + } + defer file.Close() + return readUsers(file) +} + func readUsers(reader io.Reader) (map[string]string, error) { users := make(map[string]string) scanner := bufio.NewScanner(reader) @@ -164,6 +174,14 @@ func RemoveUser(path string, users map[string]string, id string) error { return fmt.Errorf("user %q does not exist", id) } + account, err := ParseAccount(current[id]) + if err != nil { + return err + } + if account.Maintenance { + return fmt.Errorf("account is under disk maintenance; use recover-user before removing it") + } + var lines []string for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) diff --git a/src/internal/ctl/app/accounts.go b/src/internal/ctl/app/accounts.go index 44b4d3b..6876263 100644 --- a/src/internal/ctl/app/accounts.go +++ b/src/internal/ctl/app/accounts.go @@ -30,7 +30,7 @@ func (a *App) ListUsers(output io.Writer) error { if err != nil { return err } - fmt.Fprintf(output, "%s\t%t\t%s\t%s\n", id, record.Locked, record.Class, record.Template) + fmt.Fprintf(output, "%s\t%t\t%s\t%s\n", id, record.Locked || record.Maintenance, record.Class, record.Template) } return nil } @@ -48,6 +48,9 @@ func (a *App) ListTemplates(output io.Writer) error { sort.Strings(names) for _, name := range names { entry := templates[name] + if entry.Image == "default" { + entry.Image = a.userImageName() + } fmt.Fprintf(output, "%s\t%s\t%s\n", name, entry.Image, entry.Seed) } names = nil @@ -63,6 +66,9 @@ func (a *App) ListTemplates(output io.Writer) error { func (a *App) ServiceAccount(command string, params *cli.Parameters) error { id := params.Params["user"] + if command == "recover-user" { + return a.RecoverUser(id) + } actions := map[string]string{"lock-user": "lock", "unlock-user": "unlock", "reset-password": "password", "disconnect-user": "disconnect", "assign-template": "template", "assign-class": "class"} action := actions[command] value := "" diff --git a/src/internal/ctl/app/backup.go b/src/internal/ctl/app/backup.go index 0087ed8..b4641c2 100644 --- a/src/internal/ctl/app/backup.go +++ b/src/internal/ctl/app/backup.go @@ -7,16 +7,19 @@ import ( "os" "path/filepath" "strings" + "syscall" archive "github.com/elecbug/linuxus/src/internal/common/backup" "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/convert" + "github.com/elecbug/linuxus/src/internal/common/ruleset" "github.com/elecbug/linuxus/src/internal/common/system_api" "github.com/elecbug/linuxus/src/internal/common/user" "github.com/elecbug/linuxus/src/internal/ctl/log" ) func VerifyBackup(path string, output io.Writer) error { - file, err := os.Open(path) + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return err } @@ -62,41 +65,185 @@ func (a *App) outsideManagedStorage(path string) error { // Maintenance locks the account before crossing Manager's runtime lock, then // takes the disk lock. Failure leaves the account locked for safe manual repair. -func (a *App) maintainUser(id string, operation func() error) error { +func (a *App) withMaintenanceLock(action func() error) error { if os.Geteuid() != 0 { - return fmt.Errorf("disk backup/restore requires root") + return fmt.Errorf("disk maintenance requires root") + } + if err := os.MkdirAll(a.diskServiceDir(), 0700); err != nil { + return err } - users, err := user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + lock, err := os.OpenFile(filepath.Join(a.diskServiceDir(), "maintenance.lock"), os.O_CREATE|os.O_RDWR, 0600) if err != nil { return err } - record, ok := users[id] - if !ok { - return fmt.Errorf("user does not exist") + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return fmt.Errorf("another backup/restore is active: %w", err) } - wasLocked := user.IsLocked(record) - if err := user.UpdateAccount(a.Config.AuthService.Mounts.HostAuthListPath, id, "lock", ""); err != nil { + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + return action() +} + +func (a *App) maintainUser(id string, operation func() error) error { + return a.withMaintenanceLock(func() error { return a.maintainUserLocked(id, operation) }) +} + +// The caller holds the maintenance lock, including any restore verification. +func (a *App) maintainUserLocked(id string, operation func() error) (retErr error) { + if err := a.checkPendingRestore(id); err != nil { return err } + path := a.Config.AuthService.Mounts.HostAuthListPath + if err := user.UpdateAccount(path, id, "maintenance", ""); err != nil { + return err + } + defer func() { + if retErr == nil { + retErr = user.UpdateAccount(path, id, "maintenance-end", "") + } + if retErr != nil { + retErr = fmt.Errorf("account %s remains under maintenance; inspect retained data and run recover-user before unlocking: %w", id, retErr) + } + }() if err := a.stopUserRuntime(id); err != nil { - return fmt.Errorf("account remains locked; stop runtime: %w", err) + return err } - err = a.withDiskLock(func() error { + if err := a.withDiskLock(func() error { if err := a.detachUserImage(id); err != nil { return err } return operation() + }); err != nil { + return err + } + log.Log(log.DETAIL_PREFIX, "Maintenance complete for %s. Runtime is stopped; log in again to resume.", id) + return nil +} + +// RecoverUser restores an interrupted replacement before clearing maintenance. +// Both host locks exclude active maintenance and disk preparation. Access stays locked. +func (a *App) RecoverUser(id string) error { + if !ruleset.AllowedUserID(id) { + return fmt.Errorf("invalid user ID") + } + return a.withMaintenanceLock(func() error { + path := a.Config.AuthService.Mounts.HostAuthListPath + users, err := user.LoadUsers(path) + if err != nil { + return err + } + record, exists := users[id] + if !exists { + return fmt.Errorf("user %q does not exist", id) + } + account, err := user.ParseAccount(record) + if err != nil { + return err + } + if !account.Maintenance { + if !account.Locked { + return fmt.Errorf("account is not under maintenance or locked") + } + if err := user.UpdateAccount(path, id, "maintenance", ""); err != nil { + return err + } + } + if err := a.stopUserRuntime(id); err != nil { + return err + } + return a.withDiskLock(func() error { + if err := a.recoverUserImage(id); err != nil { + return err + } + return user.UpdateAccount(path, id, "maintenance-failed", "") + }) }) +} + +func (a *App) previousUserImage(id string) string { + return filepath.Join(a.Config.Volumes.Host.Homes, ".restore-"+id+".previous") +} + +func (a *App) checkPendingRestore(id string) error { + if _, err := os.Lstat(a.previousUserImage(id)); err == nil { + return fmt.Errorf("user %s has an interrupted restore; run recover-user first", id) + } else if !os.IsNotExist(err) { + return err + } + return nil +} + +func requireRegularImage(path string) error { + info, err := os.Lstat(path) if err != nil { - return fmt.Errorf("account %s remains locked; data retained for recovery: %w", id, err) + return err } - if !wasLocked { - if err := user.UpdateAccount(a.Config.AuthService.Mounts.HostAuthListPath, id, "unlock", ""); err != nil { + if !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("image %s must be a nonempty regular file", path) + } + return nil +} + +func syncImageDirectory(path string) error { + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +// Recover only existing images; never initialize missing data during recovery. +func (a *App) recoverUserImage(id string) error { + imagePath := filepath.Join(a.Config.Volumes.Host.Homes, id+".img") + previous := a.previousUserImage(id) + hasPrevious := false + if _, err := os.Lstat(previous); err == nil { + if err := requireRegularImage(previous); err != nil { return err } + hasPrevious = true + } else if !os.IsNotExist(err) { + return err } - log.Log(log.DETAIL_PREFIX, "Maintenance complete for %s. Runtime is stopped; log in again to resume.", id) - return nil + if !hasPrevious { + if err := requireRegularImage(imagePath); err != nil { + return fmt.Errorf("cannot recover missing or invalid home image: %w", err) + } + } + if err := a.detachUserImage(id); err != nil { + return err + } + if hasPrevious { + if err := a.restorePreviousImage(id); err != nil { + return err + } + } + return a.createUserDisk(id, id == a.Config.ManagerService.AdminID) +} + +func (a *App) restorePreviousImage(id string) error { + previous := a.previousUserImage(id) + image := filepath.Join(a.Config.Volumes.Host.Homes, id+".img") + // Rename is a no-op for two hard links to the same inode. Remove the extra + // name explicitly when interruption happened before the replacement. + old, err := os.Stat(previous) + if err != nil { + return err + } + current, err := os.Lstat(image) + if err != nil && !os.IsNotExist(err) { + return err + } + if err == nil && os.SameFile(old, current) { + err = os.Remove(previous) + } else { + err = os.Rename(previous, image) + } + if err != nil { + return fmt.Errorf("previous image retained at %s: %w", previous, err) + } + return syncImageDirectory(image) } func (a *App) detachUserImage(id string) error { @@ -117,6 +264,9 @@ func (a *App) detachUserImage(id string) error { } func (a *App) BackupUser(id, output string) error { + if !ruleset.AllowedUserID(id) { + return fmt.Errorf("invalid user ID") + } output, err := filepath.Abs(output) if err != nil { return err @@ -136,7 +286,7 @@ func (a *App) BackupUser(id, output string) error { defer os.Remove(temp.Name()) defer temp.Close() return a.maintainUser(id, func() error { - image, err := os.Open(filepath.Join(a.Config.Volumes.Host.Homes, id+".img")) + image, err := os.OpenFile(filepath.Join(a.Config.Volumes.Host.Homes, id+".img"), os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return err } @@ -159,6 +309,13 @@ func (a *App) BackupUser(id, output string) error { } func (a *App) RestoreUser(id, input string, replace bool) error { + return a.withMaintenanceLock(func() error { return a.restoreUserLocked(id, input, replace) }) +} + +func (a *App) restoreUserLocked(id, input string, replace bool) error { + if !ruleset.AllowedUserID(id) { + return fmt.Errorf("invalid user ID") + } if err := a.outsideManagedStorage(input); err != nil { return err } @@ -168,7 +325,7 @@ func (a *App) RestoreUser(id, input string, replace bool) error { } else if err != nil && !os.IsNotExist(err) { return err } - file, err := os.Open(input) + file, err := os.OpenFile(input, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return err } @@ -196,7 +353,17 @@ func (a *App) RestoreUser(id, input string, replace bool) error { } defer os.Remove(temp.Name()) defer temp.Close() - manifest, err := archive.Read(file, temp, int64(available)) + reserve := int64(0) + if a.Config.Capacity.MinFreeSpace != "" { + reserve, err = convert.BytesFromString(a.Config.Capacity.MinFreeSpace) + if err != nil { + return err + } + } + if available <= uint64(reserve) { + return fmt.Errorf("insufficient restore space above configured reserve") + } + manifest, err := archive.Read(file, temp, int64(available)-reserve) if err != nil { return err } @@ -206,41 +373,64 @@ func (a *App) RestoreUser(id, input string, replace bool) error { if err := temp.Close(); err != nil { return err } - return a.maintainUser(id, func() error { - previous := temp.Name() + ".previous" - hasPrevious := false - if _, err := os.Lstat(imagePath); err == nil { - if !replace { - return fmt.Errorf("image appeared during verification; use --replace") - } - if err := os.Rename(imagePath, previous); err != nil { - return err - } - hasPrevious = true - } else if !os.IsNotExist(err) { + return a.maintainUserLocked(id, func() error { return a.restorePreparedImage(id, temp.Name(), replace) }) +} + +// The caller holds maintenance and disk locks and has detached the old image. +func (a *App) restorePreparedImage(id, prepared string, replace bool) error { + imagePath := filepath.Join(a.Config.Volumes.Host.Homes, id+".img") + previous := a.previousUserImage(id) + if err := a.checkPendingRestore(id); err != nil { + return err + } + if err := requireRegularImage(prepared); err != nil { + return err + } + hasPrevious := false + if _, err := os.Lstat(imagePath); err == nil { + if !replace { + return fmt.Errorf("image appeared during verification; use --replace") + } + if err := requireRegularImage(imagePath); err != nil { return err } - if err := os.Rename(temp.Name(), imagePath); err != nil { - if hasPrevious { - err = errors.Join(err, os.Rename(previous, imagePath)) - } + // Preserve the old inode before atomically replacing its public name. + if err := os.Link(imagePath, previous); err != nil { return err } - if err := a.createUserDisk(id, id == a.Config.ManagerService.AdminID); err != nil { - if cleanupErr := a.detachUserImage(id); cleanupErr != nil { - return errors.Join(err, fmt.Errorf("retain previous image at %s: %w", previous, cleanupErr)) - } - if hasPrevious { - if restoreErr := os.Rename(previous, imagePath); restoreErr != nil { - return errors.Join(err, fmt.Errorf("previous image retained at %s: %w", previous, restoreErr)) - } - return errors.Join(err, a.createUserDisk(id, id == a.Config.ManagerService.AdminID)) - } + hasPrevious = true + if err := syncImageDirectory(previous); err != nil { return err } + } else if !os.IsNotExist(err) { + return err + } + if err := os.Rename(prepared, imagePath); err != nil { if hasPrevious { - return os.Remove(previous) + err = errors.Join(err, a.restorePreviousImage(id)) } - return nil - }) + return err + } + if err := syncImageDirectory(imagePath); err != nil { + return err + } + if err := a.createUserDisk(id, id == a.Config.ManagerService.AdminID); err != nil { + if cleanupErr := a.detachUserImage(id); cleanupErr != nil { + return errors.Join(err, fmt.Errorf("retain previous image at %s: %w", previous, cleanupErr)) + } + if hasPrevious { + if restoreErr := a.restorePreviousImage(id); restoreErr != nil { + return errors.Join(err, fmt.Errorf("previous image retained at %s: %w", previous, restoreErr)) + } + return errors.Join(err, a.createUserDisk(id, id == a.Config.ManagerService.AdminID)) + } + return err + } + if hasPrevious { + if err := os.Remove(previous); err != nil { + return err + } + return syncImageDirectory(imagePath) + } + return nil } diff --git a/src/internal/ctl/app/backup_test.go b/src/internal/ctl/app/backup_test.go new file mode 100644 index 0000000..df44bc8 --- /dev/null +++ b/src/internal/ctl/app/backup_test.go @@ -0,0 +1,162 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +type restoreAPI struct { + recordingDiskAPI + failOnce bool +} + +func (f *restoreAPI) Mount(source, target string) error { + if f.failOnce { + f.failOnce = false + return errors.New("injected mount failure") + } + return f.recordingDiskAPI.Mount(source, target) +} +func (f *restoreAPI) MountPointsUnder(string) ([]string, error) { return nil, nil } +func (f *restoreAPI) FindLoopDevicesForImages(string) ([]string, error) { return nil, nil } + +func TestRestorePreservesOriginalOnMountFailure(t *testing.T) { + for _, mode := range []string{"success", "mount-failure", "no-replace"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + image := filepath.Join(root, "alice.img") + prepared := filepath.Join(root, ".restore-new") + os.WriteFile(image, []byte("original student data"), 0600) + os.WriteFile(prepared, []byte("restored student data"), 0600) + api := &restoreAPI{recordingDiskAPI: recordingDiskAPI{images: map[string]int64{image: 8 << 20}, formatted: map[string]int{}, mounted: map[string]bool{}}, failOnce: mode == "mount-failure"} + a := &App{systemAPI: api} + a.Config.Volumes.Host.Homes = root + a.Config.UserService.Limits.User.Disk = "8M" + err := a.restorePreparedImage("alice", prepared, mode != "no-replace") + if (err == nil) != (mode == "success") { + t.Fatalf("mode=%s error=%v", mode, err) + } + data, _ := os.ReadFile(image) + want := "original student data" + if mode == "success" { + want = "restored student data" + } + if string(data) != want { + t.Fatalf("data lost: %q", data) + } + if len(api.formatted) != 0 { + t.Fatal("restore formatted an existing image") + } + if _, err := os.Stat(a.previousUserImage("alice")); !os.IsNotExist(err) { + t.Fatal("successful recovery left an unexpected previous image") + } + }) + } +} + +func recoveryFixture(t *testing.T) (*App, *restoreAPI, string) { + t.Helper() + root := t.TempDir() + image := filepath.Join(root, "alice.img") + api := &restoreAPI{recordingDiskAPI: recordingDiskAPI{images: map[string]int64{image: 8 << 20}, formatted: map[string]int{}, mounted: map[string]bool{}}} + a := &App{systemAPI: api} + a.Config.Volumes.Host.Homes = root + a.Config.UserService.Limits.User.Disk = "8M" + return a, api, image +} + +func writeRecoveryFile(t *testing.T, path, data string) { + t.Helper() + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } +} + +func TestRecoverInterruptedImageReplacement(t *testing.T) { + for _, stage := range []string{"before-replacement", "after-replacement", "missing-current", "after-commit"} { + t.Run(stage, func(t *testing.T) { + a, api, image := recoveryFixture(t) + writeRecoveryFile(t, image, "original data") + if stage != "after-commit" { + if err := os.Link(image, a.previousUserImage("alice")); err != nil { + t.Fatal(err) + } + } + if stage == "missing-current" { + if err := os.Remove(image); err != nil { + t.Fatal(err) + } + } else if stage == "after-replacement" || stage == "after-commit" { + prepared := filepath.Join(a.Config.Volumes.Host.Homes, ".restore-new") + writeRecoveryFile(t, prepared, "replacement data") + if err := os.Rename(prepared, image); err != nil { + t.Fatal(err) + } + } + if err := a.recoverUserImage("alice"); err != nil { + t.Fatal(err) + } + want := "original data" + if stage == "after-commit" { + want = "replacement data" + } + data, err := os.ReadFile(image) + if err != nil || string(data) != want { + t.Fatalf("recovery lost data: %q, %v", data, err) + } + if _, err := os.Lstat(a.previousUserImage("alice")); !os.IsNotExist(err) { + t.Fatal("recovery left an obsolete original link") + } + if len(api.formatted) != 0 || !api.mounted[filepath.Join(a.Config.Volumes.Host.Homes, "alice")] { + t.Fatal("recovery formatted data or failed to mount the recovered image") + } + }) + } +} + +func TestRecoveryNeverCreatesMissingDataOrFollowsSymlinks(t *testing.T) { + for _, mode := range []string{"missing", "empty", "symlink", "invalid-previous"} { + t.Run(mode, func(t *testing.T) { + a, api, image := recoveryFixture(t) + delete(api.images, image) + switch mode { + case "empty": + writeRecoveryFile(t, image, "") + case "symlink": + other := filepath.Join(a.Config.Volumes.Host.Homes, "other.img") + writeRecoveryFile(t, other, "unrelated data") + if err := os.Symlink(other, image); err != nil { + t.Fatal(err) + } + case "invalid-previous": + writeRecoveryFile(t, image, "current data") + if err := os.Symlink(image, a.previousUserImage("alice")); err != nil { + t.Fatal(err) + } + } + if err := a.recoverUserImage("alice"); err == nil { + t.Fatal("invalid recovery source was accepted") + } + if len(api.images) != 0 || len(api.formatted) != 0 || len(api.mounted) != 0 { + t.Fatal("invalid recovery changed disks") + } + }) + } +} + +func TestRestoreRefusesToOverwritePendingRecovery(t *testing.T) { + a, api, image := recoveryFixture(t) + writeRecoveryFile(t, image, "current data") + writeRecoveryFile(t, a.previousUserImage("alice"), "retained original data") + prepared := filepath.Join(a.Config.Volumes.Host.Homes, ".restore-new") + writeRecoveryFile(t, prepared, "another replacement") + if err := a.restorePreparedImage("alice", prepared, true); err == nil { + t.Fatal("new restore overwrote pending recovery") + } + data, err := os.ReadFile(a.previousUserImage("alice")) + if err != nil || string(data) != "retained original data" || len(api.mounted) != 0 { + t.Fatal("pending recovery data changed") + } +} diff --git a/src/internal/ctl/app/diagnostics.go b/src/internal/ctl/app/diagnostics.go index de71001..3ba193d 100644 --- a/src/internal/ctl/app/diagnostics.go +++ b/src/internal/ctl/app/diagnostics.go @@ -46,6 +46,7 @@ type doctorChecks struct { lookPath func(string) (string, error) docker func(context.Context) error disk func(context.Context, string) error + runtime func(*App, func(string, string, string)) } // Doctor reports independent prerequisite failures without changing host state. @@ -53,6 +54,7 @@ func Doctor(configFile string, output io.Writer) error { return runDoctor(configFile, output, doctorChecks{ goos: runtime.GOOS, euid: os.Geteuid(), lookPath: exec.LookPath, docker: probeDocker, disk: probeDiskService, + runtime: func(a *App, report func(string, string, string)) { a.diagnoseRuntime(report) }, }) } @@ -144,6 +146,9 @@ func runDoctor(configFile string, output io.Writer, checks doctorChecks) error { report("OK", "auto-ensure", "disabled; new users require ensure-disk") } } + if a != nil && checks.runtime != nil { + checks.runtime(a, report) + } if writeErr != nil { return writeErr } diff --git a/src/internal/ctl/app/disk.go b/src/internal/ctl/app/disk.go index a4b6030..442af87 100644 --- a/src/internal/ctl/app/disk.go +++ b/src/internal/ctl/app/disk.go @@ -3,6 +3,7 @@ package app import ( "errors" "fmt" + "os" "path/filepath" "sort" "strings" @@ -15,6 +16,9 @@ import ( // cleanVolumesAll unmounts and removes all user and shared disks after confirming with the user. func (a *App) cleanVolumesAll() error { + if err := a.checkSupervisor(); err != nil { + return err + } yes, err := log.Input("Are you sure you want to clean volumes for ALL users? This action cannot be undone. (yes/no): ") if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) @@ -25,6 +29,9 @@ func (a *App) cleanVolumesAll() error { return nil } + if err := a.checkMaintenanceAccounts(""); err != nil { + return err + } log.Log(log.RUN_PREFIX, "Cleaning volumes for all users...") log.Log(log.INFO_PREFIX, "Stopping and removing all managed containers and networks...") @@ -117,7 +124,46 @@ func (a *App) cleanVolumeUser(userID string) error { if !ruleset.AllowedUserID(userID) { return fmt.Errorf("invalid user ID: %q", userID) } - return a.withDiskLock(func() error { return a.cleanVolumeUserUnlocked(userID) }) + return a.withDiskLock(func() error { + if err := a.checkMaintenanceAccounts(userID); err != nil { + return err + } + return a.cleanVolumeUserUnlocked(userID) + }) +} + +// Destructive cleanup must not discard an abandoned maintenance operation. +// The service caller also holds the maintenance lock against new operations. +func (a *App) checkMaintenanceAccounts(id string) error { + users, err := user.LoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + if err != nil && !os.IsNotExist(err) { + return err + } + for userID, record := range users { + if id != "" && id != userID { + continue + } + account, err := user.ParseAccount(record) + if err != nil { + return err + } + if account.Maintenance { + return fmt.Errorf("user %s is under disk maintenance; use recover-user before cleanup", userID) + } + } + if id != "" { + return a.checkPendingRestore(id) + } + entries, err := os.ReadDir(a.Config.Volumes.Host.Homes) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".restore-") && strings.HasSuffix(entry.Name(), ".previous") { + return fmt.Errorf("retained restore image %s requires recovery before cleanup", entry.Name()) + } + } + return nil } func (a *App) cleanVolumeUserUnlocked(userID string) error { @@ -158,7 +204,12 @@ func (a *App) cleanVolumeUserUnlocked(userID string) error { // ensureDiskAll creates and mounts disks for all users and shared volumes. func (a *App) ensureDiskAll() error { - return a.withDiskLock(a.ensureDiskAllUnlocked) + return a.withDiskLock(func() error { + if err := user.SyncUsers(a.UserIDs, a.Config.AuthService.Mounts.HostAuthListPath); err != nil { + return err + } + return a.ensureDiskAllUnlocked() + }) } func (a *App) ensureDiskAllUnlocked() error { @@ -173,7 +224,18 @@ func (a *App) ensureDiskAllUnlocked() error { return err } - for userID := range a.UserIDs { + for userID, record := range a.UserIDs { + account, err := user.ParseAccount(record) + if err != nil { + return fmt.Errorf("invalid account %s: %w", userID, err) + } + if account.Maintenance { + log.Log(log.INFO_PREFIX, "Skipping %s: disk maintenance requires recovery.", userID) + continue + } + if err := a.checkPendingRestore(userID); err != nil { + return err + } if err := a.createUserDisk(userID, a.Config.ManagerService.AdminID == userID); err != nil { return err } @@ -187,7 +249,12 @@ func (a *App) ensureDiskUser(userID string) error { if !ruleset.AllowedUserID(userID) { return fmt.Errorf("invalid user ID") } - return a.withDiskLock(func() error { return a.ensureDiskUserUnlocked(userID) }) + return a.withDiskLock(func() error { + if err := user.SyncUsers(a.UserIDs, a.Config.AuthService.Mounts.HostAuthListPath); err != nil { + return err + } + return a.ensureDiskUserUnlocked(userID) + }) } func (a *App) ensureDiskUserUnlocked(userID string) error { @@ -195,6 +262,17 @@ func (a *App) ensureDiskUserUnlocked(userID string) error { return fmt.Errorf("user ID not found in auth list: %s", userID) } + account, err := user.ParseAccount(a.UserIDs[userID]) + if err != nil { + return err + } + if account.Maintenance { + return fmt.Errorf("account is under disk maintenance; use recover-user first") + } + if err := a.checkPendingRestore(userID); err != nil { + return err + } + if err := a.systemAPI.MkdirAll(a.Config.Volumes.Host.Homes, 0755); err != nil { return err } diff --git a/src/internal/ctl/app/disk_diagnostics.go b/src/internal/ctl/app/disk_diagnostics.go index 42d44ba..5893951 100644 --- a/src/internal/ctl/app/disk_diagnostics.go +++ b/src/internal/ctl/app/disk_diagnostics.go @@ -50,7 +50,7 @@ func (a *App) registerDiskDiagnostics(mux *http.ServeMux) { path := filepath.Join(a.Config.Volumes.Host.Homes, id) mounted, err := a.systemAPI.IsMountPoint(path) if err != nil { - rows[id] = map[string]any{"error": err.Error()} + rows[id] = map[string]any{"disk_error": err.Error()} continue } row := map[string]any{"mounted": mounted} @@ -60,6 +60,8 @@ func (a *App) registerDiskDiagnostics(mux *http.ServeMux) { row["total_bytes"] = total row["available_bytes"] = free row["used_bytes"] = total - free + } else { + row["disk_error"] = err.Error() } } rows[id] = row diff --git a/src/internal/ctl/app/disk_service_test.go b/src/internal/ctl/app/disk_service_test.go index b2928fd..ef6c89e 100644 --- a/src/internal/ctl/app/disk_service_test.go +++ b/src/internal/ctl/app/disk_service_test.go @@ -18,6 +18,7 @@ import ( "github.com/elecbug/linuxus/src/internal/common/diskservice" "github.com/elecbug/linuxus/src/internal/common/packet" "github.com/elecbug/linuxus/src/internal/common/system_api" + "github.com/elecbug/linuxus/src/internal/common/user" ) type mountedDiskAPI struct { @@ -391,3 +392,58 @@ func TestServiceDownStopsDiskServiceWhenDockerFails(t *testing.T) { t.Fatal("Docker failure left the disk service running") } } + +func TestCapacityReserveRejectsWithoutDiskMutation(t *testing.T) { + root := t.TempDir() + api := &recordingDiskAPI{images: map[string]int64{}, formatted: map[string]int{}, mounted: map[string]bool{}} + a := &App{systemAPI: api} + a.Config.Volumes.Host.Homes = root + a.Config.Volumes.Host.Share = filepath.Join(root, "share") + a.Config.Volumes.Host.Readonly = filepath.Join(root, "readonly") + a.Config.Capacity.MinFreeSpace = "9000000000000000000" + w := httptest.NewRecorder() + a.diskServiceHandler(func() {}).ServeHTTP(w, httptest.NewRequest("GET", "/capacity", nil)) + if w.Code != 503 { + t.Fatalf("reserve not enforced: %d %s", w.Code, w.Body.String()) + } + if err := a.createFormattedImage(filepath.Join(root, "new.img"), 8<<20); err == nil { + t.Fatal("image created below reserve") + } + if len(api.images) != 0 { + t.Fatal("disk mutated before reserve check") + } +} + +func TestEnsureDiskReloadsAndSkipsMaintenanceAccounts(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "AUTH_LIST") + if err := os.WriteFile(path, []byte("alice:legacy\nbob:legacy\n"), 0600); err != nil { + t.Fatal(err) + } + api := &recordingDiskAPI{images: map[string]int64{}, formatted: map[string]int{}, mounted: map[string]bool{}} + a := &App{systemAPI: api, UserIDs: map[string]string{"alice": "legacy", "bob": "legacy"}} + a.Config.AuthService.Mounts.HostAuthListPath = path + a.Config.Volumes.Host.Homes = filepath.Join(root, "homes") + a.Config.Volumes.Host.Share = filepath.Join(root, "share") + a.Config.Volumes.Host.Readonly = filepath.Join(root, "readonly") + a.Config.Volumes.DiskLimit = "8M" + a.Config.UserService.Limits.User.Disk = "8M" + if err := user.UpdateAccount(path, "alice", "maintenance", ""); err != nil { + t.Fatal(err) + } + if err := a.ensureDiskAll(); err != nil { + t.Fatal(err) + } + if _, exists := api.images[filepath.Join(a.Config.Volumes.Host.Homes, "alice.img")]; exists { + t.Fatal("startup initialized missing data during interrupted maintenance") + } + if !api.mounted[filepath.Join(a.Config.Volumes.Host.Homes, "bob")] { + t.Fatal("maintenance prevented another user's disk preparation") + } + if err := a.ensureDiskUser("alice"); err == nil { + t.Fatal("manual disk preparation bypassed maintenance") + } + if err := a.cleanVolumeUser("alice"); err == nil { + t.Fatal("cleanup discarded abandoned maintenance") + } +} diff --git a/src/internal/ctl/app/docker_build.go b/src/internal/ctl/app/docker_build.go index 3f99f2c..d60cdaf 100644 --- a/src/internal/ctl/app/docker_build.go +++ b/src/internal/ctl/app/docker_build.go @@ -5,6 +5,7 @@ import ( "bytes" "fmt" "io" + "io/fs" "os" "runtime" "strconv" @@ -92,6 +93,21 @@ func tarRuntimeContext(dockerfile, executable string) (io.Reader, error) { if err := add("start.sh", 0755, script); err != nil { return nil, err } + if err := fs.WalkDir(assets.Files, "templates", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + data, err := assets.Files.ReadFile(path) + if err != nil { + return err + } + return add(path, 0644, data) + }); err != nil { + return nil, err + } } else { binary, err := os.Open(executable) if err != nil { diff --git a/src/internal/ctl/app/doctor_runtime.go b/src/internal/ctl/app/doctor_runtime.go new file mode 100644 index 0000000..93e7e44 --- /dev/null +++ b/src/internal/ctl/app/doctor_runtime.go @@ -0,0 +1,87 @@ +package app + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/client" + "github.com/elecbug/linuxus/src/internal/common/system_api" + "github.com/elecbug/linuxus/src/internal/common/user" +) + +func (a *App) diagnoseRuntime(report func(string, string, string)) { + for _, entry := range []struct{ name, path string }{ + {"account storage", filepath.Dir(a.Config.AuthService.Mounts.HostAuthListPath)}, + {"home image storage", a.Config.Volumes.Host.Homes}, + {"shared image storage", filepath.Dir(a.Config.Volumes.Host.Share)}, + {"readonly image storage", filepath.Dir(a.Config.Volumes.Host.Readonly)}, + } { + total, free, err := system_api.DiskSpace(entry.path) + if err != nil { + report("FAIL", entry.name, err.Error()) + continue + } + status := "OK" + if free == 0 { + status = "FAIL" + } + report(status, entry.name, fmt.Sprintf("%s: %.2f GiB free of %.2f GiB", entry.path, float64(free)/(1<<30), float64(total)/(1<<30))) + } + if err := a.checkFreeSpace(); err != nil { + report("FAIL", "storage reserve", err.Error()+"; free space or adjust CAPACITY_MIN_FREE_SPACE") + } + api := system_api.NewSystemAPI() + mounts := []string{a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly} + users, err := user.TryLoadUsers(a.Config.AuthService.Mounts.HostAuthListPath) + if err == nil { + for id := range users { + mounts = append(mounts, filepath.Join(a.Config.Volumes.Host.Homes, id)) + } + } else if !os.IsNotExist(err) { + report("FAIL", "account database", err.Error()+"; inspect AUTH_LIST or retry after account maintenance") + } + for _, path := range mounts { + mounted, err := api.IsMountPoint(path) + switch { + case err != nil: + report("FAIL", "disk mount", err.Error()) + case !mounted: + report("WARN", "disk mount", path+" is not mounted; run up or ensure-disk before access") + default: + report("OK", "disk mount", path) + } + } + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return + } + defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, name := range []string{a.Config.AuthService.Container.Name, a.Config.ManagerService.Container.Name} { + info, err := cli.ContainerInspect(ctx, name) + if err != nil { + report("FAIL", name, fmt.Sprintf("%v; start services with up (or systemctl start linuxus)", err)) + continue + } + if info.State == nil || !info.State.Running { + report("FAIL", name, "container is not running; inspect Docker logs and restart services") + continue + } + if info.State.Health != nil && info.State.Health.Status != "healthy" { + report("FAIL", name, "health status "+info.State.Health.Status+"; inspect Docker logs") + } else { + report("OK", name, "running") + } + } + ctxDisk, cancelDisk := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelDisk() + if err := probeDiskService(ctxDisk, a.diskSocket()); err != nil { + report("FAIL", "disk service", err.Error()+"; run up or restart linuxus.service") + } else { + report("OK", "disk service", "healthy") + } +} diff --git a/src/internal/ctl/app/runtime_test.go b/src/internal/ctl/app/runtime_test.go index c3ba93b..c57d648 100644 --- a/src/internal/ctl/app/runtime_test.go +++ b/src/internal/ctl/app/runtime_test.go @@ -110,8 +110,17 @@ func TestRuntimeBuildContextsContainOnlyDeploymentAssets(t *testing.T) { t.Fatal("binary is not executable") } } - if len(entries) != 2 { - t.Fatalf("unexpected context: %v", entries) + count := 2 + if name == "user" { + count = 5 + for _, seed := range []string{"templates/linux/README.txt", "templates/c/hello.c", "templates/python/hello.py"} { + if len(entries[seed]) == 0 { + t.Fatalf("missing classroom seed %s", seed) + } + } + } + if len(entries) != count { + t.Fatalf("unexpected context entry count: %d", len(entries)) } if bytes.Contains(entries["Dockerfile"], []byte("go build")) { t.Fatal("runtime still builds source") @@ -136,10 +145,9 @@ func TestAutoEnsureManagerSpec(t *testing.T) { if err != nil { t.Fatal(err) } - want := []string{"/var/run/docker.sock:/var/run/docker.sock:rw"} - if enabled { - want = append(want, a.diskServiceDir()+":"+diskservice.ContainerDir+":ro") - } + want := []string{"/var/run/docker.sock:/var/run/docker.sock:rw", + a.diskServiceDir() + ":" + diskservice.ContainerDir + ":ro", + a.Config.AuthService.Mounts.HostAuthListPath + ":/run/linuxus-accounts:ro"} if !reflect.DeepEqual(spec.Volumes, want) || spec.Privileged { t.Fatalf("invalid manager spec: %+v", spec) } diff --git a/src/internal/ctl/app/service.go b/src/internal/ctl/app/service.go index 8c8f571..73d8447 100644 --- a/src/internal/ctl/app/service.go +++ b/src/internal/ctl/app/service.go @@ -138,6 +138,10 @@ func (a *App) ServicePS(params *cli.Parameters) error { // ServiceCleanVolume unmounts and removes managed volume data and loop devices. func (a *App) ServiceCleanVolume(params *cli.Parameters) error { + return a.withMaintenanceLock(func() error { return a.cleanVolumeServiceLocked(params) }) +} + +func (a *App) cleanVolumeServiceLocked(params *cli.Parameters) error { if len(params.Params) > 1 || (len(params.Params) == 1 && params.MainParam != "") { return fmt.Errorf("too many parameters for volume clean option, please specify only one '--user ' or use '--all' to clean volumes for all users") } @@ -284,12 +288,19 @@ func (a *App) ServiceRemoveUser(params *cli.Parameters) error { return nil } - if err := user.RemoveUser(a.Config.AuthService.Mounts.HostAuthListPath, a.UserIDs, userID); err != nil { - return fmt.Errorf("failed to remove user: %w", err) - } - - if err := a.cleanVolumeUser(userID); err != nil { - return fmt.Errorf("failed to clean user volumes: %w", err) + if err := a.withMaintenanceLock(func() error { + if err := a.checkPendingRestore(userID); err != nil { + return err + } + if err := user.RemoveUser(a.Config.AuthService.Mounts.HostAuthListPath, a.UserIDs, userID); err != nil { + return fmt.Errorf("failed to remove user: %w", err) + } + if err := a.stopUserRuntime(userID); err != nil { + return fmt.Errorf("account removed; home data retained because runtime shutdown failed: %w", err) + } + return a.cleanVolumeUser(userID) + }); err != nil { + return err } log.Log(log.DETAIL_PREFIX, "User %s removed successfully.", userID) diff --git a/src/internal/ctl/app/supervisor.go b/src/internal/ctl/app/supervisor.go index fd72eec..70bd5f8 100644 --- a/src/internal/ctl/app/supervisor.go +++ b/src/internal/ctl/app/supervisor.go @@ -138,6 +138,9 @@ func (a *App) Supervise() error { probe, cancel := context.WithTimeout(ctx, 2*time.Second) err := probeDiskService(probe, a.diskSocket()) cancel() + if ctx.Err() != nil { + return nil + } if err != nil { if err := a.startDiskService(); err != nil { return fmt.Errorf("recover disk service: %w", err) @@ -153,11 +156,7 @@ func (a *App) Supervise() error { if err == nil && info.State != nil && info.State.Running { continue } - if name == a.Config.ManagerService.Container.Name { - err = a.ensureManagerContainer() - } else { - err = a.ensureAuthContainer() - } + err = a.recoverServiceContainer(ctx, name) if err != nil { return fmt.Errorf("recover %s: %w", name, err) } @@ -166,6 +165,18 @@ func (a *App) Supervise() error { } } +// Bound the complete recovery, including Docker create/start calls after inspect. +func (a *App) recoverServiceContainer(ctx context.Context, name string) error { + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + recovery := *a + recovery.context = ctx + if name == a.Config.ManagerService.Container.Name { + return recovery.ensureManagerContainer() + } + return recovery.ensureAuthContainer() +} + func (a *App) serviceRestartPolicy() string { if a.supervised { return "no" diff --git a/src/internal/ctl/app/supervisor_test.go b/src/internal/ctl/app/supervisor_test.go new file mode 100644 index 0000000..6d3c43e --- /dev/null +++ b/src/internal/ctl/app/supervisor_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/docker/docker/client" + "path/filepath" + "strings" + "testing" +) + +func TestSystemdUnitPinsPathsAndOwnsChildren(t *testing.T) { + var output bytes.Buffer + if err := WriteSystemdUnit("/opt/class %1/$ctl", "/etc/classroom settings/.env", &output); err != nil { + t.Fatal(err) + } + text := output.String() + for _, want := range []string{"Requires=docker.service", "After=docker.service local-fs.target", "KillMode=control-group", "Restart=on-failure", "class %%1/$$ctl", "LINUXUS_CONFIG=/etc/classroom settings/.env", "supervise"} { + if !strings.Contains(text, want) { + t.Errorf("unit missing %q", want) + } + } + if err := WriteSystemdUnit("/tmp/ctl\nExecStart=/bad", "/etc/.env", &bytes.Buffer{}); err == nil { + t.Fatal("accepted unit directive injection") + } + a := &App{supervised: true} + a.Config.AuthService.Mounts.HostAuthListPath = filepath.Join(t.TempDir(), "AUTH_LIST") + if a.serviceRestartPolicy() != "no" { + t.Fatal("Docker may start supervised services before mounts") + } +} + +type recoveryTransport func(*http.Request) (*http.Response, error) + +func (f recoveryTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestSupervisorRecoveryBoundsCreateAndHonorsCancellation(t *testing.T) { + for _, service := range []string{"auth", "manager"} { + t.Run(service, func(t *testing.T) { + entered := make(chan bool, 1) + transport := recoveryTransport(func(r *http.Request) (*http.Response, error) { + path := strings.TrimPrefix(r.URL.Path, "/v1.47") + if path == "/containers/create" { + deadline, ok := r.Context().Deadline() + entered <- ok && time.Until(deadline) > 0 && time.Until(deadline) <= time.Minute + <-r.Context().Done() + return nil, r.Context().Err() + } + if path != "/containers/json" { + return nil, fmt.Errorf("unexpected Docker API: %s", path) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`[]`)), Header: make(http.Header)}, nil + }) + cli, err := client.NewClientWithOpts(client.WithHost("tcp://127.0.0.1:2375"), client.WithVersion("1.47"), client.WithHTTPClient(&http.Client{Transport: transport})) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + a := &App{dockerClient: cli, context: context.Background()} + a.Config.AuthService.Container.Name = "auth" + a.Config.AuthService.Container.ExternalPort = 8080 + a.Config.ManagerService.Container.Name = "manager" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- a.recoverServiceContainer(ctx, service) }() + select { + case bounded := <-entered: + cancel() + if !bounded { + t.Fatal("Docker create did not receive a bounded recovery deadline") + } + case err := <-done: + t.Fatalf("recovery failed before create: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("recovery did not reach create") + } + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("recovery lost cancellation: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Docker recovery ignored supervisor shutdown") + } + if a.context.Err() != nil { + t.Fatal("recovery changed the supervisor's context") + } + }) + } +} diff --git a/src/internal/manager/handler/handle_session.go b/src/internal/manager/handler/handle_session.go index 2e43ea5..f2adbde 100644 --- a/src/internal/manager/handler/handle_session.go +++ b/src/internal/manager/handler/handle_session.go @@ -50,6 +50,11 @@ func (s *Server) HandleUserSessionState(w http.ResponseWriter, r *http.Request) req.ObservedAt = time.Now() } + if req.ObservedAt.After(time.Now().Add(time.Minute)) { + http.Error(w, "invalid observed_at", http.StatusBadRequest) + return + } + s.updateSessionState(req.UserID, req.ActiveSessions, req.ObservedAt) w.WriteHeader(http.StatusOK) @@ -59,12 +64,19 @@ func (s *Server) HandleUserSessionState(w http.ResponseWriter, r *http.Request) func (s *Server) updateSessionState(userID string, active int, observedAt time.Time) { s.mu.Lock() defer s.mu.Unlock() + if !s.lastSnapshotAt.IsZero() && !observedAt.After(s.lastSnapshotAt) { + return + } + s.updateSessionStateLocked(userID, active, observedAt) +} +// The caller holds mu for the whole update or complete snapshot. +func (s *Server) updateSessionStateLocked(userID string, active int, observedAt time.Time) { if s.runtimes == nil { s.runtimes = make(map[string]*RuntimeState) } - rt, ok := s.runtimes[userID] - if !ok { + rt := s.runtimes[userID] + if rt == nil { rt = &RuntimeState{ UserID: userID, } diff --git a/src/internal/manager/handler/handle_up_test.go b/src/internal/manager/handler/handle_up_test.go index 3b969b8..ae03f5c 100644 --- a/src/internal/manager/handler/handle_up_test.go +++ b/src/internal/manager/handler/handle_up_test.go @@ -120,8 +120,12 @@ func runtimeTestServer(t *testing.T, d *runtimeDocker, diskHandler http.HandlerF t.Cleanup(func() { httpServer.Close() }) go httpServer.Serve(listener) } + accountPath := filepath.Join(dir, "AUTH_LIST") + if err := os.WriteFile(accountPath, []byte("alice:hash\n"), 0600); err != nil { + t.Fatal(err) + } s := &Server{docker: cli, diskClient: diskservice.NewClient(socket, time.Second), cfg: &config.Config{ - AutoEnsure: true, UserImage: "test-user", UserContainerNamePrefix: "user_", NetworkPrefix: "net_", + AuthListFile: accountPath, AutoEnsure: true, UserImage: "test-user", UserContainerNamePrefix: "user_", NetworkPrefix: "net_", BaseIP: "10.10.0.0", AuthContainerName: "auth", AdminUserID: "admin", ManagerWaitTime: time.Second, HostHomesDir: "/volumes/homes", HostShareDir: "/volumes/share", HostReadonlyDir: "/volumes/readonly", ContainerRuntimeUser: "user", ContainerShareDir: "/home/share", ContainerReadonlyDir: "/home/readonly", diff --git a/src/internal/manager/handler/operations.go b/src/internal/manager/handler/operations.go index 1fd4e58..acc2a10 100644 --- a/src/internal/manager/handler/operations.go +++ b/src/internal/manager/handler/operations.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "encoding/json" "fmt" + "io" "net/http" "strings" "time" @@ -18,8 +19,8 @@ import ( func (s *Server) account(id string) (user.Account, error) { if s.cfg.AuthListFile == "" { - return user.Account{}, nil - } // Unit/in-process configurations. + return user.Account{}, fmt.Errorf("account database is not configured") + } users, err := user.LoadUsers(s.cfg.AuthListFile) if err != nil { return user.Account{}, fmt.Errorf("cannot read account database: %w", err) @@ -29,7 +30,7 @@ func (s *Server) account(id string) (user.Account, error) { return user.Account{}, fmt.Errorf("account does not exist") } account, err := user.ParseAccount(value) - if err != nil || account.Locked { + if err != nil || account.Locked || account.Maintenance { return user.Account{}, fmt.Errorf("account is locked or invalid") } return account, nil @@ -55,6 +56,9 @@ func (s *Server) userTemplate(id string) (string, commonconfig.Template, error) if !ok { return "", commonconfig.Template{}, fmt.Errorf("unknown environment template %s", name) } + if template.Image == "default" { + template.Image = s.cfg.UserImage + } return name, template, nil } @@ -173,12 +177,50 @@ func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { } } } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://disk-service/usage", nil) + diskErr := fmt.Errorf("disk service unavailable") + if s.diskClient != nil { + resp, err := s.diskClient.Do(req) + diskErr = err + if err == nil { + if resp.StatusCode != http.StatusOK { + diskErr = fmt.Errorf("disk service returned HTTP %d", resp.StatusCode) + } else { + var usage map[string]map[string]any + diskErr = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&usage) + if diskErr == nil && usage == nil { + diskErr = fmt.Errorf("invalid disk usage response") + } + if diskErr == nil { + for id, space := range usage { + if rows[id] == nil { + rows[id] = map[string]any{} + } + for key, value := range space { + rows[id][key] = value + } + } + } + } + resp.Body.Close() + } + } + if diskErr != nil { + w.Header().Set("X-Linuxus-Disk-Error", "Disk usage is unavailable; check the host disk service.") + for _, row := range rows { + row["disk_error"] = "Disk service unavailable" + } + } http_helper.WriteJSONViaHTTP(w, 200, rows) } // Reconcile discovers runtimes after Manager restarts and gives Auth a grace // period to replay its complete session snapshot before idle cleanup is enabled. func (s *Server) reconcileRuntimes(ctx context.Context) error { + if err := s.prepareMu.LockContext(ctx); err != nil { + return err + } + defer s.prepareMu.Unlock() containers, err := s.docker.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { return err diff --git a/src/internal/manager/handler/operations_test.go b/src/internal/manager/handler/operations_test.go new file mode 100644 index 0000000..120da61 --- /dev/null +++ b/src/internal/manager/handler/operations_test.go @@ -0,0 +1,172 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/docker/docker/client" + commonconfig "github.com/elecbug/linuxus/src/internal/common/config" + "github.com/elecbug/linuxus/src/internal/common/user" + "github.com/elecbug/linuxus/src/internal/manager/config" +) + +func TestLockedAccountCannotResumeExistingRuntime(t *testing.T) { + d := &runtimeDocker{exists: true, running: true} + s := runtimeTestServer(t, d, nil) + path := filepath.Join(t.TempDir(), "AUTH_LIST") + os.WriteFile(path, []byte("alice:hash\n"), 0600) + user.UpdateAccount(path, "alice", "lock", "") + s.cfg.AuthListFile = path + if _, err := s.ensureUserRuntimeReady(context.Background(), "alice"); err == nil { + t.Fatal("locked user resumed a running container") + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.mutations) != 0 { + t.Fatal("rejected user changed Docker") + } +} + +func TestPreparationQueueLimitAndCancellation(t *testing.T) { + s := &Server{cfg: &config.Config{MaxPending: 1}} + s.prepareMu.Lock() + defer s.prepareMu.Unlock() + s.pending.Store(1) + if _, err := s.ensureUserRuntimeReady(context.Background(), "alice"); err == nil || !strings.Contains(err.Error(), "queue") { + t.Fatal(err) + } + if s.pending.Load() != 1 { + t.Fatal("queue counter leaked") + } + s.pending.Store(0) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := s.ensureUserRuntimeReady(ctx, "alice"); err == nil { + t.Fatal("ignored cancellation") + } + if s.pending.Load() != 0 { + t.Fatal("cancelled request occupied queue") + } +} + +func TestSnapshotRepairsLostEndReport(t *testing.T) { + s := &Server{cfg: &config.Config{ManagerSessionSecret: "secret"}, runtimes: map[string]*RuntimeState{"alice": {UserID: "alice", ActiveSessions: 1, LastObservedAt: time.Now().Add(-time.Minute)}}} + r := httptest.NewRequest("POST", "/user/session-snapshot", strings.NewReader(`{"observed_at":"`+time.Now().UTC().Format(time.RFC3339Nano)+`","sessions":{}}`)) + r.Header.Set("X-Manager-Session-Secret", "secret") + w := httptest.NewRecorder() + s.HandleSessionSnapshot(w, r) + if w.Code != http.StatusOK || s.runtimes["alice"].ActiveSessions != 0 || s.runtimes["alice"].IdleSince.IsZero() { + t.Fatal("lost end report not repaired") + } +} + +func TestTemplateSelectionAndClassOverride(t *testing.T) { + d := &runtimeDocker{} + s := runtimeTestServer(t, d, nil) + path := filepath.Join(t.TempDir(), "AUTH_LIST") + os.WriteFile(path, []byte("alice:hash\n"), 0600) + s.cfg.AuthListFile = path + s.cfg.Templates = map[string]commonconfig.Template{"python": {Image: "classroom/python:v1", Seed: "/opt/python"}} + s.cfg.Classes = map[string]string{"class-a": "python"} + user.UpdateAccount(path, "alice", "class", "class-a") + name, profile, err := s.userTemplate("alice") + if err != nil || name != "python" || profile.Image != "classroom/python:v1" { + t.Fatal(name, profile, err) + } + user.UpdateAccount(path, "alice", "template", "default") + name, profile, err = s.userTemplate("alice") + if err != nil || name != "default" || profile.Image != "test-user" { + t.Fatal("direct default template ignored") + } +} + +func TestRunningLimitAndReconciliationUseDockerState(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"Id":"alice","Names":["/user_alice"],"State":"running"},{"Id":"other","Names":["/unrelated"],"State":"running"}]`)) + })) + defer server.Close() + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(server.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(server.Client())) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + s := &Server{docker: cli, cfg: &config.Config{UserContainerNamePrefix: "user_", MaxRunning: 1}} + if err := s.checkAdmission(context.Background()); err == nil { + t.Fatal("running limit ignored") + } + s.cfg.MaxRunning = 2 + if err := s.checkAdmission(context.Background()); err != nil { + t.Fatal("unrelated container counted", err) + } + if err := s.reconcileRuntimes(context.Background()); err != nil { + t.Fatal(err) + } + if len(s.runtimes) != 1 || time.Until(s.runtimes["alice"].IdleSince) < 80*time.Second { + t.Fatal("restart grace missing") + } + s.updateSessionState("alice", 1, time.Now()) + s.reconcileRuntimes(context.Background()) + if s.runtimes["alice"].ActiveSessions != 1 { + t.Fatal("discovery overwrote live session state") + } +} + +type statusTransport func(*http.Request) (*http.Response, error) + +func (f statusTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestAdminStatusReportsDiskServiceFailures(t *testing.T) { + for _, mode := range []string{"unavailable", "http-error", "bad-json", "null", "success"} { + t.Run(mode, func(t *testing.T) { + docker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"Id":"alice","Names":["/user_alice"],"State":"running"}]`)) + })) + defer docker.Close() + cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+strings.TrimPrefix(docker.URL, "http://")), client.WithVersion("1.47"), client.WithHTTPClient(docker.Client())) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + s := &Server{docker: cli, cfg: &config.Config{UserContainerNamePrefix: "user_", ManagerSessionSecret: "secret"}} + if mode != "unavailable" { + s.diskClient = &http.Client{Transport: statusTransport(func(r *http.Request) (*http.Response, error) { + status, body := 200, `{"alice":{"mounted":true,"used_bytes":123}}` + switch mode { + case "http-error": + status, body = 503, "busy" + case "bad-json": + body = "broken JSON" + case "null": + body = "null" + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + } + r := httptest.NewRequest(http.MethodGet, "/admin/status", nil) + r.Header.Set("X-Manager-Session-Secret", "secret") + w := httptest.NewRecorder() + s.HandleAdminStatus(w, r) + var rows map[string]map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil || w.Code != 200 { + t.Fatalf("status response failed: %d %s %v", w.Code, w.Body, err) + } + if rows["alice"]["state"] != "running" { + t.Fatal("disk failure discarded container status") + } + warned := w.Header().Get("X-Linuxus-Disk-Error") != "" + if warned != (mode != "success") || (rows["alice"]["disk_error"] != nil) != warned { + t.Fatalf("incorrect disk failure indication: %s %+v", w.Header(), rows) + } + }) + } +} diff --git a/src/internal/manager/handler/server.go b/src/internal/manager/handler/server.go index 31c9ffc..ceac8e8 100644 --- a/src/internal/manager/handler/server.go +++ b/src/internal/manager/handler/server.go @@ -37,6 +37,8 @@ type Server struct { mu sync.Mutex // runtimes tracks active user runtimes by sanitized user ID. runtimes map[string]*RuntimeState + // lastSnapshotAt rejects delayed reports even for users absent from the map. + lastSnapshotAt time.Time } // RuntimeState tracks observed session activity for one user runtime. diff --git a/src/internal/manager/handler/session_sync.go b/src/internal/manager/handler/session_sync.go index 06e4432..d3dd807 100644 --- a/src/internal/manager/handler/session_sync.go +++ b/src/internal/manager/handler/session_sync.go @@ -26,21 +26,26 @@ func (s *Server) HandleSessionSnapshot(w http.ResponseWriter, r *http.Request) { return } } + s.applySessionSnapshot(snapshot) + w.WriteHeader(200) +} + +// Apply the complete observation atomically, including the absence of users. +func (s *Server) applySessionSnapshot(snapshot packet.SessionSnapshot) { s.mu.Lock() - ids := make([]string, 0, len(s.runtimes)) - for id := range s.runtimes { - ids = append(ids, id) + defer s.mu.Unlock() + if !snapshot.ObservedAt.After(s.lastSnapshotAt) { + return } - s.mu.Unlock() + s.lastSnapshotAt = snapshot.ObservedAt for id, count := range snapshot.Sessions { - s.updateSessionState(id, count, snapshot.ObservedAt) + s.updateSessionStateLocked(id, count, snapshot.ObservedAt) } - for _, id := range ids { + for id := range s.runtimes { if _, ok := snapshot.Sessions[id]; !ok { - s.updateSessionState(id, 0, snapshot.ObservedAt) + s.updateSessionStateLocked(id, 0, snapshot.ObservedAt) } } - w.WriteHeader(200) } func (s *Server) startReconciliation(ctx context.Context) { diff --git a/src/internal/manager/handler/session_sync_test.go b/src/internal/manager/handler/session_sync_test.go new file mode 100644 index 0000000..74ad735 --- /dev/null +++ b/src/internal/manager/handler/session_sync_test.go @@ -0,0 +1,75 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/elecbug/linuxus/src/internal/common/packet" + "github.com/elecbug/linuxus/src/internal/manager/config" +) + +func TestSnapshotRejectsDelayedReportsForUnknownAndRemovedUsers(t *testing.T) { + now := time.Now() + s := &Server{} + s.applySessionSnapshot(packet.SessionSnapshot{ObservedAt: now, Sessions: map[string]int{}}) + for _, at := range []time.Time{now.Add(-time.Second), now} { + s.updateSessionState("alice", 1, at) + if len(s.runtimes) != 0 { + t.Fatal("delayed report resurrected an absent session") + } + } + s.updateSessionState("alice", 1, now.Add(time.Second)) + if s.runtimes["alice"].ActiveSessions != 1 { + t.Fatal("new session report was ignored") + } + s.applySessionSnapshot(packet.SessionSnapshot{ObservedAt: now.Add(2 * time.Second), Sessions: map[string]int{}}) + delete(s.runtimes, "alice") // Model completed idle cleanup. + s.applySessionSnapshot(packet.SessionSnapshot{ObservedAt: now, Sessions: map[string]int{"alice": 1}}) + if len(s.runtimes) != 0 { + t.Fatal("older full snapshot recreated a cleaned runtime") + } +} + +func TestConcurrentSnapshotsKeepNewestObservation(t *testing.T) { + s := &Server{} + now := time.Now() + var workers sync.WaitGroup + for i := 0; i < 100; i++ { + workers.Add(1) + go func(i int) { + defer workers.Done() + s.applySessionSnapshot(packet.SessionSnapshot{ObservedAt: now.Add(time.Duration(i) * time.Millisecond), Sessions: map[string]int{"alice": i, "bob": i}}) + }(i) + } + workers.Wait() + for _, id := range []string{"alice", "bob"} { + if s.runtimes[id].ActiveSessions != 99 { + t.Fatalf("%s retained a stale snapshot: %+v", id, s.runtimes[id]) + } + } + s.updateSessionState("alice", 101, now.Add(time.Second)) + s.applySessionSnapshot(packet.SessionSnapshot{ObservedAt: now.Add(500 * time.Millisecond), Sessions: map[string]int{}}) + if s.runtimes["alice"].ActiveSessions != 101 || s.runtimes["bob"].ActiveSessions != 0 { + t.Fatal("snapshot overwrote a newer individual report or missed an absent user") + } +} + +func TestSessionReportsRejectFutureTimestamps(t *testing.T) { + s := &Server{cfg: &config.Config{ManagerSessionSecret: "secret"}} + body, err := json.Marshal(packet.SessionStateReport{UserID: "alice", ActiveSessions: 1, ObservedAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/user/session-state", strings.NewReader(string(body))) + r.Header.Set("X-Manager-Session-Secret", "secret") + w := httptest.NewRecorder() + s.HandleUserSessionState(w, r) + if w.Code != http.StatusBadRequest || len(s.runtimes) != 0 { + t.Fatal("future report could suppress later session updates") + } +} From eb1bb6fa6a16e82d9e8b358abe7c537a1427f41f Mon Sep 17 00:00:00 2001 From: elecbug Date: Sun, 4 Oct 2026 16:53:05 +0900 Subject: [PATCH 9/9] feat: add scripts for building, Docker reinstallation, and migration - Introduced `build_ctl.sh` to build a static Linux binary for the CLI and services. - Added `docker_reinstall.sh` for a comprehensive Docker reinstallation process, including stopping services, removing containers/images, and setting up repositories. - Created `linuxus-completion.bash` for command-line completion support for the `linuxusctl` command. - Implemented `migrate_system_paths.py` to facilitate migration from legacy deployment paths to system paths with symlink support. - Added unit tests in `test_migrate_system_paths.py` to validate migration functionality and error handling. --- README.md | 6 +++--- {doc => docs}/.gitignore | 0 {doc => docs}/OPERATIONS.md | 0 {doc => docs}/USAGE.md | 14 +++++++------- {doc/fig => docs/figs}/01-login.png | Bin {doc/fig => docs/figs}/02-shell_1.png | Bin {doc/fig => docs/figs}/03-shell_2.png | Bin {doc/fig => docs/figs}/04-arch.png | Bin {doc/fig => docs/figs}/logo.png | Bin {doc/fig => docs/figs}/logo.svg | 0 {shell => scripts}/build_ctl.sh | 0 {shell => scripts}/docker_reinstall.sh | 0 {shell => scripts}/linuxus-completion.bash | 0 {shell => scripts}/migrate_system_paths.py | 0 {shell => scripts}/test_migrate_system_paths.py | 0 15 files changed, 10 insertions(+), 10 deletions(-) rename {doc => docs}/.gitignore (100%) rename {doc => docs}/OPERATIONS.md (100%) rename {doc => docs}/USAGE.md (98%) rename {doc/fig => docs/figs}/01-login.png (100%) rename {doc/fig => docs/figs}/02-shell_1.png (100%) rename {doc/fig => docs/figs}/03-shell_2.png (100%) rename {doc/fig => docs/figs}/04-arch.png (100%) rename {doc/fig => docs/figs}/logo.png (100%) rename {doc/fig => docs/figs}/logo.svg (100%) rename {shell => scripts}/build_ctl.sh (100%) rename {shell => scripts}/docker_reinstall.sh (100%) rename {shell => scripts}/linuxus-completion.bash (100%) rename {shell => scripts}/migrate_system_paths.py (100%) rename {shell => scripts}/test_migrate_system_paths.py (100%) diff --git a/README.md b/README.md index b148397..f1e3a67 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LINUXUS -

+

Linuxus, a Docker-based service that provides Ubuntu shell environments via a web browser for Linux education

--- @@ -20,7 +20,7 @@ Linuxus enables instructors to provide per-user Linux environments without requi ## 🚀 Usage -Refer to the [Usage documentation](./doc/USAGE.md) for installation and the [Operations guide](./doc/OPERATIONS.md) for diagnostics, recovery, accounts, backups, classroom templates, and the admin interface. +Refer to the [Usage documentation](./docs/USAGE.md) for installation and the [Operations guide](./docs/OPERATIONS.md) for diagnostics, recovery, accounts, backups, classroom templates, and the admin interface. --- @@ -53,4 +53,4 @@ Please refer to [SECURITY.md](./SECURITY.md) for responsible disclosure instruct ## Operations -Operational diagnostics, systemd recovery, account management, backups and restores, resource limits, classroom templates, and an administrator interface are available. See [Operations](./doc/OPERATIONS.md). +Operational diagnostics, systemd recovery, account management, backups and restores, resource limits, classroom templates, and an administrator interface are available. See [Operations](./docs/OPERATIONS.md). diff --git a/doc/.gitignore b/docs/.gitignore similarity index 100% rename from doc/.gitignore rename to docs/.gitignore diff --git a/doc/OPERATIONS.md b/docs/OPERATIONS.md similarity index 100% rename from doc/OPERATIONS.md rename to docs/OPERATIONS.md diff --git a/doc/USAGE.md b/docs/USAGE.md similarity index 98% rename from doc/USAGE.md rename to docs/USAGE.md index abda0df..d82b6b7 100644 --- a/doc/USAGE.md +++ b/docs/USAGE.md @@ -30,7 +30,7 @@ sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin d Build the single Linux executable (Go 1.26.1 or newer): ```bash -./shell/build_ctl.sh +./scripts/build_ctl.sh ``` The script can be invoked from any working directory. It builds a static Linux @@ -49,7 +49,7 @@ For a more convenient CLI experience, enable bash completion support. Run the following command: ```bash -source ./shell/linuxus-completion.bash +source ./scripts/linuxus-completion.bash ``` After enabling completion, you can use `TAB` to automatically complete commands and options. @@ -313,7 +313,7 @@ For the standard previous layout, use the provided one-time migration helper with the newly built CLI: ```bash -sudo python3 ./shell/migrate_system_paths.py --from /absolute/old/deployment +sudo python3 ./scripts/migrate_system_paths.py --from /absolute/old/deployment sudo ./linuxusctl up ``` @@ -396,16 +396,16 @@ it deletes the images and their contents. ## 🌐 APPENDIX - Preview Image -> ![](./fig/04-arch.png) +> ![](./figs/04-arch.png) > Linuxus Architecture Diagram -> ![](./fig/01-login.png) +> ![](./figs/01-login.png) > Login Page -> ![](./fig/02-shell_1.png) +> ![](./figs/02-shell_1.png) > Shell Page - Access -> ![](./fig/03-shell_2.png) +> ![](./figs/03-shell_2.png) > Shell Page - Test GCC ## Extended operations diff --git a/doc/fig/01-login.png b/docs/figs/01-login.png similarity index 100% rename from doc/fig/01-login.png rename to docs/figs/01-login.png diff --git a/doc/fig/02-shell_1.png b/docs/figs/02-shell_1.png similarity index 100% rename from doc/fig/02-shell_1.png rename to docs/figs/02-shell_1.png diff --git a/doc/fig/03-shell_2.png b/docs/figs/03-shell_2.png similarity index 100% rename from doc/fig/03-shell_2.png rename to docs/figs/03-shell_2.png diff --git a/doc/fig/04-arch.png b/docs/figs/04-arch.png similarity index 100% rename from doc/fig/04-arch.png rename to docs/figs/04-arch.png diff --git a/doc/fig/logo.png b/docs/figs/logo.png similarity index 100% rename from doc/fig/logo.png rename to docs/figs/logo.png diff --git a/doc/fig/logo.svg b/docs/figs/logo.svg similarity index 100% rename from doc/fig/logo.svg rename to docs/figs/logo.svg diff --git a/shell/build_ctl.sh b/scripts/build_ctl.sh similarity index 100% rename from shell/build_ctl.sh rename to scripts/build_ctl.sh diff --git a/shell/docker_reinstall.sh b/scripts/docker_reinstall.sh similarity index 100% rename from shell/docker_reinstall.sh rename to scripts/docker_reinstall.sh diff --git a/shell/linuxus-completion.bash b/scripts/linuxus-completion.bash similarity index 100% rename from shell/linuxus-completion.bash rename to scripts/linuxus-completion.bash diff --git a/shell/migrate_system_paths.py b/scripts/migrate_system_paths.py similarity index 100% rename from shell/migrate_system_paths.py rename to scripts/migrate_system_paths.py diff --git a/shell/test_migrate_system_paths.py b/scripts/test_migrate_system_paths.py similarity index 100% rename from shell/test_migrate_system_paths.py rename to scripts/test_migrate_system_paths.py