diff --git a/.github/workflows/ssh-desktop.yml b/.github/workflows/ssh-desktop.yml new file mode 100644 index 00000000..078e7024 --- /dev/null +++ b/.github/workflows/ssh-desktop.yml @@ -0,0 +1,122 @@ +name: SSH desktop foundation + +on: + pull_request: + paths: + - '.github/workflows/ssh-desktop.yml' + - 'cmd/atenea-ssh-controller/**' + - 'internal/sshcontrol/**' + - 'desktop/ssh/**' + - 'go.mod' + - 'go.sum' + +jobs: + native-build: + strategy: + fail-fast: false + matrix: + os: [macos-15, windows-2025, ubuntu-24.04] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.7' + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.2' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install Linux WebView build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb xauth xdotool imagemagick dbus-x11 weston + - name: Frontend check and build + working-directory: desktop/ssh/frontend + shell: bash + run: | + bun install --frozen-lockfile + bun run check + bun run build + - name: Native controller tests + shell: bash + run: go test -race -count=1 ./internal/sshcontrol/... + - name: Build Wails shell + working-directory: desktop/ssh + shell: bash + run: | + python scripts/restricted_wails.py test + python scripts/restricted_wails.py build + - name: Check Linux launcher diagnostics + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: bash scripts/linux_launcher_test.sh + - name: Bundle controller beside window + shell: bash + run: | + case "${{ runner.os }}" in + macOS) target=desktop/ssh/build/bin/atenea-ssh.app/Contents/MacOS/atenea-ssh-controller ;; + Windows) target=desktop/ssh/build/bin/atenea-ssh-controller.exe ;; + Linux) target=desktop/ssh/build/bin/atenea-ssh-controller ;; + esac + go build -o "$target" ./cmd/atenea-ssh-controller + - name: Render Linux window in a virtual X11 session + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh + - name: Render copied Linux package through a Terminal link + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: | + install_root=$(mktemp -d) + trap 'rm -rf "$install_root"' EXIT + mkdir -p "$install_root/bin" "$install_root/command" "$install_root/config" + install -m 755 build/bin/atenea-ssh build/bin/atenea-ssh-bin build/bin/atenea-ssh-controller "$install_root/bin/" + ln -s ../bin/atenea-ssh "$install_root/command/atenea-ssh" + ATENEA_SSH_SHELL="$install_root/command/atenea-ssh" \ + ATENEA_SSH_CONTROLLER="$install_root/bin/atenea-ssh-controller" \ + ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-installed-link.png \ + XDG_CONFIG_HOME="$install_root/config" \ + dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh + - name: Exercise Linux lifecycle in a virtual Wayland session + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: dbus-run-session -- bash scripts/linux_wayland_lifecycle.sh + - name: Probe Linux WebView bridge in virtual X11 + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: | + python scripts/restricted_wails.py probe-build + (cd ../.. && go build -o desktop/ssh/build/bin/atenea-ssh-controller ./cmd/atenea-ssh-controller) + ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-bridge-probe.png dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh + - name: Capture Linux WebView navigation probe in virtual X11 + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: | + python scripts/restricted_wails.py navigation-probe-build + (cd ../.. && go build -o desktop/ssh/build/bin/atenea-ssh-controller ./cmd/atenea-ssh-controller) + ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-navigation-probe.png dbus-run-session -- xvfb-run -a -s '-screen 0 1280x800x24' bash scripts/linux_render_smoke.sh + - name: Capture Linux WebView navigation probe in virtual Wayland + if: runner.os == 'Linux' + working-directory: desktop/ssh + shell: bash + run: ATENEA_SSH_CAPTURE=build/ci-artifacts/linux-wayland-navigation-probe.png ATENEA_SSH_CAPTURE_DELAY=7 dbus-run-session -- bash scripts/linux_wayland_lifecycle.sh + - name: Save Linux window capture + if: runner.os == 'Linux' && always() + uses: actions/upload-artifact@v4 + with: + name: atenea-ssh-linux-window + path: | + desktop/ssh/build/ci-artifacts/linux-render-smoke.png + desktop/ssh/build/ci-artifacts/linux-installed-link.png + desktop/ssh/build/ci-artifacts/linux-bridge-probe.png + desktop/ssh/build/ci-artifacts/linux-navigation-probe.png + desktop/ssh/build/ci-artifacts/linux-wayland.png + desktop/ssh/build/ci-artifacts/linux-wayland-navigation-probe.png + if-no-files-found: ignore diff --git a/cmd/atenea-ssh-controller/main.go b/cmd/atenea-ssh-controller/main.go new file mode 100644 index 00000000..9bf9c7b8 --- /dev/null +++ b/cmd/atenea-ssh-controller/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "time" + + "github.com/Tutitoos/atenea/internal/sshcontrol/controller" +) + +// The controller deliberately exposes no SSH commands in this foundation. +func main() { + rootFlag := flag.String("root", "", "absolute per-user state directory") + stopFlag := flag.Bool("stop", false, "stop the current user's controller") + flag.Parse() + if flag.NArg() != 0 { + fmt.Fprintln(os.Stderr, "unexpected arguments") + os.Exit(2) + } + root := *rootFlag + if root == "" { + var err error + root, err = controller.Root() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + id, err := controller.InstallationID(root) + if err == nil && *stopFlag { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + _, err = controller.Call(ctx, root, id, "stop") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if err == nil { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + err = controller.Serve(ctx, root, id) + } + if err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/desktop/ssh/.gitignore b/desktop/ssh/.gitignore new file mode 100644 index 00000000..28289704 --- /dev/null +++ b/desktop/ssh/.gitignore @@ -0,0 +1,8 @@ +/build/bin/ +/build/ci-artifacts/ +/frontend/node_modules/ +/frontend/dist/* +!/frontend/dist/gitkeep +/frontend/wailsjs/ +/frontend/package.json.md5 +/ssh diff --git a/desktop/ssh/README.md b/desktop/ssh/README.md new file mode 100644 index 00000000..630a8aba --- /dev/null +++ b/desktop/ssh/README.md @@ -0,0 +1,216 @@ +# Atenea SSH desktop foundation + +This is a fixture-only Wails v2.15.0 shell and a separate user-owned Go +controller. It does not read SSH config, store credentials, open remote +connections or run prompts. The example addresses use the reserved +documentation network `192.0.2.0/24`. + +## Local build + +Use Go 1.26.7, Python 3.12 and Bun 1.4.2. From this directory: + +```sh +GOTOOLCHAIN=go1.26.7 python3 scripts/restricted_wails.py test +GOTOOLCHAIN=go1.26.7 python3 scripts/restricted_wails.py build +# To build the guarded Windows amd64 shell from a macOS host: +ATENEA_WAILS_PLATFORM=windows/amd64 GOTOOLCHAIN=go1.26.7 python3 scripts/restricted_wails.py build +# On macOS, from the repository root: +GOTOOLCHAIN=go1.26.7 go build -o desktop/ssh/build/bin/atenea-ssh.app/Contents/MacOS/atenea-ssh-controller ./cmd/atenea-ssh-controller +``` + +On macOS the controller binary belongs beside the window executable inside +`build/bin/atenea-ssh.app/Contents/MacOS/`; on Windows it belongs beside the +window executable. On Linux, the build puts `atenea-ssh` (launcher), +`atenea-ssh-bin` (native window) and `atenea-ssh-controller` in the same +directory. Install all three together. The launcher checks for a graphical +session and linked libraries before starting the window, with actionable +errors on stderr. A Terminal link to the launcher resolves to that package +directory, with a bounded link chain; the executable and controller still stay +together there. For Ubuntu 24.04, the required runtime packages include +`libgtk-3-0` and `libwebkit2gtk-4.1-0`; see the [Wails Linux runtime guide](https://wails.io/docs/guides/linux-distro-support/). +Ubuntu CI also copies the three executables to a separate temporary package +directory and opens the window through a relative Terminal link under virtual +X11. Its capture proves that this package layout launches in the CI session; +it does not represent an installer or a clean desktop installation. +These checks do not prove that a stale display address can open a window or +that a clean machine has every runtime requirement. The window starts the +controller on demand. Closing the window does +not stop it. A later lifecycle UI will provide explicit stop/restart. +For a manual stop, run the installed controller executable with `--stop`. +On macOS and Linux, the window checks a user-state path that exceeds the Unix +socket address limit before launching the controller and reports the path problem. +This does not relocate existing state or make an overlong path usable. +A macOS 26.6.2 arm64 fixture trial used a 107-byte socket path: the window +displayed the path error and created no user-state files. A separate shorter +temporary path launched the sibling controller and rendered its available state. + +`bun run check` and `bun run build` run in `frontend/`. The browser development +preview only renders synthetic fixtures; it reports that the controller bridge +is unavailable. + +## Current security boundary + +The application Bind list exposes only `ControllerStatus`, which takes no +JavaScript arguments. The controller accepts only `status` and `stop` operations +after native peer authentication and protocol negotiation. The pinned Wails +source is copied into a temporary Go workspace during build. Exact upstream +source hashes are checked before the script restricts the dispatcher and the +three platform message entry points. The app references a marker available only +in that patched copy, so `go build ./...` without the script fails to compile. +Call messages over 4096 bytes are rejected at each native entry point before +they reach the dispatcher; the Windows additional-objects path uses the same +limit. +The only permitted JavaScript binding is the zero-argument status call; native +window close (`Q`) and framework readiness signals remain available. Browser, +clipboard, notification, window-control, drag/resize/file-drop and obfuscated +binding messages are rejected before their framework handlers. The frontend +also sets a packaged-assets-only CSP. The guarded Windows Wails copy changes +WebView2's global permission setting from allow to deny; the fixture UI does +not request native browser permissions. + +The guarded macOS WKWebView cancels navigation and new windows outside the +packaged `wails://wails/` page. The guarded Linux WebKitGTK window applies +the same restriction through its native navigation policy. Windows WebView2 +uses native navigation-starting and new-window events to keep its document +at `http://wails.localhost/`. The build script checks exact source hashes and +compiles these hooks into temporary copies of Wails and go-webview2. Broader +navigation trials and native Linux desktop sessions remain necessary before +this boundary is accepted. + +The per-user installation ID is written completely to a private temporary +file before being published atomically. Concurrent first launches therefore +read the same completed ID. Native endpoint ownership still allows only one +controller listener. The concurrent-ID tests cover simultaneous callers in one +process and eight independent processes. A separate process test starts a +controller, checks that a different installation cannot stop it and that an +incompatible protocol is closed before any operation, then verifies the +legitimate client still works. It also checks that a second controller cannot +take the endpoint, kills the owner, restarts it and verifies status and explicit +stop. This exercises stale endpoint recovery with synthetic state. Real macOS +and Windows graphical-session trials of simultaneous GUI activation are +recorded below; Linux, another-user sessions, logout and sleep/resume remain +separate acceptance checks. + +The guard has direct dispatcher tests, a shared ingress-policy test and +rendered macOS and Windows launch checks. Diagnostic screen and clipboard +calls did not return data in the tested WebViews. The current Windows and +virtual X11 probes distinguish a rejection from a timeout: both calls timed +out after 1.5 seconds, while the permitted controller status call succeeded. +A timeout alone does not prove explicit rejection or exclude a later response. +Other hostile calls, real Linux desktop and Wayland WebView behavior, broader +navigation probes, and clean installation +remain open in issue #151. Do not ship this shell with real SSH data or +credentials until those gates pass. Development preview uses only synthetic +fixtures. + +The optional `probe-build` command creates a local synthetic diagnostic app +that calls framework screen and clipboard read methods directly from its +WebView. It reports resolved, rejected, timed out or unavailable for each +call, never a returned value. It also sends a direct window-title command; +the virtual X11 smoke test checks that the native title remains `Atenea SSH`. +The normal `build` command removes the probe flag and verifies that its label +and direct title command are absent from the bundled JavaScript. +The fixture search shows a focus ring on keyboard focus, and the history +shortcut uses an immediate scroll when reduced motion is requested. Native +keyboard and assistive-technology acceptance remains open. +The separate `navigation-probe-build` attempts a top-level navigation to a +loopback URL with no SSH data. A surviving fixture window after the attempt +is an observation of blocked navigation; it does not alone identify which +browser or native policy stopped it. Normal builds verify that the probe URL +is absent from the bundled JavaScript. + +## Platform evidence + +| Platform | Build | Render/launch | Installed package | Runtime dependency | +| --- | --- | --- | --- | --- | +| macOS 26.6.2 arm64 | Local restricted Wails production build passed | Local `.app` opened with CSP and displayed fixture UI; native window close left controller running and explicit stop terminated it. Two simultaneous windows and a reopened third window showed the fixture UI and one shared controller in a separate temporary user-state root. An earlier diagnostic build showed no resolved screen or clipboard read within 1.5 seconds, without displaying returned data; that build did not distinguish rejection from timeout. A separate navigation-probe build left the fixture UI visible after the loopback attempt in an inspected window capture | User-level copy in `~/Applications` was signed locally, verified, opened with its sibling controller, then removed from Applications; clean-system install remains open | WKWebView supplied by macOS | +| Windows 2025 CI runner, amd64 | Native Wails shell, controller build and controller tests passed | Not tested | Not tested | WebView2 runtime | +| Windows 11 Pro build 26200 test workstation, amd64 | Guarded shell and controller cross-built from macOS; transferred EXE hashes matched | Both processes started in the active user session. A later console-session trial rendered two simultaneous fixture windows and a reopened third window with one shared controller. Window-only captures showed `Controlador disponible`; the revised diagnostic showed both direct WebView screen and clipboard calls timed out after 1.5 seconds. At `3b111b9`, a separate navigation-probe build attempted a loopback page; the captured fixture window remained visible after 7 seconds | Temporary per-user EXEs launched and removed; no installer or clean-system test | WebView2 rendered the fixture window | +| Ubuntu 24.04 CI runner, amd64 | Native Wails shell, controller build and controller tests passed | Passed in virtual X11 with Xvfb. Inspected normal, bridge diagnostic and navigation-probe captures showed fixture UI and `Controlador disponible`. The navigation probe left the packaged page visible after the loopback attempt; screen/clipboard calls timed out after 1.5 seconds. A separate headless Weston session exercised two Wayland shell processes sharing one controller, window-process close and explicit controller stop. Its inspected normal and navigation-probe captures showed the synthetic device list and `Controlador disponible`; the latter also showed the packaged-page survival message after the loopback attempt. Real desktop X11 and Wayland remain open | Not tested | GTK3 and WebKit2GTK 4.1 | + +At `9a0ceca`, a locally signed macOS build and sibling controller were launched +with a temporary `HOME`, so this trial did not use the normal Atenea state. +Two separate `open -n` launches rendered separate windows; inspected captures +of each contained the synthetic device list and `Controlador disponible`. +Process inspection showed two GUI processes and exactly one controller. Closing +the first window through its native close button ended only that GUI process. +A third launch rendered the same available-controller state and reused the +original controller process. Closing the remaining windows left that process +running; its explicit `--stop` terminated it. The temporary processes and state +were then removed. This observes one macOS graphical session, not another +user, Windows/Linux GUI concurrency, sleep, logout or clean-system installation. + +At `c33e94f`, a guarded Windows build and sibling controller were transferred +to a separate temporary directory after SHA-256 comparison. A temporary task +ran in the logged-in console session with isolated `APPDATA`. Its report +recorded two GUI processes and exactly one controller. Three window-only +captures were inspected: the two simultaneous windows and a reopened third +window all rendered the synthetic device list and `Controlador disponible`. +Closing the first with the native window-close request left the second and the +controller running. Reopening reused the same controller process. Closing the +remaining windows left it running until explicit `--stop`, which terminated it. +The temporary task, processes and files were removed. This observes one +Windows graphical session, not another user, Linux GUI concurrency, logout, +sleep or clean-system installation. + +The guarded build matrix passed on native macOS, Windows and Ubuntu runners at +`2de550f`. The Windows workstation trial used a temporary interactive scheduled task to +launch the two verified EXEs in the active user session. The capture selected +only the Atenea window. A second trial used `probe-build`; its original +`Puente bloqueado` label conflated rejection with a timeout. The revised probe +showed `Pantalla: sin respuesta · Portapapeles: sin respuesta` after direct +framework read calls from the Windows WebView. It never displays or records +returned values. This is observed non-response within 1.5 seconds, not proof +of an explicit rejection. +Both preview processes, the two temporary tasks and the temporary directory +were removed after each trial. The normal build was restored afterward. This +does not verify every framework operation, remote SSH, credential storage or +an installer. A separate Windows user-session trial used a temporary +`navigation-probe-build` based on `3b111b9`. The transferred window and +controller hashes matched the cross-built binaries. Seven seconds after the +loopback navigation attempt, a window-only capture still showed the fixture +UI, the available controller and the probe's survival message. The test +processes, scheduled task and temporary directory were removed afterward. +This confirms that the window remained on its packaged page in that trial; +it does not prove which layer rejected the navigation or cover other +navigation types. On Ubuntu 24.04 CI at `a6db23f`, Xvfb launched the packaged +window and a controller in a virtual X11 session. The captured window was +inspected and visibly contained the fixture list and available controller. +The CI smoke step checks that a visible window can be captured; visual content +was verified separately by inspecting the artifact. A second Xvfb run at +`f183222` captured the diagnostic window. Both direct calls reported `sin +respuesta` after 1.5 seconds while controller status succeeded. This does not +prove explicit rejection or exclude a later response. It also does not establish +behavior in a real Linux desktop session or Wayland. At `0013f86`, another +Xvfb capture showed the fixture UI and probe survival message after the +loopback navigation attempt. The CI step captured the window; its visible +content was inspected separately. At `b31c28d`, Ubuntu CI also ran the normal +shell twice with `GDK_BACKEND=wayland` and no X11 display, under a temporary +headless Weston compositor. The lifecycle step observed both shell processes +alive and one controller process under isolated user state. After ending both +shell processes, the controller remained until its explicit stop command, +then exited. This exercises the Wayland client and controller lifecycle in a +virtual session. That process-only run did not establish visible pixels, user interaction, +navigation behavior or installation in a real Linux desktop. At `5a9235b`, +the Ubuntu CI job captured the Weston output after both shell processes had +started. The PNG was inspected separately: it showed the fixture device list, +the example-data badge and `Controlador disponible`. The CI step only checks +that one nonempty PNG was written; it does not interpret the pixels. Weston's +debug capture was enabled only for the isolated temporary compositor using +synthetic data. The image shows one foreground window, while the process check +covers the two simultaneous shells. This still does not test a real desktop, +user interaction or Wayland navigation policy. At `2758583`, the Ubuntu CI job +also captured the navigation-probe build under the same virtual Wayland setup, +seven seconds after its loopback navigation attempt. The inspected 1024×640 +PNG showed the fixture UI, available controller and `La página empaquetada +sigue visible tras el intento`. The test checks process survival and that a +PNG exists; the visual content was confirmed separately. This observes that +the packaged page remained visible, without identifying which browser or +native layer prevented navigation or covering other navigation types. A CI build +does not prove a graphical session, WebView behavior, installation or runtime +availability on a clean user machine. OS logout and sleep/resume remain +unobserved. Wails' [installation guide](https://wails.io/docs/gettingstarted/installation/) +lists platform requirements; its [build guide](https://wails.io/docs/gettingstarted/building/) +documents Ubuntu 24.04's `webkit2_41` tag. The frontend uses the bundled +Nunito font under the included SIL Open Font License; platform window chrome +and font rasterization can differ. diff --git a/desktop/ssh/app.go b/desktop/ssh/app.go new file mode 100644 index 00000000..29d1b7a7 --- /dev/null +++ b/desktop/ssh/app.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/Tutitoos/atenea/internal/sshcontrol/controller" + "github.com/Tutitoos/atenea/internal/sshcontrol/handshake" + "github.com/Tutitoos/atenea/internal/sshcontrol/localipc" +) + +// App exposes only fixture lifecycle data. Wails framework runtime methods +// require a separate host-side bridge audit before this becomes production UI. +type App struct{ mu sync.Mutex } + +type ControllerView struct { + State string `json:"state"` + Detail string `json:"detail"` +} + +// ControllerStatus starts the sibling process on demand. It never takes a path +// or command from JavaScript and never dispatches a remote operation. +func (a *App) ControllerStatus() ControllerView { + a.mu.Lock() + defer a.mu.Unlock() + root, err := controller.Root() + if err != nil { + return ControllerView{"error", "No se encuentra el directorio del usuario"} + } + if err := localipc.ValidateEndpoint(root); errors.Is(err, localipc.ErrEndpointTooLong) { + return ControllerView{"error", "La ruta del usuario es demasiado larga para el socket local"} + } + id, err := controller.InstallationID(root) + if err != nil { + return ControllerView{"error", "No se puede abrir el estado local"} + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if status, err := controller.Call(ctx, root, id, "status"); err == nil { + return ControllerView{status.State, "Controlador disponible"} + } else if errors.Is(err, handshake.ErrVersion) || errors.Is(err, handshake.ErrPeer) { + return ControllerView{"incompatible", "Versión o instalación del controlador incompatible"} + } else if errors.Is(err, localipc.ErrPeer) || errors.Is(err, localipc.ErrPrivateRoot) { + return ControllerView{"error", "No se pudo verificar la identidad del controlador"} + } + executable, err := os.Executable() + if err != nil { + return ControllerView{"error", "No se encuentra la instalación"} + } + name := "atenea-ssh-controller" + if runtime.GOOS == "windows" { + name += ".exe" + } + command := exec.Command(filepath.Join(filepath.Dir(executable), name), "--root", root) + if err := command.Start(); err != nil { + if errors.Is(err, os.ErrNotExist) { + return ControllerView{"missing", "Falta el controlador en la instalación"} + } + return ControllerView{"error", "No se pudo iniciar el controlador"} + } + _ = command.Process.Release() + probe := time.NewTicker(100 * time.Millisecond) + defer probe.Stop() + for { + select { + case <-ctx.Done(): + return ControllerView{"error", "El controlador no respondió a tiempo"} + case <-probe.C: + if status, err := controller.Call(ctx, root, id, "status"); err == nil { + return ControllerView{status.State, "Controlador disponible"} + } + } + } +} diff --git a/desktop/ssh/app_unix_test.go b/desktop/ssh/app_unix_test.go new file mode 100644 index 00000000..d67425f5 --- /dev/null +++ b/desktop/ssh/app_unix_test.go @@ -0,0 +1,33 @@ +//go:build darwin || linux + +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Tutitoos/atenea/internal/sshcontrol/controller" +) + +func TestControllerStatusReportsLongSocketPathBeforeStateCreation(t *testing.T) { + home := filepath.Join(t.TempDir(), strings.Repeat("x", 104)) + if runtime.GOOS == "linux" { + t.Setenv("XDG_CONFIG_HOME", home) + } else { + t.Setenv("HOME", home) + } + root, err := controller.Root() + if err != nil { + t.Fatal(err) + } + view := (&App{}).ControllerStatus() + if view.State != "error" || !strings.Contains(view.Detail, "demasiado larga") { + t.Fatalf("unexpected long-path status: %#v", view) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("long endpoint created user state: %v", err) + } +} diff --git a/desktop/ssh/bridge/guard.go.txt b/desktop/ssh/bridge/guard.go.txt new file mode 100644 index 00000000..d0f4c62d --- /dev/null +++ b/desktop/ssh/bridge/guard.go.txt @@ -0,0 +1,38 @@ + +// ateneaSSHAllowedMessage is checked before Wails' framework dispatcher. This +// fixture shell needs only its zero-argument controller status binding. +func ateneaSSHAllowedMessage(message string) bool { + // Native window close on macOS/Linux is delivered to this dispatcher as Q. + // A page can also request quit; it cannot reach controller state by doing so. + if message == "Q" { return true } + if len(message) < 2 || len(message) > 4096 || message[0] != 'C' { + return false + } + var payload map[string]json.RawMessage + if json.Unmarshal([]byte(message[1:]), &payload) != nil || len(payload) != 3 { + return false + } + var name, callbackID string + var args []json.RawMessage + if json.Unmarshal(payload["name"], &name) != nil || name != "main.App.ControllerStatus" { + return false + } + if json.Unmarshal(payload["args"], &args) != nil || len(args) != 0 { + return false + } + if json.Unmarshal(payload["callbackID"], &callbackID) != nil { + return false + } + // Wails generates this ID from the binding name and a numeric random value. + // Restrict it before the dispatcher can include it in a trace message. + const prefix = "main.App.ControllerStatus-" + if len(callbackID) <= len(prefix) || len(callbackID) > len(prefix)+32 || callbackID[:len(prefix)] != prefix { + return false + } + for _, c := range callbackID[len(prefix):] { + if c < '0' || c > '9' { + if c != '.' && c != 'e' && c != '+' && c != '-' { return false } + } + } + return true +} diff --git a/desktop/ssh/bridge/guard_test.go.txt b/desktop/ssh/bridge/guard_test.go.txt new file mode 100644 index 00000000..1d37908a --- /dev/null +++ b/desktop/ssh/bridge/guard_test.go.txt @@ -0,0 +1,35 @@ +package dispatcher + +import "testing" + +func TestAteneaSSHBridgeGuard(t *testing.T) { + allowed := `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-123"}` + if !ateneaSSHAllowedMessage(allowed) { t.Fatal("fixture status binding blocked") } + if !ateneaSSHAllowedMessage(`C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-1.2e+10"}`) { t.Fatal("fallback runtime callback blocked") } + if !ateneaSSHAllowedMessage("Q") { t.Fatal("native close blocked") } + for _, message := range []string{ + `BOpen:https://example.invalid`, + `Wz`, `S`, `H`, `Ddrop`, `LSecret`, `Eevent`, + `C{"name":"wails.ClipboardGetText","args":[],"callbackID":"1"}`, + `C{"name":"wails.Environment","args":[],"callbackID":"1"}`, + `C{"name":"main.App.ControllerStatus","args":["/tmp"],"callbackID":"1"}`, + `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"1","extra":true}`, + `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-secret"}`, + `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-123\nsecret"}`, + `c{"id":1,"args":[],"callbackID":"1"}`, + } { + if ateneaSSHAllowedMessage(message) { t.Fatalf("unauthorized bridge message accepted: %s", message) } + } +} + +func TestAteneaSSHDispatcherSilentlyDropsDirectMessages(t *testing.T) { + var d Dispatcher + for _, message := range []string{ + `BO:https://example.invalid`, `Wz`, `S`, `H`, `Ddrop`, + `C{"name":"wails.ClipboardGetText","args":[],"callbackID":"1"}`, + `C{"name":"wails.Environment","args":[],"callbackID":"1"}`, + `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"main.App.ControllerStatus-secret"}`, + } { + if result, err := d.ProcessMessage(message, nil); result != "" || err != nil { t.Fatalf("dispatcher did not drop message: result=%q err=%v", result, err) } + } +} diff --git a/desktop/ssh/bridge/ingress.go.txt b/desktop/ssh/bridge/ingress.go.txt new file mode 100644 index 00000000..e2341801 --- /dev/null +++ b/desktop/ssh/bridge/ingress.go.txt @@ -0,0 +1,6 @@ + +// Permit lifecycle signals and the single bound-call channel. All other +// JavaScript messages are rejected before framework special handlers. +func ateneaSSHIngressMessage(message string) bool { + return message == "DomReady" || message == "runtime:ready" || message == "Q" || (len(message) >= 2 && len(message) <= 4096 && message[0] == 'C') +} diff --git a/desktop/ssh/bridge/ingress_test.go.txt b/desktop/ssh/bridge/ingress_test.go.txt new file mode 100644 index 00000000..5e83df75 --- /dev/null +++ b/desktop/ssh/bridge/ingress_test.go.txt @@ -0,0 +1,16 @@ +package PLATFORM + +import ( + "strings" + "testing" +) + +func TestAteneaSSHIngressRejectsRuntimeBypass(t *testing.T) { + for _, message := range []string{"DomReady", "runtime:ready", "Q", `C{"name":"main.App.ControllerStatus","args":[],"callbackID":"1"}`} { + if !ateneaSSHIngressMessage(message) { t.Fatalf("required lifecycle or call message blocked: %s", message) } + } + if !ateneaSSHIngressMessage("C" + strings.Repeat("x", 4095)) { t.Fatal("maximum-size call message blocked at ingress") } + for _, message := range []string{"", "C", "drag", "resize:left", "wails:showInspector", "wails:openInspector", "file:drop:0:0", "BOpen:https://example.invalid", "Ddrop", "Wz", "C" + strings.Repeat("x", 4096)} { + if ateneaSSHIngressMessage(message) { t.Fatalf("framework bypass accepted: %s", message) } + } +} diff --git a/desktop/ssh/bridge/navigation_darwin.m.txt b/desktop/ssh/bridge/navigation_darwin.m.txt new file mode 100644 index 00000000..180f8ffc --- /dev/null +++ b/desktop/ssh/bridge/navigation_darwin.m.txt @@ -0,0 +1,14 @@ +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)action + decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { + NSURL *url = action.request.URL; + BOOL packaged = url != nil && + [[url scheme] isEqualToString:@"wails"] && + [[url host] isEqualToString:@"wails"] && + [url port] == nil && [url user] == nil && + [[url path] isEqualToString:@"/"]; + if (!packaged || action.targetFrame == nil || !action.targetFrame.isMainFrame) { + decisionHandler(WKNavigationActionPolicyCancel); + return; + } + decisionHandler(WKNavigationActionPolicyAllow); +} diff --git a/desktop/ssh/bridge/navigation_linux.c.txt b/desktop/ssh/bridge/navigation_linux.c.txt new file mode 100644 index 00000000..881a50be --- /dev/null +++ b/desktop/ssh/bridge/navigation_linux.c.txt @@ -0,0 +1,24 @@ +static gboolean ateneaNavigationPolicy(WebKitWebView *webview, WebKitPolicyDecision *decision, + WebKitPolicyDecisionType type, gpointer data) +{ + if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) + { + webkit_policy_decision_ignore(decision); + return TRUE; + } + if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) + { + return FALSE; + } + WebKitNavigationPolicyDecision *navigation = WEBKIT_NAVIGATION_POLICY_DECISION(decision); + WebKitNavigationAction *action = webkit_navigation_policy_decision_get_navigation_action(navigation); + WebKitURIRequest *request = action == NULL ? NULL : webkit_navigation_action_get_request(action); + const gchar *uri = request == NULL ? NULL : webkit_uri_request_get_uri(request); + if (g_strcmp0(uri, "wails://wails/") == 0 || + (uri != NULL && g_str_has_prefix(uri, "wails://wails/#"))) + { + return FALSE; + } + webkit_policy_decision_ignore(decision); + return TRUE; +} diff --git a/desktop/ssh/bridge/navigation_windows.go.txt b/desktop/ssh/bridge/navigation_windows.go.txt new file mode 100644 index 00000000..f094f22a --- /dev/null +++ b/desktop/ssh/bridge/navigation_windows.go.txt @@ -0,0 +1,124 @@ +package edge + +import ( + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Keep these callbacks in the reviewed edge package. The older pkg/webview2 +// package has unrelated callback initializers that fail under Go 1.26. +type ateneaNavigationArgsVtbl struct { + _IUnknownVtbl + GetUri ComProc + GetIsUserInitiated ComProc + GetIsRedirected ComProc + GetRequestHeaders ComProc + GetCancel ComProc + PutCancel ComProc + GetNavigationId ComProc +} + +type ateneaNavigationArgs struct{ vtbl *ateneaNavigationArgsVtbl } + +type ateneaNewWindowArgsVtbl struct { + _IUnknownVtbl + GetUri ComProc + PutNewWindow ComProc + GetNewWindow ComProc + PutHandled ComProc + GetHandled ComProc + GetIsUserInitiated ComProc + GetDeferral ComProc + GetWindowFeatures ComProc +} + +type ateneaNewWindowArgs struct{ vtbl *ateneaNewWindowArgsVtbl } + +type ateneaNavigationHandlerVtbl struct { + _IUnknownVtbl + Invoke ComProc +} + +type ateneaNavigationHandler struct { + vtbl *ateneaNavigationHandlerVtbl + owner *Chromium +} + +type ateneaNewWindowHandlerVtbl struct { + _IUnknownVtbl + Invoke ComProc +} + +type ateneaNewWindowHandler struct { + vtbl *ateneaNewWindowHandlerVtbl + owner *Chromium +} + +var ateneaNavigationVtable = ateneaNavigationHandlerVtbl{ + _IUnknownVtbl{ + NewComProc(func(h *ateneaNavigationHandler, iid, object uintptr) uintptr { return h.owner.QueryInterface(iid, object) }), + NewComProc(func(h *ateneaNavigationHandler) uintptr { return h.owner.AddRef() }), + NewComProc(func(h *ateneaNavigationHandler) uintptr { return h.owner.Release() }), + }, + NewComProc(func(h *ateneaNavigationHandler, _ *ICoreWebView2, args *ateneaNavigationArgs) uintptr { + return h.owner.ateneaNavigationStarting(args) + }), +} + +var ateneaNewWindowVtable = ateneaNewWindowHandlerVtbl{ + _IUnknownVtbl{ + NewComProc(func(h *ateneaNewWindowHandler, iid, object uintptr) uintptr { return h.owner.QueryInterface(iid, object) }), + NewComProc(func(h *ateneaNewWindowHandler) uintptr { return h.owner.AddRef() }), + NewComProc(func(h *ateneaNewWindowHandler) uintptr { return h.owner.Release() }), + }, + NewComProc(func(h *ateneaNewWindowHandler, _ *ICoreWebView2, args *ateneaNewWindowArgs) uintptr { + return h.owner.ateneaNewWindowRequested(args) + }), +} + +func ateneaAllowedNavigation(uri string) bool { + return uri == "http://wails.localhost/" || strings.HasPrefix(uri, "http://wails.localhost/#") +} + +func (e *Chromium) ateneaNavigationStarting(args *ateneaNavigationArgs) uintptr { + var rawURI *uint16 + hr, _, _ := args.vtbl.GetUri.Call(uintptr(unsafe.Pointer(args)), uintptr(unsafe.Pointer(&rawURI))) + allowed := false + if windows.Handle(hr) == windows.S_OK && rawURI != nil { + allowed = ateneaAllowedNavigation(windows.UTF16PtrToString(rawURI)) + windows.CoTaskMemFree(unsafe.Pointer(rawURI)) + } + if !allowed { + hr, _, _ = args.vtbl.PutCancel.Call(uintptr(unsafe.Pointer(args)), 1) + if windows.Handle(hr) != windows.S_OK { + e.errorCallback(windows.Errno(hr)) + } + } + return 0 +} + +func (e *Chromium) ateneaNewWindowRequested(args *ateneaNewWindowArgs) uintptr { + hr, _, _ := args.vtbl.PutHandled.Call(uintptr(unsafe.Pointer(args)), 1) + if windows.Handle(hr) != windows.S_OK { + e.errorCallback(windows.Errno(hr)) + } + return 0 +} + +func (e *Chromium) ateneaRegisterNavigation() { + var token _EventRegistrationToken + hr, _, _ := e.webview.vtbl.AddNavigationStarting.Call( + uintptr(unsafe.Pointer(e.webview)), uintptr(unsafe.Pointer(e.navigationStarting)), uintptr(unsafe.Pointer(&token)), + ) + if windows.Handle(hr) != windows.S_OK { + e.errorCallback(windows.Errno(hr)) + } + hr, _, _ = e.webview.vtbl.AddNewWindowRequested.Call( + uintptr(unsafe.Pointer(e.webview)), uintptr(unsafe.Pointer(e.newWindowRequested)), uintptr(unsafe.Pointer(&token)), + ) + if windows.Handle(hr) != windows.S_OK { + e.errorCallback(windows.Errno(hr)) + } +} diff --git a/desktop/ssh/bridge/navigation_windows_test.go.txt b/desktop/ssh/bridge/navigation_windows_test.go.txt new file mode 100644 index 00000000..e29137d9 --- /dev/null +++ b/desktop/ssh/bridge/navigation_windows_test.go.txt @@ -0,0 +1,27 @@ +package edge + +import "testing" + +func TestAteneaAllowedNavigation(t *testing.T) { + tests := []struct { + uri string + want bool + }{ + {"http://wails.localhost/", true}, + {"http://wails.localhost/#ssh", true}, + {"http://wails.localhost/other", false}, + {"http://wails.localhost/?other", false}, + {"https://wails.localhost/", false}, + {"http://wails.localhost.evil/", false}, + {"http://user@wails.localhost/", false}, + {"http://wails.localhost:8080/", false}, + {"file:///C:/secret", false}, + {"data:text/html,hello", false}, + {"about:blank", false}, + } + for _, tt := range tests { + if got := ateneaAllowedNavigation(tt.uri); got != tt.want { + t.Errorf("ateneaAllowedNavigation(%q) = %t, want %t", tt.uri, got, tt.want) + } + } +} diff --git a/desktop/ssh/build/appicon.png b/desktop/ssh/build/appicon.png new file mode 100644 index 00000000..63617fe4 Binary files /dev/null and b/desktop/ssh/build/appicon.png differ diff --git a/desktop/ssh/build/darwin/Info.plist b/desktop/ssh/build/darwin/Info.plist new file mode 100644 index 00000000..ac1ca2d1 --- /dev/null +++ b/desktop/ssh/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + dev.tutitoos.atenea.ssh + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/desktop/ssh/frontend/bun.lock b/desktop/ssh/frontend/bun.lock new file mode 100644 index 00000000..4d6a98f3 --- /dev/null +++ b/desktop/ssh/frontend/bun.lock @@ -0,0 +1,259 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "atenea-ssh-desktop", + "dependencies": { + "react": "19.1.1", + "react-dom": "19.1.1", + }, + "devDependencies": { + "@types/react": "19.1.13", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "5.0.0", + "typescript": "5.9.2", + "vite": "7.0.6", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.30", "", {}, "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.4", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.4", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.4", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.4", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.4", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.4", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.4", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.4", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], + + "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.30", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="], + + "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.433", "", {}, "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "node-releases": ["node-releases@2.0.56", "", {}, "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], + + "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "rollup": ["rollup@4.63.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + + "vite": ["vite@7.0.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/desktop/ssh/frontend/index.html b/desktop/ssh/frontend/index.html new file mode 100644 index 00000000..0053824b --- /dev/null +++ b/desktop/ssh/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Atenea SSH + + +
+ + + diff --git a/desktop/ssh/frontend/package.json b/desktop/ssh/frontend/package.json new file mode 100644 index 00000000..a6b027ce --- /dev/null +++ b/desktop/ssh/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "atenea-ssh-desktop", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "check": "tsc --noEmit", + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/react": "19.1.13", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "5.0.0", + "typescript": "5.9.2", + "vite": "7.0.6" + } +} diff --git a/desktop/ssh/frontend/src/App.tsx b/desktop/ssh/frontend/src/App.tsx new file mode 100644 index 00000000..5696fc45 --- /dev/null +++ b/desktop/ssh/frontend/src/App.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from 'react' + +type ControllerView = { state: string; detail: string } +type Host = { alias: string; address: string; platform: string; agent: string; tone: 'ready' | 'pending' | 'error' } +type ProbeOutcome = 'resolved' | 'rejected' | 'timeout' | 'unavailable' +type ProbeView = { detail: string; tone: 'pending' | 'safe' | 'warning' } + +declare global { + interface Window { + go?: { main?: { App?: { ControllerStatus: () => Promise } } } + runtime?: { ScreenGetAll?: () => Promise; ClipboardGetText?: () => Promise } + WailsInvoke?: (message: string) => void + } +} + +const hosts: Host[] = [ + { alias: 'equipo-diseno', address: '192.0.2.12', platform: 'macOS', agent: 'Agente disponible', tone: 'ready' }, + { alias: 'servidor-pruebas', address: '192.0.2.24', platform: 'Linux', agent: 'Sin instalar', tone: 'pending' }, + { alias: 'estacion-taller', address: '192.0.2.36', platform: 'Windows', agent: 'Conexión pendiente', tone: 'error' }, +] + +const activity = [ + { time: '10:42', title: 'Sesión de ejemplo iniciada', detail: 'equipo-diseno · Vista previa' }, + { time: 'Ayer', title: 'Estado del agente consultado', detail: 'servidor-pruebas · Vista previa' }, +] + +function App() { + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(hosts[0].alias) + const [visibility, setVisibility] = useState<'visible' | 'oculto'>('visible') + const [controller, setController] = useState({ state: 'checking', detail: 'Comprobando controlador…' }) + const [bridgeProbe, setBridgeProbe] = useState({ detail: 'Comprobando puente…', tone: 'pending' }) + const [navigationProbe, setNavigationProbe] = useState('Preparando intento de navegación…') + const [theme, setTheme] = useState<'light' | 'dark'>(() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + const filtered = useMemo(() => hosts.filter(host => `${host.alias} ${host.address} ${host.platform}`.toLowerCase().includes(query.toLowerCase())), [query]) + const host = hosts.find(item => item.alias === selected) ?? hosts[0] + + useEffect(() => { + document.documentElement.dataset.theme = theme + }, [theme]) + + useEffect(() => { + const status = window.go?.main?.App?.ControllerStatus + if (!status) { setController({ state: 'preview', detail: 'Vista previa en navegador' }); return } + void status().then(setController).catch(() => setController({ state: 'error', detail: 'No se pudo consultar el controlador' })) + }, []) + + useEffect(() => { + if (import.meta.env.VITE_ATENEA_BRIDGE_PROBE !== '1') return + // A direct framework command must not change the native window title. + window.WailsInvoke?.('WTAtenea SSH probe escaped') + if (!window.runtime) { setBridgeProbe({ detail: 'Runtime no disponible', tone: 'warning' }); return } + const check = async (call: (() => Promise) | undefined): Promise => { + if (!call) return 'unavailable' + // Never inspect or render a framework result, which could contain local data. + try { + return await Promise.race([ + Promise.resolve().then(call).then(() => 'resolved', () => 'rejected'), + new Promise(resolve => window.setTimeout(() => resolve('timeout'), 1500)), + ]) + } catch { return 'rejected' } + } + void Promise.all([ + check(window.runtime?.ScreenGetAll), + check(window.runtime?.ClipboardGetText), + ]).then(([screen, clipboard]) => { + const outcomes: Record = { + resolved: 'respondió', rejected: 'rechazada', timeout: 'sin respuesta', unavailable: 'no disponible', + } + setBridgeProbe({ + detail: `Pantalla: ${outcomes[screen]} · Portapapeles: ${outcomes[clipboard]}`, + tone: screen === 'rejected' && clipboard === 'rejected' ? 'safe' : 'warning', + }) + }) + }, []) + + useEffect(() => { + if (import.meta.env.VITE_ATENEA_NAVIGATION_PROBE !== '1') return + const timer = window.setTimeout(() => { + try { + window.location.assign('http://127.0.0.1:9/atenea-navigation-probe') + window.setTimeout(() => setNavigationProbe('La página empaquetada sigue visible tras el intento'), 1500) + } catch { + setNavigationProbe('El navegador rechazó el intento de navegación') + } + }, 1000) + return () => window.clearTimeout(timer) + }, []) + + return
+ +
+
Espacio de trabajo/Dispositivos
+
+ {import.meta.env.VITE_ATENEA_BRIDGE_PROBE === '1' &&
{bridgeProbe.detail}
} + {import.meta.env.VITE_ATENEA_NAVIGATION_PROBE === '1' &&
{navigationProbe}
} +
CONEXIONES SSH

Tus dispositivos

Consulta el estado de tus equipos y elige dónde trabajar.

DATOS DE EJEMPLO
+
Controlador local · {controller.detail}
+

Dispositivos

{filtered.length} de {hosts.length}
{filtered.map(item => )}{filtered.length === 0 &&

No hay dispositivos con ese nombre.

}
+
▣{host.agent}

{host.alias}

{host.address} · {host.platform}

Alias SSH{host.alias}
Estado del agente{host.agent}
Conversación{visibility === 'visible' ? 'Visible' : 'Oculta'}
Visibilidad del chatElige cómo aparecerá la conversación en este equipo.

Esta vista no abre conexiones SSH ni guarda preferencias.

+
ACTIVIDAD

Historial reciente

Vista previa
{activity.map(item =>
◷
{item.title}{item.detail}
)}
+
+
+
+} + +export default App diff --git a/desktop/ssh/frontend/src/assets/fonts/OFL.txt b/desktop/ssh/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 00000000..5843e21b --- /dev/null +++ b/desktop/ssh/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/ssh/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/desktop/ssh/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 00000000..2f9cc596 Binary files /dev/null and b/desktop/ssh/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/desktop/ssh/frontend/src/main.tsx b/desktop/ssh/frontend/src/main.tsx new file mode 100644 index 00000000..3626ff30 --- /dev/null +++ b/desktop/ssh/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import {createRoot} from 'react-dom/client' +import './style.css' +import App from './App' + +const container = document.getElementById('root') + +const root = createRoot(container!) + +root.render( + + + +) diff --git a/desktop/ssh/frontend/src/style.css b/desktop/ssh/frontend/src/style.css new file mode 100644 index 00000000..380d75b5 --- /dev/null +++ b/desktop/ssh/frontend/src/style.css @@ -0,0 +1,10 @@ +@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2') format('woff2');font-display:swap} +:root{font-family:Nunito,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17212f;background:#f6f7f9;font-synthesis:none} +:root[data-theme="dark"]{color:#e9edf4;background:#10151d} +*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #7d9de5;outline-offset:2px}button:disabled{cursor:not-allowed} +.shell{display:flex;min-height:100vh}.sidebar{width:228px;flex:none;background:#182333;color:#d9e2f2;display:flex;flex-direction:column;padding:29px 13px 18px}.brand{display:flex;align-items:center;gap:11px;padding:0 13px 42px}.brand-mark{display:grid;place-items:center;width:32px;height:32px;background:#6f8de2;border-radius:10px;color:white;font-size:24px}.brand strong,.brand small,.sidebar-bottom strong,.sidebar-bottom small{display:block}.brand strong{font-size:16px;color:#fff}.brand small,.sidebar-bottom small{font-size:11px;color:#a1afc1}.sidebar-label,.eyebrow{font-size:10px;letter-spacing:.14em;font-weight:800}.sidebar-label{color:#8495aa;padding:0 14px 13px}.nav-item{border:0;background:none;color:#aebbd0;width:100%;padding:12px 14px;text-align:left;border-radius:9px;display:flex;align-items:center;gap:13px;font-weight:700;font-size:13px}.nav-item span:first-child{font-size:17px}.nav-item.active{background:#34415d;color:#fff}.nav-count{margin-left:auto;color:#c9d6f2}.sidebar-bottom{margin-top:auto;border-top:1px solid #344154;padding:18px 11px 0;display:flex;align-items:center;gap:10px;font-size:12px}.avatar{display:grid;place-items:center;background:#6b80bf;color:#fff;border-radius:50%;width:30px;height:30px;font-weight:800} +.bridge-probe{background:#fff3d9;color:#765000;border:1px solid #e9c775;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;font-weight:800}.bridge-probe[data-tone="safe"]{background:#dff4e9;color:#166843;border-color:#91d8b0} +.search:focus-within{outline:3px solid #7d9de5;outline-offset:2px} +.workspace{flex:1;min-width:0}.topbar{height:65px;border-bottom:1px solid #e4e8ed;background:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 38px;font-size:12px}.breadcrumb{color:#909baa}.slash{margin:0 12px;color:#b5bfcb}.theme-button{border:1px solid #e3e7ed;background:#fff;border-radius:8px;width:30px;height:30px;color:#5e6b80}.content{max-width:1160px;margin:auto;padding:42px 38px}.heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:24px}.eyebrow{color:#687fc2}.heading h1{margin:7px 0 6px;font-size:29px;letter-spacing:-.04em}.heading p{margin:0;color:#768394;font-size:13px}.fixture-badge{background:#e9eefb;color:#5269a9;border:1px solid #d8e1f8;border-radius:5px;font-weight:800;font-size:9px;letter-spacing:.1em;padding:8px 10px;white-space:nowrap}.controller-state{display:flex;align-items:center;gap:9px;background:#eef3fb;border:1px solid #dae5f4;border-radius:10px;padding:12px 15px;margin-bottom:20px;font-size:12px;color:#546176}.controller-state strong{color:#314159}.state-dot,.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#d5a752;flex:none}.state-dot.running,.status-dot.ready{background:#42b888}.state-dot.error,.state-dot.missing,.status-dot.error{background:#dc806f}.columns{display:grid;grid-template-columns:minmax(290px,.9fr) minmax(340px,1.1fr);gap:18px}.list-panel,.detail-panel,.history{background:#fff;border:1px solid #e4e8ee;border-radius:12px;box-shadow:0 5px 18px rgba(24,39,58,.035)}.list-panel{padding:20px 14px}.panel-heading{display:flex;justify-content:space-between;align-items:center;padding:0 8px 16px}.panel-heading h2,.detail-panel h2,.history h2{margin:0;font-size:16px;letter-spacing:-.02em}.panel-heading span,.history-heading>span{color:#96a0ad;font-size:11px}.search{height:39px;border:1px solid #e0e5ec;background:#f8f9fb;border-radius:8px;display:flex;align-items:center;padding:0 11px;gap:8px;color:#9aa6b5}.search span{font-size:22px;line-height:0}.search input{background:transparent;border:0;outline:0;width:100%;font-size:12px;color:inherit}.host-list{padding-top:13px}.host-row{display:flex;align-items:center;gap:11px;width:100%;text-align:left;border:1px solid transparent;background:none;padding:12px;border-radius:9px;margin-bottom:3px;color:inherit}.host-row:hover{background:#f5f7fb}.host-row.selected{background:#ecf1fc;border-color:#d9e3fa}.device-icon,.large-device{display:grid;place-items:center;background:#e8eef9;color:#607bc0;border-radius:9px;font-size:21px;width:37px;height:37px;flex:none}.host-copy{flex:1;min-width:0}.host-copy strong,.host-copy small,.activity-row strong,.activity-row small{display:block}.host-copy strong{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-copy small{font-size:10px;color:#93a0af;margin-top:3px}.empty{font-size:12px;color:#8491a1;text-align:center;padding:20px}.detail-panel{padding:23px}.detail-top{display:flex;justify-content:space-between;align-items:flex-start}.large-device{width:46px;height:46px;font-size:26px}.status-pill{background:#e4f7ed;color:#268661;padding:6px 9px;border-radius:5px;font-size:10px;font-weight:800}.status-pill.pending{background:#fff4dd;color:#a9741a}.status-pill.error{background:#fff0ec;color:#af6154}.detail-panel h2{margin-top:19px;font-size:19px}.device-address{margin:3px 0 0;color:#94a0af;font-size:11px}.divider{height:1px;background:#e9edf2;margin:23px 0}.meta{display:grid;gap:14px}.meta>div{display:flex;justify-content:space-between;gap:10px;font-size:11px}.meta span{color:#94a0af}.meta strong{font-weight:700}.visibility{margin-top:25px;background:#f7f9fc;border:1px solid #e9edf3;border-radius:9px;padding:14px}.visibility strong,.visibility small{display:block}.visibility strong{font-size:11px}.visibility small{color:#929dad;font-size:10px;margin:3px 0 14px}.segmented{display:flex;background:#e8edf4;border-radius:7px;padding:3px}.segmented button{width:50%;border:0;background:transparent;color:#778599;padding:6px;border-radius:5px;font-size:11px}.segmented button[aria-pressed="true"]{background:#fff;color:#304c91;box-shadow:0 1px 4px #cbd5e2}.connect{margin-top:20px;border:0;background:#788fcb;color:#fff;width:100%;border-radius:8px;padding:11px 14px;text-align:left;font-weight:800;font-size:11px;opacity:.65}.connect span{float:right}.fixture-note{color:#9ca7b4;text-align:center;font-size:10px;margin:10px 0 0}.history{margin-top:23px;padding:21px 24px}.history-heading{display:flex;justify-content:space-between;align-items:end;padding-bottom:15px}.history h2{margin-top:5px}.activity-row{border-top:1px solid #eef0f3;display:flex;align-items:center;gap:11px;padding:13px 0;font-size:11px}.activity-icon{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:#f0f2f7;color:#8793aa;font-size:18px}.activity-row>div{flex:1}.activity-row small{font-size:10px;color:#9ca7b4;margin-top:2px}.activity-row time{font-size:10px;color:#a1aab6} +:root[data-theme="dark"] .workspace{background:#10151d}:root[data-theme="dark"] .topbar,:root[data-theme="dark"] .list-panel,:root[data-theme="dark"] .detail-panel,:root[data-theme="dark"] .history{background:#1b2532;border-color:#334151}:root[data-theme="dark"] .theme-button,:root[data-theme="dark"] .search,:root[data-theme="dark"] .visibility{background:#253142;color:#dce5f0;border-color:#38475b}:root[data-theme="dark"] .host-row:hover,:root[data-theme="dark"] .host-row.selected,:root[data-theme="dark"] .controller-state{background:#293955;border-color:#425675;color:#dce5f0}:root[data-theme="dark"] .controller-state strong{color:#f0f4fb}:root[data-theme="dark"] .divider,:root[data-theme="dark"] .activity-row{border-color:#344052}:root[data-theme="dark"] .divider{background:#344052}:root[data-theme="dark"] .segmented{background:#344052}:root[data-theme="dark"] .segmented button[aria-pressed="true"]{background:#526994;color:white} +@media(max-width:920px){.sidebar{width:65px}.brand{padding:0 3px 40px}.brand>span:last-child{display:none}.sidebar-label,.nav-item{font-size:0}.nav-item{justify-content:center}.nav-item span:first-child{font-size:20px}.nav-count,.sidebar-bottom>div{display:none}.sidebar-bottom{justify-content:center;padding:16px 0 0}.content{padding:30px 22px}.topbar{padding:0 22px}}@media(max-width:700px){.columns{grid-template-columns:1fr}.sidebar{width:54px}.heading{display:block}.fixture-badge{display:inline-block;margin-top:14px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition:none!important;animation:none!important}} diff --git a/desktop/ssh/frontend/src/vite-env.d.ts b/desktop/ssh/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/desktop/ssh/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/ssh/frontend/tsconfig.json b/desktop/ssh/frontend/tsconfig.json new file mode 100644 index 00000000..823e83d1 --- /dev/null +++ b/desktop/ssh/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/desktop/ssh/frontend/tsconfig.node.json b/desktop/ssh/frontend/tsconfig.node.json new file mode 100644 index 00000000..b8afcc8f --- /dev/null +++ b/desktop/ssh/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/desktop/ssh/frontend/vite.config.ts b/desktop/ssh/frontend/vite.config.ts new file mode 100644 index 00000000..49550655 --- /dev/null +++ b/desktop/ssh/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()] +}) diff --git a/desktop/ssh/go.mod b/desktop/ssh/go.mod new file mode 100644 index 00000000..29fab8ca --- /dev/null +++ b/desktop/ssh/go.mod @@ -0,0 +1,42 @@ +module github.com/Tutitoos/atenea/desktop/ssh + +go 1.25.13 + +require ( + github.com/Tutitoos/atenea v0.0.0 + github.com/wailsapp/wails/v2 v2.15.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.39.0 // indirect +) + +replace github.com/Tutitoos/atenea => ../.. diff --git a/desktop/ssh/go.sum b/desktop/ssh/go.sum new file mode 100644 index 00000000..b78a7144 --- /dev/null +++ b/desktop/ssh/go.sum @@ -0,0 +1,84 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q= +github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/desktop/ssh/main.go b/desktop/ssh/main.go new file mode 100644 index 00000000..f99ad1b7 --- /dev/null +++ b/desktop/ssh/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "embed" + "log" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +// The build fails if the reviewed host-side Wails overlay is missing. +var _ = options.AteneaSSHBridgeGuard + +func main() { + app := &App{} + if err := wails.Run(&options.App{ + Title: "Atenea SSH", + Width: 1100, + Height: 720, + MinWidth: 760, + MinHeight: 540, + AssetServer: &assetserver.Options{Assets: assets}, + BackgroundColour: &options.RGBA{R: 246, G: 247, B: 249, A: 1}, + Bind: []interface{}{app}, + }); err != nil { + log.Fatal(err) + } +} diff --git a/desktop/ssh/scripts/linux_launcher.sh b/desktop/ssh/scripts/linux_launcher.sh new file mode 100644 index 00000000..430eae82 --- /dev/null +++ b/desktop/ssh/scripts/linux_launcher.sh @@ -0,0 +1,59 @@ +#!/bin/sh +set -eu + +launcher=$(command -v "$0") +case "$launcher" in + /*) ;; + *) launcher=$(pwd)/$launcher ;; +esac +links=0 +while [ -L "$launcher" ]; do + links=$((links + 1)) + if [ "$links" -gt 16 ]; then + printf '%s\n' 'Atenea SSH: el enlace del lanzador tiene demasiados niveles. Reinstala el paquete completo.' >&2 + exit 78 + fi + target=$(readlink "$launcher") || exit 78 + case "$target" in + /*) launcher=$target ;; + *) launcher=$(dirname -- "$launcher")/$target ;; + esac +done +binary=$(dirname -- "$launcher")/atenea-ssh-bin +if [ ! -x "$binary" ]; then + printf '%s\n' 'Atenea SSH: falta el ejecutable de la ventana junto al lanzador. Reinstala el paquete completo.' >&2 + exit 78 +fi + +wayland_socket='' +if [ -n "${WAYLAND_DISPLAY:-}" ]; then + case "$WAYLAND_DISPLAY" in + /*) wayland_socket=$WAYLAND_DISPLAY ;; + *) wayland_socket=${XDG_RUNTIME_DIR:-}/$WAYLAND_DISPLAY ;; + esac +fi +if { [ -z "${DISPLAY:-}" ] || [ "${GDK_BACKEND:-}" = wayland ]; } && + { [ -z "$wayland_socket" ] || [ ! -S "$wayland_socket" ]; } || + { [ "${GDK_BACKEND:-}" = x11 ] && [ -z "${DISPLAY:-}" ]; }; then + printf '%s\n' 'Atenea SSH: no hay una sesión gráfica X11 o Wayland disponible. Inicia sesión en el escritorio de este usuario y vuelve a abrir la aplicación.' >&2 + exit 69 +fi + +if ! command -v ldd >/dev/null 2>&1; then + printf '%s\n' 'Atenea SSH: no se puede comprobar las bibliotecas gráficas (falta ldd).' >&2 + exit 69 +fi +ldd_status=0 +dependencies=$(ldd "$binary" 2>&1) || ldd_status=$? +missing=$(printf '%s\n' "$dependencies" | awk '/not found/ { print $1 }') +if [ -n "$missing" ]; then + printf '%s\n' 'Atenea SSH: faltan bibliotecas gráficas:' "$missing" >&2 + printf '%s\n' 'Instala los paquetes de GTK3 y WebKit2GTK 4.1 de tu distribución. En Ubuntu 24.04: sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0' >&2 + exit 69 +fi +if [ "$ldd_status" -ne 0 ]; then + printf '%s\n' 'Atenea SSH: no se pudieron comprobar las bibliotecas de la ventana. Comprueba la arquitectura y las dependencias del paquete.' >&2 + exit 69 +fi + +exec "$binary" "$@" diff --git a/desktop/ssh/scripts/linux_launcher_test.sh b/desktop/ssh/scripts/linux_launcher_test.sh new file mode 100644 index 00000000..164f5e74 --- /dev/null +++ b/desktop/ssh/scripts/linux_launcher_test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +cp scripts/linux_launcher.sh "$tmp/atenea-ssh" +chmod +x "$tmp/atenea-ssh" + +expect_failure() { + local expected=$1 + shift + local status=0 + "$@" >"$tmp/stdout" 2>"$tmp/stderr" || status=$? + if [[ $status -eq 0 ]] || ! grep -Fq "$expected" "$tmp/stderr"; then + printf 'Unexpected launcher result (status %s):\n' "$status" >&2 + cat "$tmp/stderr" >&2 + exit 1 + fi +} + +expect_failure 'falta el ejecutable' env DISPLAY=:99 "$tmp/atenea-ssh" +cat >"$tmp/atenea-ssh-bin" <<'EOF' +#!/bin/sh +printf 'opened:%s\n' "$1" +EOF +chmod +x "$tmp/atenea-ssh-bin" +expect_failure 'no hay una sesión gráfica' env -u DISPLAY -u WAYLAND_DISPLAY "$tmp/atenea-ssh" +expect_failure 'no hay una sesión gráfica' env DISPLAY=:99 GDK_BACKEND=wayland WAYLAND_DISPLAY=invalid XDG_RUNTIME_DIR="$tmp" "$tmp/atenea-ssh" + +mkdir "$tmp/fakebin" +cat >"$tmp/fakebin/ldd" <<'EOF' +#!/bin/sh +printf '%s\n' 'libwebkit2gtk-4.1.so.0 => not found' +exit 1 +EOF +chmod +x "$tmp/fakebin/ldd" +expect_failure 'libwebkit2gtk-4.1.so.0' env DISPLAY=:99 PATH="$tmp/fakebin:$PATH" "$tmp/atenea-ssh" + +cat >"$tmp/fakebin/ldd" <<'EOF' +#!/bin/sh +printf '%s\n' 'libwebkit2gtk-4.1.so.0 => /usr/lib/libwebkit2gtk-4.1.so.0' +EOF +chmod +x "$tmp/fakebin/ldd" +actual=$(env DISPLAY=:99 PATH="$tmp/fakebin:$PATH" "$tmp/atenea-ssh" test-argument) +[[ "$actual" == 'opened:test-argument' ]] +ln -s ../atenea-ssh "$tmp/fakebin/atenea-ssh-link" +actual=$(env DISPLAY=:99 PATH="$tmp/fakebin:$PATH" atenea-ssh-link linked-argument) +[[ "$actual" == 'opened:linked-argument' ]] +ln -s ../atenea-ssh "$tmp/fakebin/link-0" +for index in {1..17}; do + ln -s "link-$((index - 1))" "$tmp/fakebin/link-$index" +done +expect_failure 'demasiados niveles' env DISPLAY=:99 "$tmp/fakebin/link-17" +printf '%s\n' 'Linux launcher diagnostics passed' diff --git a/desktop/ssh/scripts/linux_render_smoke.sh b/desktop/ssh/scripts/linux_render_smoke.sh new file mode 100644 index 00000000..dcec3d77 --- /dev/null +++ b/desktop/ssh/scripts/linux_render_smoke.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +shell=${ATENEA_SSH_SHELL:-build/bin/atenea-ssh} +controller=${ATENEA_SSH_CONTROLLER:-build/bin/atenea-ssh-controller} +capture=${ATENEA_SSH_CAPTURE:-build/ci-artifacts/linux-render-smoke.png} +log_file=$(mktemp) +app_pid='' + +cleanup() { + if [[ -n "$app_pid" ]]; then + kill "$app_pid" 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true + fi + "$controller" --stop >/dev/null 2>&1 || true + if [[ -s "$log_file" ]]; then cat "$log_file"; fi + rm -f "$log_file" +} +trap cleanup EXIT + +"$shell" >"$log_file" 2>&1 & +app_pid=$! +window_id='' +for _ in {1..60}; do + if ! kill -0 "$app_pid" 2>/dev/null; then + echo 'Atenea SSH exited before a window appeared' >&2 + exit 1 + fi + window_id=$(xdotool search --onlyvisible --name '^Atenea SSH$' | head -n 1 || true) + if [[ -n "$window_id" ]]; then break; fi + sleep 0.5 +done +if [[ -z "$window_id" ]]; then + echo 'Atenea SSH window was not visible within 30 seconds' >&2 + exit 1 +fi + +# Let the packaged WebView finish loading before capturing this synthetic UI. +sleep 4 +actual_title=$(xdotool getwindowname "$window_id") +if [[ "$actual_title" != 'Atenea SSH' ]]; then + echo 'Atenea SSH native window title changed during render' >&2 + exit 1 +fi +mkdir -p "$(dirname "$capture")" +import -window "$window_id" "$capture" +test -s "$capture" +echo "Captured Atenea SSH window: $capture" diff --git a/desktop/ssh/scripts/linux_wayland_lifecycle.sh b/desktop/ssh/scripts/linux_wayland_lifecycle.sh new file mode 100644 index 00000000..1b67bf97 --- /dev/null +++ b/desktop/ssh/scripts/linux_wayland_lifecycle.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This is a virtual Wayland render and lifecycle test, not a real desktop test. +shell=build/bin/atenea-ssh +controller=build/bin/atenea-ssh-controller +capture=${ATENEA_SSH_CAPTURE:-build/ci-artifacts/linux-wayland.png} +capture_delay=${ATENEA_SSH_CAPTURE_DELAY:-3} +test_root=$(mktemp -d) +weston_pid='' +first_pid='' +second_pid='' + +cleanup() { + for pid in "$first_pid" "$second_pid" "$weston_pid"; do + if [[ -n "$pid" ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + done + "$controller" --stop >/dev/null 2>&1 || true + if [[ -f "$test_root/failed" ]]; then + for name in weston first second screenshooter; do + if [[ -s "$test_root/$name.log" ]]; then + echo "=== $name log ===" >&2 + cat "$test_root/$name.log" >&2 + fi + done + fi + rm -rf "$test_root" +} +trap cleanup EXIT +trap 'touch "$test_root/failed"' ERR + +export XDG_RUNTIME_DIR="$test_root/runtime" +export XDG_CONFIG_HOME="$test_root/config" +export WAYLAND_DISPLAY=atenea-ssh-test +export GDK_BACKEND=wayland +export XDG_SESSION_TYPE=wayland +unset DISPLAY +mkdir -m 700 "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$test_root/pictures" +mkdir -p "$(dirname "$capture")" +capture_dir=$(cd "$(dirname "$capture")" && pwd) +capture_name=$(basename "$capture") +export XDG_PICTURES_DIR="$test_root/pictures" + +# Weston debug exposes output capture, safe only on this isolated synthetic CI socket. +weston --no-config --debug --backend=headless --renderer=pixman --socket="$WAYLAND_DISPLAY" --idle-time=0 >"$test_root/weston.log" 2>&1 & +weston_pid=$! +for _ in {1..60}; do + if [[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]]; then break; fi + kill -0 "$weston_pid" + sleep 0.5 +done +test -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" + +"$shell" >"$test_root/first.log" 2>&1 & +first_pid=$! +"$shell" >"$test_root/second.log" 2>&1 & +second_pid=$! + +controller_root="$XDG_CONFIG_HOME/atenea/ssh-desktop" +controller_count() { + local count=0 proc binary arg root + local -a args + for proc in /proc/[0-9]*/cmdline; do + [[ -r "$proc" ]] || continue + mapfile -d '' -t args <"$proc" || true + ((${#args[@]} >= 3)) || continue + binary=${args[0]} + arg=${args[1]} + root=${args[2]} + if [[ "$binary" == *'/atenea-ssh-controller' && "$arg" == --root && "$root" == "$controller_root" ]]; then + ((count += 1)) + fi + done + printf '%s\n' "$count" +} + +for _ in {1..60}; do + kill -0 "$first_pid" + kill -0 "$second_pid" + if [[ $(controller_count) == 1 ]]; then break; fi + sleep 0.5 +done +test "$(controller_count)" == 1 +echo 'Wayland: two shell processes share one responsive controller' + +sleep "$capture_delay" +weston-screenshooter >"$test_root/screenshooter.log" 2>&1 +shopt -s nullglob +screenshots=("$XDG_PICTURES_DIR"/wayland-screenshot-*.png) +if ((${#screenshots[@]} != 1)); then + cat "$test_root/screenshooter.log" >&2 + echo "Expected one virtual Wayland screenshot, found ${#screenshots[@]}" >&2 + exit 1 +fi +mv "${screenshots[0]}" "$capture_dir/$capture_name" +test -s "$capture_dir/$capture_name" +echo "Captured virtual Wayland output: $capture" + +kill "$first_pid" +wait "$first_pid" 2>/dev/null || true +first_pid='' +kill -0 "$second_pid" +test "$(controller_count)" == 1 +kill "$second_pid" +wait "$second_pid" 2>/dev/null || true +second_pid='' +test "$(controller_count)" == 1 + +"$controller" --stop +for _ in {1..20}; do + if [[ $(controller_count) == 0 ]]; then break; fi + sleep 0.2 +done +test "$(controller_count)" == 0 +echo 'Wayland: shell close leaves controller running; explicit stop ends it' diff --git a/desktop/ssh/scripts/restricted_wails.py b/desktop/ssh/scripts/restricted_wails.py new file mode 100644 index 00000000..ac8255dc --- /dev/null +++ b/desktop/ssh/scripts/restricted_wails.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Build and test pinned Wails with Atenea's host-side dispatcher guard. + +The upstream module is copied to a temporary Go workspace and patched only +after exact source-hash checks. The application will not compile without the +guard marker added to that verified source copy. +""" + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +WAILS = "github.com/wailsapp/wails/v2" +WEBVIEW2 = "github.com/wailsapp/go-webview2" +WEBVIEW2_CHROMIUM_HASH = "6013bea6dc614888de282a37febebdfb31984377b317b3911671b20209b8b671" +SOURCE_HASHES = { + "internal/frontend/dispatcher/dispatcher.go": "baa6bc120411c07323e66476bb14a9872088a970449a600a765890012beb3133", + "internal/frontend/desktop/darwin/frontend.go": "96b4e064ea8178a0ae26e65eab5c92c4200eca3f0f241c2035f88de6eaead35e", + "internal/frontend/desktop/darwin/WailsContext.m": "ffb03f12a11f4af78b3ea2fac0ef5b2b931bc5de0b0e835ed75554fd7ad91e72", + "internal/frontend/desktop/linux/frontend.go": "2610e740979055e636a30d05670126aad3fa6b77071c3f9af20e2fb6c47c89f5", + "internal/frontend/desktop/linux/window.c": "76529ab2c6eb8a3b195823f934ca14adac937b300ad0420cd6e3e6e6630b5643", + "internal/frontend/desktop/windows/frontend.go": "9598c7f21779e2332db788a9e09f3b4856d563ab5fc2c93bf7035743b9d7befa", + "pkg/options/options.go": "9ec72bb753c04f7bf1f5d09ab973db41791028df3f13051d5fac5c19143a6d2e", +} +MARKER = "AteneaSSHBridgeGuard" +GUARD_CALL = "\tif !ateneaSSHAllowedMessage(message) {\n\t\treturn \"\", nil // Drop untrusted messages without logging their contents.\n\t}\n" + + +def run(*args: str, env: dict[str, str] | None = None, cwd: Path = ROOT) -> None: + subprocess.run(args, cwd=cwd, env=env, check=True) + + +def prepare_workspace(tmp: Path) -> Path: + subprocess.run(["go", "mod", "verify"], cwd=ROOT, check=True, stdout=subprocess.DEVNULL) + version = subprocess.check_output( + ["go", "list", "-m", "-f", "{{.Version}}", WAILS], cwd=ROOT, text=True + ).strip() + if version != "v2.15.0": + raise RuntimeError(f"expected Wails v2.15.0, found {version or 'local replacement'}") + module_dir = Path( + subprocess.check_output( + ["go", "list", "-m", "-f", "{{.Dir}}", WAILS], cwd=ROOT, text=True + ).strip() + ) + webview_version = subprocess.check_output( + ["go", "list", "-m", "-f", "{{.Version}}", WEBVIEW2], cwd=ROOT, text=True + ).strip() + if webview_version != "v1.0.22": + raise RuntimeError(f"expected go-webview2 v1.0.22, found {webview_version or 'local replacement'}") + webview_dir = Path( + subprocess.check_output( + ["go", "list", "-m", "-f", "{{.Dir}}", WEBVIEW2], cwd=ROOT, text=True + ).strip() + ) + chromium_source = webview_dir / "pkg/edge/chromium.go" + if hashlib.sha256(chromium_source.read_bytes()).hexdigest() != WEBVIEW2_CHROMIUM_HASH: + raise RuntimeError("go-webview2 Chromium source changed; audit before updating guard") + for relative, expected_hash in SOURCE_HASHES.items(): + original = module_dir / relative + source = original.read_bytes() + if hashlib.sha256(source).hexdigest() != expected_hash: + raise RuntimeError(f"Wails source changed: {relative}; audit before updating guard") + patched = tmp / "wails" + shutil.copytree(module_dir, patched) + patched_webview = tmp / "go-webview2" + shutil.copytree(webview_dir, patched_webview) + chromium = patched_webview / "pkg/edge/chromium.go" + content = chromium.read_text(encoding="utf-8") + webview_anchors = { + "navigationCompleted *ICoreWebView2NavigationCompletedEventHandler\n": "navigationCompleted *ICoreWebView2NavigationCompletedEventHandler\n\tnavigationStarting *ateneaNavigationHandler\n\tnewWindowRequested *ateneaNewWindowHandler\n", + "e.navigationCompleted = newICoreWebView2NavigationCompletedEventHandler(e)\n": "e.navigationCompleted = newICoreWebView2NavigationCompletedEventHandler(e)\n\te.navigationStarting = &ateneaNavigationHandler{vtbl: &ateneaNavigationVtable, owner: e}\n\te.newWindowRequested = &ateneaNewWindowHandler{vtbl: &ateneaNewWindowVtable, owner: e}\n", + } + for anchor, replacement in webview_anchors.items(): + if content.count(anchor) != 1: + raise RuntimeError("go-webview2 Chromium navigation anchor changed") + content = content.replace(anchor, replacement) + registration = "err = e.webview.AddNavigationCompleted(e.navigationCompleted, &token)\n\tif err != nil {\n\t\te.errorCallback(err)\n\t}\n" + if content.count(registration) != 1: + raise RuntimeError("go-webview2 Chromium registration anchor changed") + content = content.replace(registration, registration + "\te.ateneaRegisterNavigation()\n") + chromium.parent.chmod(0o700) + chromium.chmod(0o600) + chromium.write_text(content, encoding="utf-8") + subprocess.run(["gofmt", "-w", str(chromium)], check=True) + for filename in ("navigation_windows.go.txt", "navigation_windows_test.go.txt"): + target = patched_webview / "pkg/edge" / filename.removesuffix(".txt").replace("navigation_windows", "atenea_navigation") + target.write_text((ROOT / "bridge" / filename).read_text(encoding="utf-8"), encoding="utf-8") + subprocess.run(["gofmt", "-w", str(target)], check=True) + for relative in SOURCE_HASHES: + target = patched / relative + content = target.read_text(encoding="utf-8") + if relative.endswith("dispatcher.go"): + import_anchor = 'import (\n\t"context"\n' + function_anchor = 'func (d *Dispatcher) ProcessMessage(message string, sender frontend.Frontend) (_ string, err error) {\n' + if content.count(import_anchor) != 1 or content.count(function_anchor) != 1: + raise RuntimeError("Wails dispatcher anchors changed") + content = content.replace(import_anchor, import_anchor + '\t"encoding/json"\n') + content = content.replace(function_anchor, function_anchor + GUARD_CALL) + log_anchor = 'd.log.Error("process message error: %s -> %s", message, err)' + if content.count(log_anchor) != 1: + raise RuntimeError("Wails dispatcher error logger changed") + content = content.replace(log_anchor, 'd.log.Error("process message error")') + content += (ROOT / "bridge/guard.go.txt").read_text(encoding="utf-8") + elif relative.endswith("frontend.go"): + if "darwin" in relative or "linux" in relative: + anchor = "func (f *Frontend) processMessage(message string) {\n" + else: + anchor = "func (f *Frontend) processMessage(message string, sender *edge.ICoreWebView2, args *edge.ICoreWebView2WebMessageReceivedEventArgs) {\n" + if content.count(anchor) != 1: + raise RuntimeError(f"Wails ingress anchor changed: {relative}") + content = content.replace(anchor, anchor + "\tif !ateneaSSHIngressMessage(message) { return }\n") + content += (ROOT / "bridge/ingress.go.txt").read_text(encoding="utf-8") + if "windows" in relative: + extra_anchor = "func (f *Frontend) processMessageWithAdditionalObjects(message string, sender *edge.ICoreWebView2, args *edge.ICoreWebView2WebMessageReceivedEventArgs) {\n" + if content.count(extra_anchor) != 1: + raise RuntimeError("Wails Windows additional-objects anchor changed") + content = content.replace(extra_anchor, extra_anchor + "\tif !ateneaSSHIngressMessage(message) || message[0] != 'C' { return }\n") + permission_anchor = "chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateAllow)" + if content.count(permission_anchor) != 1: + raise RuntimeError("Wails Windows WebView2 permission anchor changed") + content = content.replace(permission_anchor, "chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateDeny)") + elif relative.endswith("WailsContext.m"): + anchor = "- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {\n" + if content.count(anchor) != 1: + raise RuntimeError("Wails macOS navigation anchor changed") + content = content.replace(anchor, (ROOT / "bridge/navigation_darwin.m.txt").read_text(encoding="utf-8") + anchor) + elif relative.endswith("window.c"): + function_anchor = "static void webviewLoadChanged(WebKitWebView *web_view, WebKitLoadEvent load_event, gpointer data)\n" + signal_anchor = ' g_signal_connect(G_OBJECT(webview), "load-changed", G_CALLBACK(webviewLoadChanged), NULL);\n' + if content.count(function_anchor) != 1 or content.count(signal_anchor) != 1: + raise RuntimeError("Wails Linux navigation anchors changed") + content = content.replace(function_anchor, (ROOT / "bridge/navigation_linux.c.txt").read_text(encoding="utf-8") + function_anchor) + content = content.replace(signal_anchor, signal_anchor + ' g_signal_connect(G_OBJECT(webview), "decide-policy", G_CALLBACK(ateneaNavigationPolicy), NULL);\n') + else: + content += f'\n// {MARKER} proves this app was built with the reviewed Wails overlay.\nconst {MARKER} = "wails-v2.15.0-guard-v1"\n' + target.parent.chmod(0o700) + target.chmod(0o600) + target.write_text(content, encoding="utf-8") + if target.suffix == ".go": + subprocess.run(["gofmt", "-w", str(target)], check=True) + dispatcher_dir = patched / "internal/frontend/dispatcher" + dispatcher_dir.chmod(0o700) + (dispatcher_dir / "atenea_guard_test.go").write_text( + (ROOT / "bridge/guard_test.go.txt").read_text(encoding="utf-8"), encoding="utf-8" + ) + subprocess.run(["gofmt", "-w", str(dispatcher_dir / "atenea_guard_test.go")], check=True) + for platform in ("darwin", "linux", "windows"): + ingress_test = patched / f"internal/frontend/desktop/{platform}/atenea_ingress_test.go" + ingress_test.write_text((ROOT / "bridge/ingress_test.go.txt").read_text(encoding="utf-8").replace("PLATFORM", platform, 1), encoding="utf-8") + subprocess.run(["gofmt", "-w", str(ingress_test)], check=True) + probe = tmp / "ingress-probe" + probe.mkdir() + (probe / "go.mod").write_text("module atenea-ssh-ingress-probe\n\ngo 1.25.0\n", encoding="utf-8") + (probe / "ingress.go").write_text("package ingressprobe\n" + (ROOT / "bridge/ingress.go.txt").read_text(encoding="utf-8"), encoding="utf-8") + (probe / "ingress_test.go").write_text( + (ROOT / "bridge/ingress_test.go.txt").read_text(encoding="utf-8").replace("PLATFORM", "ingressprobe", 1), encoding="utf-8" + ) + workspace = tmp / "go.work" + workspace.write_text( + "go 1.26.7\nuse (\n" + + f" {json.dumps(str(ROOT.parents[1]))}\n" + + f" {json.dumps(str(ROOT))}\n" + + f" {json.dumps(str(patched))}\n" + + f" {json.dumps(str(patched_webview))}\n" + + ")\n", encoding="utf-8" + ) + return workspace + + +def main() -> None: + if len(sys.argv) != 2 or sys.argv[1] not in {"test", "build", "probe-build", "navigation-probe-build"}: + raise SystemExit("usage: restricted_wails.py test|build|probe-build|navigation-probe-build") + with tempfile.TemporaryDirectory(prefix="atenea-wails-") as directory: + workspace = prepare_workspace(Path(directory)) + env = dict(os.environ) + env["GOWORK"] = str(workspace) + if sys.argv[1] == "test": + tags = ("-tags", "webkit2_41") if sys.platform.startswith("linux") else () + run("go", "test", "-count=1", WAILS + "/internal/frontend/dispatcher", env=env) + if sys.platform == "win32": + # The dependency's own graphical tests need an interactive COM + # session that CI runners do not provide. Run our URL policy + # test, then compile the complete guarded shell below. + run("go", "test", "-count=1", "-run", "^TestAteneaAllowedNavigation$", WEBVIEW2 + "/pkg/edge", env=env) + probe_env = dict(env) + probe_env["GOWORK"] = "off" + run("go", "test", "-count=1", "./...", cwd=Path(directory) / "ingress-probe", env=probe_env) + run("go", "vet", *tags, "./...", env=env) + run("go", "test", *tags, "./...", env=env) + else: + if sys.argv[1] == "probe-build": + env["VITE_ATENEA_BRIDGE_PROBE"] = "1" + else: + env.pop("VITE_ATENEA_BRIDGE_PROBE", None) + if sys.argv[1] == "navigation-probe-build": + env["VITE_ATENEA_NAVIGATION_PROBE"] = "1" + else: + env.pop("VITE_ATENEA_NAVIGATION_PROBE", None) + platform = env.pop("ATENEA_WAILS_PLATFORM", "") + if platform and platform not in {"darwin/arm64", "windows/amd64", "windows/arm64", "linux/amd64"}: + raise RuntimeError(f"unsupported Wails target: {platform}") + build_args = ["go", "run", WAILS + "/cmd/wails", "build", "-clean"] + if platform: + build_args.extend(["-platform", platform]) + target_linux = platform.startswith("linux/") if platform else sys.platform.startswith("linux") + build_args.extend(["-tags", "atenea_ssh_restricted" + (",webkit2_41" if target_linux else "")]) + run( + *build_args, + env=env, + ) + assets = list((ROOT / "frontend/dist/assets").glob("*.js")) + has_probe = any(b"Runtime no disponible" in asset.read_bytes() for asset in assets) + if not assets or has_probe != (sys.argv[1] == "probe-build"): + raise RuntimeError("frontend probe mode does not match requested build") + has_title_probe = any(b"WTAtenea SSH probe escaped" in asset.read_bytes() for asset in assets) + if has_title_probe != (sys.argv[1] == "probe-build"): + raise RuntimeError("native window-title probe mode does not match requested build") + has_navigation_probe = any(b"atenea-navigation-probe" in asset.read_bytes() for asset in assets) + if has_navigation_probe != (sys.argv[1] == "navigation-probe-build"): + raise RuntimeError("frontend navigation probe mode does not match requested build") + if target_linux: + native = ROOT / "build/bin/atenea-ssh" + if not native.is_file(): + raise RuntimeError("Linux Wails executable missing after build") + native.rename(native.with_name("atenea-ssh-bin")) + shutil.copy2(ROOT / "scripts/linux_launcher.sh", native) + native.chmod(0o755) + + +if __name__ == "__main__": + main() diff --git a/desktop/ssh/wails.json b/desktop/ssh/wails.json new file mode 100644 index 00000000..9010c3d7 --- /dev/null +++ b/desktop/ssh/wails.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "atenea-ssh", + "outputfilename": "atenea-ssh", + "frontend:install": "bun install --frozen-lockfile", + "frontend:build": "bun run build", + "frontend:dev:watcher": "bun run dev", + "frontend:dev:serverUrl": "auto" +} diff --git a/go.mod b/go.mod index 28543d14..927bd917 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.13 require ( github.com/BurntSushi/toml v1.6.0 + github.com/Microsoft/go-winio v0.6.2 github.com/duckdb/duckdb-go/v2 v2.5.6 github.com/google/uuid v1.6.0 github.com/mattn/go-isatty v0.0.24 diff --git a/go.sum b/go.sum index 67d2a7c6..47e8505f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI= diff --git a/internal/sshcontrol/controller/controller.go b/internal/sshcontrol/controller/controller.go new file mode 100644 index 00000000..eecfa063 --- /dev/null +++ b/internal/sshcontrol/controller/controller.go @@ -0,0 +1,218 @@ +// Package controller implements the fixture-only local desktop service. +// Native peer checks belong to localipc; no message here authorizes SSH work. +package controller + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/Tutitoos/atenea/internal/sshcontrol/handshake" + "github.com/Tutitoos/atenea/internal/sshcontrol/localipc" + "github.com/Tutitoos/atenea/internal/sshcontrol/wire" +) + +const requestTimeout = 5 * time.Second + +// ErrProtocol reports a disallowed fixture operation or malformed response. +var ErrProtocol = errors.New("ssh controller: invalid fixture request") + +// Status contains only controller lifecycle data. It contains no host or secret. +type Status struct { + State string `json:"state"` + Protocol string `json:"protocol"` +} + +type request struct { + Operation string `json:"operation"` +} + +// Root selects the dedicated state directory for the current OS account. +func Root() (string, error) { + config, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(config, "atenea", "ssh-desktop"), nil +} + +// InstallationID creates a stable, user-local identifier. The native transport +// authenticates the peer; this identifier is only a compatibility check. +func InstallationID(root string) (string, error) { + if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root { + return "", localipc.ErrPrivateRoot + } + if err := os.MkdirAll(root, 0o700); err != nil { + return "", err + } + info, err := os.Lstat(root) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", localipc.ErrPrivateRoot + } + if err := prepareRoot(root); err != nil { + return "", err + } + path := filepath.Join(root, "installation.id") + if data, err := os.ReadFile(path); err == nil { + id := string(data) + parsed, parseErr := uuid.Parse(id) + if parseErr != nil || parsed == uuid.Nil || parsed.String() != id { + return "", ErrProtocol + } + return id, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + id, err := uuid.NewRandomFromReader(rand.Reader) + if err != nil { + return "", err + } + // Write a private temporary file before publishing the final path. Linking + // within the same directory is atomic and cannot replace another winner. + f, err := os.CreateTemp(root, ".installation-*.tmp") + if err != nil { + return "", err + } + defer func() { _ = os.Remove(f.Name()) }() + if _, err = f.WriteString(id.String()); err != nil { + _ = f.Close() + return "", err + } + if err = f.Sync(); err != nil { + _ = f.Close() + return "", err + } + if err = f.Close(); err != nil { + return "", err + } + if err = os.Link(f.Name(), path); errors.Is(err, os.ErrExist) { + return InstallationID(root) + } else if err != nil { + return "", err + } + return id.String(), nil +} + +// Serve owns the authenticated endpoint until cancellation or an explicit stop. +// Closing the graphical window does not close this listener. +func Serve(ctx context.Context, root, installationID string) error { + hello, err := handshake.New(handshake.Controller, installationID) + if err != nil { + return err + } + listener, err := localipc.Listen(root) + if err != nil { + return err + } + defer func() { _ = listener.Close() }() + serveCtx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(serveCtx, func() { _ = listener.Close() }) + defer stop() + var workers sync.WaitGroup + defer workers.Wait() + slots := make(chan struct{}, 8) + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + if serveCtx.Err() != nil { + return nil + } + return acceptErr + } + select { + case slots <- struct{}{}: + workers.Add(1) + go func() { defer workers.Done(); defer func() { <-slots }(); serveConn(serveCtx, conn, hello, cancel) }() + default: + _ = conn.Close() + } + } +} + +func serveConn(ctx context.Context, conn net.Conn, hello handshake.Hello, stop context.CancelFunc) { + defer func() { _ = conn.Close() }() + negotiation, err := handshake.Exchange(ctx, conn, hello) + if err != nil || negotiation.Peer.Component != handshake.Desktop { + return + } + _ = conn.SetDeadline(time.Now().Add(requestTimeout)) + payload, err := wire.ReadFrame(conn) + if err != nil { + return + } + req, err := decodeRequest(payload) + if err != nil { + return + } + switch req.Operation { + case "status": + response, _ := json.Marshal(Status{State: "running", Protocol: "1.0"}) + _ = wire.WriteFrame(conn, response) + case "stop": + response, _ := json.Marshal(Status{State: "stopping", Protocol: "1.0"}) + if wire.WriteFrame(conn, response) == nil { + stop() + } + } +} + +func decodeRequest(payload []byte) (request, error) { + var fields map[string]json.RawMessage + if json.Unmarshal(payload, &fields) != nil || len(fields) != 1 || fields["operation"] == nil { + return request{}, ErrProtocol + } + var operation string + if json.Unmarshal(fields["operation"], &operation) != nil || (operation != "status" && operation != "stop") { + return request{}, ErrProtocol + } + return request{Operation: operation}, nil +} + +// Call makes one bounded fixture request over a freshly authenticated channel. +func Call(ctx context.Context, root, installationID, operation string) (Status, error) { + if operation != "status" && operation != "stop" { + return Status{}, ErrProtocol + } + hello, err := handshake.New(handshake.Desktop, installationID) + if err != nil { + return Status{}, err + } + conn, err := localipc.Dial(root, requestTimeout) + if err != nil { + return Status{}, err + } + defer func() { _ = conn.Close() }() + if _, err := handshake.Exchange(ctx, conn, hello); err != nil { + return Status{}, err + } + _ = conn.SetDeadline(time.Now().Add(requestTimeout)) + payload, _ := json.Marshal(request{Operation: operation}) + if err := wire.WriteFrame(conn, payload); err != nil { + return Status{}, err + } + response, err := wire.ReadFrame(conn) + if err != nil { + return Status{}, err + } + var status Status + if err := json.Unmarshal(response, &status); err != nil { + return Status{}, ErrProtocol + } + if status.Protocol != "1.0" || (status.State != "running" && status.State != "stopping") { + return Status{}, fmt.Errorf("%w: invalid status", ErrProtocol) + } + return status, nil +} diff --git a/internal/sshcontrol/controller/controller_process_test.go b/internal/sshcontrol/controller/controller_process_test.go new file mode 100644 index 00000000..8112a35b --- /dev/null +++ b/internal/sshcontrol/controller/controller_process_test.go @@ -0,0 +1,211 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/Tutitoos/atenea/internal/sshcontrol/handshake" + "github.com/Tutitoos/atenea/internal/sshcontrol/localipc" + "github.com/Tutitoos/atenea/internal/sshcontrol/wire" +) + +const childRootEnv = "ATENEA_SSH_CONTROLLER_TEST_ROOT" +const idRootEnv = "ATENEA_SSH_INSTALLATION_ID_TEST_ROOT" +const idOutputEnv = "ATENEA_SSH_INSTALLATION_ID_TEST_OUTPUT" + +func TestInstallationIDAcrossProcesses(t *testing.T) { + root := filepath.Join(t.TempDir(), "controller") + const callers = 8 + var commands [callers]*exec.Cmd + var outputs [callers]bytes.Buffer + var paths [callers]string + for i := range commands { + paths[i] = filepath.Join(t.TempDir(), "result") + cmd := exec.Command(os.Args[0], "-test.run=^TestInstallationIDSubprocess$", "-test.timeout=20s") + cmd.Env = append(os.Environ(), idRootEnv+"="+root, idOutputEnv+"="+paths[i]) + cmd.Stdout, cmd.Stderr = &outputs[i], &outputs[i] + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + commands[i] = cmd + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + } + want := "" + for i, cmd := range commands { + if err := cmd.Wait(); err != nil { + t.Fatalf("installation ID child %d: %v: %s", i, err, outputs[i].String()) + } + data, err := os.ReadFile(paths[i]) + if err != nil { + t.Fatal(err) + } + if want == "" { + want = string(data) + } else if string(data) != want { + t.Fatalf("separate processes created different installation IDs") + } + } + if current, err := InstallationID(root); err != nil || current != want { + t.Fatalf("published installation ID differs from child processes: %v", err) + } +} + +func TestInstallationIDSubprocess(t *testing.T) { + root, output := os.Getenv(idRootEnv), os.Getenv(idOutputEnv) + if root == "" || output == "" { + t.Skip("child-only") + } + id, err := InstallationID(root) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte(id), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestRestartAfterKilledController(t *testing.T) { + base := t.TempDir() + if runtime.GOOS != "windows" { + shortTemp := "/tmp" + if runtime.GOOS == "darwin" { + shortTemp = "/private/tmp" + } + var err error + base, err = os.MkdirTemp(shortTemp, "a151-restart-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(base) }) + } + root := filepath.Join(base, "controller") + id, err := InstallationID(root) + if err != nil { + t.Fatal(err) + } + + start := func() (*exec.Cmd, *bytes.Buffer) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestControllerSubprocess$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), childRootEnv+"="+root) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + return cmd, &output + } + awaitRunning := func(cmd *exec.Cmd, output *bytes.Buffer) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + status, err := Call(context.Background(), root, id, "status") + if err == nil && status.State == "running" { + return + } + if time.Now().After(deadline) { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("controller did not become ready: %v, output: %s", err, output.String()) + } + time.Sleep(20 * time.Millisecond) + } + } + + first, firstOutput := start() + awaitRunning(first, firstOutput) + otherID := uuid.NewString() + if otherID == id { + t.Fatal("fixture installation IDs collided") + } + if _, err := Call(context.Background(), root, otherID, "stop"); err == nil { + t.Fatalf("different installation could stop controller: %v", err) + } + conn, err := localipc.Dial(root, time.Second) + if err != nil { + t.Fatal(err) + } + if err := conn.SetDeadline(time.Now().Add(time.Second)); err != nil { + _ = conn.Close() + t.Fatal(err) + } + incompatible, err := handshake.New(handshake.Desktop, id) + if err != nil { + _ = conn.Close() + t.Fatal(err) + } + incompatible.ProtocolMajor++ + payload, err := json.Marshal(incompatible) + if err != nil { + _ = conn.Close() + t.Fatal(err) + } + if err := wire.WriteFrame(conn, payload); err != nil { + _ = conn.Close() + t.Fatal(err) + } + if _, err := wire.ReadFrame(conn); err == nil { + _ = conn.Close() + t.Fatal("incompatible protocol received a response") + } + _ = conn.Close() + if status, err := Call(context.Background(), root, id, "status"); err != nil || status.State != "running" { + t.Fatalf("rejected peers disrupted controller: %+v, %v", status, err) + } + contenderCtx, cancelContender := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelContender() + contender := exec.CommandContext(contenderCtx, os.Args[0], "-test.run=^TestControllerSubprocess$", "-test.timeout=10s") + contender.Env = append(os.Environ(), childRootEnv+"="+root) + if output, err := contender.CombinedOutput(); err == nil || contenderCtx.Err() != nil { + t.Fatalf("second controller acquired a live endpoint or hung: %v, output: %s", err, output) + } + if status, err := Call(context.Background(), root, id, "status"); err != nil || status.State != "running" { + t.Fatalf("first controller lost its endpoint: %+v, %v", status, err) + } + if err := first.Process.Kill(); err != nil { + t.Fatal(err) + } + if err := first.Wait(); err == nil { + t.Fatal("killed controller exited successfully") + } + if runtime.GOOS != "windows" { + if _, err := os.Lstat(localipc.Endpoint(root)); err != nil { + t.Fatalf("killed controller left no socket to recover: %v", err) + } + } + + second, secondOutput := start() + awaitRunning(second, secondOutput) + if _, err := Call(context.Background(), root, id, "stop"); err != nil { + t.Fatal(err) + } + if err := second.Wait(); err != nil { + t.Fatalf("restarted controller did not stop cleanly: %v, output: %s", err, secondOutput.String()) + } +} + +// TestControllerSubprocess is only entered by the parent test above. +func TestControllerSubprocess(t *testing.T) { + root := os.Getenv(childRootEnv) + if root == "" { + t.Skip("child-only") + } + id, err := InstallationID(root) + if err != nil { + t.Fatal(err) + } + if err := Serve(context.Background(), root, id); err != nil { + t.Fatal(err) + } +} diff --git a/internal/sshcontrol/controller/controller_test.go b/internal/sshcontrol/controller/controller_test.go new file mode 100644 index 00000000..36ac518c --- /dev/null +++ b/internal/sshcontrol/controller/controller_test.go @@ -0,0 +1,130 @@ +package controller + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" +) + +func TestFixtureControllerLifecycle(t *testing.T) { + var base string + if runtime.GOOS == "windows" { + base = t.TempDir() + } else { + shortTemp := "/tmp" + if runtime.GOOS == "darwin" { + shortTemp = "/private/tmp" + } + var err error + base, err = os.MkdirTemp(shortTemp, "a151-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(base) }) + } + root := filepath.Join(base, "controller") + id, err := InstallationID(root) + if err != nil { + t.Fatal(err) + } + again, err := InstallationID(root) + if err != nil || again != id { + t.Fatalf("unstable id: %q %v", again, err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- Serve(ctx, root, id) }() + deadline := time.Now().Add(3 * time.Second) + for { + select { + case err := <-done: + t.Fatalf("controller exited before ready: %v", err) + default: + } + status, callErr := Call(context.Background(), root, id, "status") + if callErr == nil { + if status.State != "running" { + t.Fatalf("status: %+v", status) + } + break + } + if time.Now().After(deadline) { + t.Fatal(callErr) + } + runtime.Gosched() + } + if _, err := Call(context.Background(), root, id, "exec"); !errors.Is(err, ErrProtocol) { + t.Fatalf("unexpected operation: %v", err) + } + if _, err := Call(context.Background(), root, id, "stop"); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("stop did not terminate") + } +} + +func TestInstallationIDConcurrentCreation(t *testing.T) { + root := filepath.Join(t.TempDir(), "controller") + const callers = 64 + start := make(chan struct{}) + results := make(chan struct { + id string + err error + }, callers) + var workers sync.WaitGroup + for range callers { + workers.Add(1) + go func() { + defer workers.Done() + <-start + id, err := InstallationID(root) + results <- struct { + id string + err error + }{id, err} + }() + } + close(start) + workers.Wait() + close(results) + want := "" + for result := range results { + if result.err != nil { + t.Fatalf("concurrent installation ID: %v", result.err) + } + if want == "" { + want = result.id + } else if result.id != want { + t.Fatalf("two installation IDs: %q and %q", want, result.id) + } + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 1 || entries[0].Name() != "installation.id" { + t.Fatalf("unexpected installation files: %v, %v", entries, err) + } +} + +func TestRequestIsExactAndLimited(t *testing.T) { + for _, raw := range []string{ + `{"Operation":"stop"}`, `{"operation":"exec"}`, `{"operation":"stop","extra":true}`, `{"operation":null}`, + } { + if _, err := decodeRequest([]byte(raw)); !errors.Is(err, ErrProtocol) { + t.Fatalf("accepted %s: %v", raw, err) + } + } + if req, err := decodeRequest([]byte(`{"operation":"status"}`)); err != nil || req.Operation != "status" { + t.Fatalf("valid status: %+v %v", req, err) + } +} diff --git a/internal/sshcontrol/controller/root_unix.go b/internal/sshcontrol/controller/root_unix.go new file mode 100644 index 00000000..5dfe3ee1 --- /dev/null +++ b/internal/sshcontrol/controller/root_unix.go @@ -0,0 +1,25 @@ +//go:build darwin || linux + +package controller + +import ( + "os" + "syscall" + + "github.com/Tutitoos/atenea/internal/sshcontrol/localipc" +) + +func prepareRoot(root string) error { + info, err := os.Lstat(root) + if err != nil { + return err + } + owner, ok := info.Sys().(*syscall.Stat_t) + if !ok || owner.Uid != uint32(os.Geteuid()) { + return localipc.ErrPrivateRoot + } + if err := os.Chmod(root, 0o700); err != nil { + return err + } + return localipc.CheckRoot(root) +} diff --git a/internal/sshcontrol/controller/root_windows.go b/internal/sshcontrol/controller/root_windows.go new file mode 100644 index 00000000..609c7b86 --- /dev/null +++ b/internal/sshcontrol/controller/root_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package controller + +import "github.com/Tutitoos/atenea/internal/sshcontrol/localipc" + +func prepareRoot(root string) error { return localipc.CheckRoot(root) } diff --git a/internal/sshcontrol/handshake/handshake.go b/internal/sshcontrol/handshake/handshake.go new file mode 100644 index 00000000..3077a1d3 --- /dev/null +++ b/internal/sshcontrol/handshake/handshake.go @@ -0,0 +1,205 @@ +// Package handshake negotiates the desktop/controller protocol after the native +// transport has authenticated both peers. The self-reported identifiers below +// provide compatibility and routing checks, never OS-user authentication. +package handshake + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "time" + + "github.com/google/uuid" + + "github.com/Tutitoos/atenea/internal/sshcontrol/wire" +) + +const ( + // Major is the current incompatible protocol version. + Major uint16 = 1 + // Minor is the highest backward-compatible version implemented here. + Minor uint16 = 0 + // Desktop identifies the graphical process, which initiates the exchange. + Desktop = "desktop" + // Controller identifies the independent process, which answers the exchange. + Controller = "controller" + // Timeout bounds handshakes even when the caller has no earlier deadline. + Timeout = 5 * time.Second +) + +var ( + // ErrInvalid reports an invalid closed-schema handshake without input text. + ErrInvalid = errors.New("ssh handshake: invalid message") + // ErrVersion reports a different, unsupported major protocol version. + ErrVersion = errors.New("ssh handshake: incompatible protocol") + // ErrPeer reports an unexpected role or installation identity. + ErrPeer = errors.New("ssh handshake: unexpected peer") + // ErrTransport reports an unsuccessful exchange without transport payloads. + ErrTransport = errors.New("ssh handshake: transport unavailable") +) + +// Hello is a process description, not a credential. InstallationID is provided +// by the installed local package/controller, not accepted from a launch URL. +// SessionInstanceID identifies this process lifetime and changes on restart. +// No per-connection execution authorization is carried forward by this message. +type Hello struct { + ProtocolMajor uint16 `json:"protocol_major"` + ProtocolMinor uint16 `json:"protocol_minor"` + Component string `json:"component"` + InstallationID string `json:"installation_id"` + SessionInstanceID string `json:"session_instance_id"` +} + +// Result is connection-local negotiation metadata. It grants no permission to +// dispatch work, use credentials, open a different user's window or read history. +type Result struct { + Peer Hello + ProtocolMinor uint16 +} + +// New constructs a hello with a fresh process-instance identity. Keep this value +// for the process lifetime; the OS transport must authenticate each connection. +func New(component, installationID string) (Hello, error) { + instance, err := uuid.NewRandom() + if err != nil { + return Hello{}, ErrInvalid + } + hello := Hello{Major, Minor, component, installationID, instance.String()} + if err := hello.validate(); err != nil { + return Hello{}, err + } + return hello, nil +} + +func canonicalID(s string) bool { + id, err := uuid.Parse(s) + return err == nil && id != uuid.Nil && id.String() == s +} + +func (h Hello) validate() error { + if h.ProtocolMajor == 0 || (h.Component != Desktop && h.Component != Controller) || !canonicalID(h.InstallationID) || !canonicalID(h.SessionInstanceID) { + return ErrInvalid + } + return nil +} + +// Decode enforces required, exact-case field names before typed decoding. This +// prevents encoding/json's case-insensitive field matching from accepting two +// differently spelled keys that overwrite the same field. Duplicate JSON keys +// and invalid UTF-8 are rejected by the framing layer, including escaped keys. +func Decode(payload []byte) (Hello, error) { + if err := wire.Validate(payload); err != nil { + return Hello{}, ErrInvalid + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + return Hello{}, ErrInvalid + } + names := [...]string{"protocol_major", "protocol_minor", "component", "installation_id", "session_instance_id"} + if len(fields) != len(names) { + return Hello{}, ErrInvalid + } + for _, name := range names { + value, ok := fields[name] + if !ok || bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return Hello{}, ErrInvalid + } + } + var hello Hello + if err := json.Unmarshal(payload, &hello); err != nil { + return Hello{}, ErrInvalid + } + if err := hello.validate(); err != nil { + return Hello{}, err + } + return hello, nil +} + +// Negotiate checks role, installed identity and version against trusted local +// configuration. A newer minor selects the common supported version; a newer +// major cannot silently downgrade. The local implementation supports only v1.0. +func Negotiate(local, remote Hello) (Result, error) { + if err := local.validate(); err != nil { + return Result{}, err + } + if err := remote.validate(); err != nil { + return Result{}, err + } + if local.ProtocolMajor != Major || local.ProtocolMinor != Minor || remote.ProtocolMajor != Major { + return Result{}, ErrVersion + } + if local.Component == remote.Component || local.InstallationID != remote.InstallationID || local.SessionInstanceID == remote.SessionInstanceID { + return Result{}, ErrPeer + } + return Result{Peer: remote, ProtocolMinor: Minor}, nil +} + +// Exchange performs one bounded handshake on an already OS-authenticated +// connection. It owns the connection exclusively until returning, clears its +// deadline on success, and closes on every failure. Cancellation interrupts a +// blocked read/write. The caller owns and serializes the connection on success. +func Exchange(ctx context.Context, conn net.Conn, local Hello) (result Result, err error) { + if conn == nil { + return Result{}, ErrTransport + } + canceled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { _ = conn.Close(); close(canceled) }) + defer func() { + if !stop() { + <-canceled + err = ctx.Err() + } + if err != nil { + result = Result{} + _ = conn.Close() + } + }() + if err := ctx.Err(); err != nil { + return Result{}, err + } + if err := local.validate(); err != nil { + return Result{}, err + } + if local.ProtocolMajor != Major || local.ProtocolMinor != Minor { + return Result{}, ErrVersion + } + deadline := time.Now().Add(Timeout) + if earlier, ok := ctx.Deadline(); ok && earlier.Before(deadline) { + deadline = earlier + } + if err := conn.SetDeadline(deadline); err != nil { + return Result{}, ErrTransport + } + payload, err := json.Marshal(local) + if err != nil { + return Result{}, ErrInvalid + } + if local.Component == Desktop { + if err := wire.WriteFrame(conn, payload); err != nil { + return Result{}, ErrTransport + } + } + received, err := wire.ReadFrame(conn) + if err != nil { + return Result{}, ErrTransport + } + remote, err := Decode(received) + if err != nil { + return Result{}, err + } + result, err = Negotiate(local, remote) + if err != nil { + return Result{}, err + } + if local.Component == Controller { + if err := wire.WriteFrame(conn, payload); err != nil { + return Result{}, ErrTransport + } + } + if err := conn.SetDeadline(time.Time{}); err != nil { + return Result{}, ErrTransport + } + return result, nil +} diff --git a/internal/sshcontrol/handshake/handshake_test.go b/internal/sshcontrol/handshake/handshake_test.go new file mode 100644 index 00000000..2bc2b42b --- /dev/null +++ b/internal/sshcontrol/handshake/handshake_test.go @@ -0,0 +1,206 @@ +package handshake + +import ( + "context" + "encoding/json" + "errors" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/Tutitoos/atenea/internal/sshcontrol/wire" +) + +const installation = "c29b3c96-5b91-4a31-a6a4-c8df574acabb" + +func hello(t *testing.T, role string) Hello { + t.Helper() + h, err := New(role, installation) + if err != nil { + t.Fatal(err) + } + return h +} + +func TestExactSchema(t *testing.T) { + original := hello(t, Desktop) + payload, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(payload) + if err != nil || decoded != original { + t.Fatalf("valid hello: %v", err) + } + s := string(payload) + inputs := []string{ + strings.Replace(s, `"protocol_major":1`, `"protocol_major":1,"PROTOCOL_MAJOR":2`, 1), + strings.Replace(s, `"protocol_major"`, `"PROTOCOL_MAJOR"`, 1), + strings.Replace(s, `"protocol_minor":0,`, "", 1), + strings.Replace(s, `"protocol_minor":0`, `"protocol_minor":null`, 1), + strings.Replace(s, `"protocol_minor":0`, `"protocol_minor":65536`, 1), + strings.Replace(s, `"protocol_minor":0`, `"protocol_minor":1e999`, 1), + strings.Replace(s, `"protocol_major":1`, `"protocol_major":1,"protocol_major":2`, 1), + strings.Replace(s, installation, strings.ToUpper(installation), 1), + strings.Replace(s, installation, "00000000-0000-0000-0000-000000000000", 1), + strings.Replace(s, `"desktop"`, `"DO_NOT_LOG"`, 1), + } + for i, input := range inputs { + got, err := Decode([]byte(input)) + if !errors.Is(err, ErrInvalid) || got != (Hello{}) { + t.Fatalf("case %d: accepted malformed hello", i) + } + if strings.Contains(err.Error(), "DO_NOT_LOG") { + t.Fatal("error leaked input") + } + } +} + +func TestNegotiation(t *testing.T) { + local, remote := hello(t, Desktop), hello(t, Controller) + remote.ProtocolMinor = 42 + result, err := Negotiate(local, remote) + if err != nil || result.ProtocolMinor != 0 { + t.Fatalf("minor negotiation: %v", err) + } + cases := []struct { + name string + modify func(*Hello) + want error + }{ + {"major", func(h *Hello) { h.ProtocolMajor = 2 }, ErrVersion}, + {"role", func(h *Hello) { h.Component = Desktop }, ErrPeer}, + {"installation", func(h *Hello) { h.InstallationID = "8221ecc6-e0de-42fd-b7eb-8edb5e5a0710" }, ErrPeer}, + {"reflection", func(h *Hello) { h.SessionInstanceID = local.SessionInstanceID }, ErrPeer}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + changed := remote + tc.modify(&changed) + got, err := Negotiate(local, changed) + if !errors.Is(err, tc.want) || got != (Result{}) { + t.Fatalf("negotiation: %v", err) + } + }) + } +} + +func TestExchangePreservesConnectionForNextFrame(t *testing.T) { + desktop, controller := net.Pipe() + defer func() { _ = desktop.Close(); _ = controller.Close() }() + a, b := hello(t, Desktop), hello(t, Controller) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + result, err := Exchange(ctx, controller, b) + if err == nil && result.Peer != a { + err = ErrPeer + } + if err == nil { + err = wire.WriteFrame(controller, []byte(`{"fixture":"ready"}`)) + } + done <- err + }() + result, err := Exchange(ctx, desktop, a) + if err != nil || result.Peer != b { + t.Fatalf("desktop: %v", err) + } + if err := desktop.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + payload, err := wire.ReadFrame(desktop) + if err != nil || string(payload) != `{"fixture":"ready"}` { + t.Fatalf("next frame: %v", err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +type observedConn struct { + net.Conn + reading chan struct{} + once sync.Once +} + +func (c *observedConn) Read(p []byte) (int, error) { + c.once.Do(func() { close(c.reading) }) + return c.Conn.Read(p) +} + +func TestCancellationClosesBlockedExchange(t *testing.T) { + a, b := net.Pipe() + defer func() { _ = a.Close(); _ = b.Close() }() + observed := &observedConn{Conn: a, reading: make(chan struct{})} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + local := hello(t, Controller) + done := make(chan error, 1) + go func() { _, err := Exchange(ctx, observed, local); done <- err }() + select { + case <-observed.reading: + case <-time.After(2 * time.Second): + t.Fatal("exchange did not reach read") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("cancellation did not interrupt read") + } + if _, err := b.Write([]byte{1}); err == nil { + t.Fatal("canceled exchange kept connection open") + } +} + +func TestIncompatiblePeerCannotContinueConnection(t *testing.T) { + a, b := net.Pipe() + defer func() { _ = a.Close(); _ = b.Close() }() + local, remote := hello(t, Controller), hello(t, Desktop) + remote.ProtocolMajor = 2 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { _, err := Exchange(ctx, a, local); done <- err }() + payload, err := json.Marshal(remote) + if err != nil { + t.Fatal(err) + } + if err := wire.WriteFrame(b, payload); err != nil { + t.Fatal(err) + } + if err := <-done; !errors.Is(err, ErrVersion) { + t.Fatalf("version: %v", err) + } + if err := wire.WriteFrame(b, []byte(`{"operation":"dispatch"}`)); err == nil { + t.Fatal("failed handshake left connection usable") + } +} + +func FuzzDecode(f *testing.F) { + f.Add([]byte(`{"protocol_major":1,"protocol_minor":0,"component":"desktop","installation_id":"c29b3c96-5b91-4a31-a6a4-c8df574acabb","session_instance_id":"8221ecc6-e0de-42fd-b7eb-8edb5e5a0710"}`)) + f.Add([]byte(`{"PROTOCOL_MAJOR":1}`)) + f.Fuzz(func(t *testing.T, input []byte) { + h, err := Decode(input) + if err != nil { + if h != (Hello{}) { + t.Fatal("partial hello returned") + } + return + } + encoded, err := json.Marshal(h) + if err != nil { + t.Fatal(err) + } + roundtrip, err := Decode(encoded) + if err != nil || roundtrip != h { + t.Fatalf("roundtrip: %v", err) + } + }) +} diff --git a/internal/sshcontrol/localipc/endpoint.go b/internal/sshcontrol/localipc/endpoint.go new file mode 100644 index 00000000..11c8aa85 --- /dev/null +++ b/internal/sshcontrol/localipc/endpoint.go @@ -0,0 +1,7 @@ +package localipc + +import "errors" + +// ErrEndpointTooLong means the user's state path cannot fit in a native Unix +// socket address. The controller must refuse startup before leaving a lock. +var ErrEndpointTooLong = errors.New("ssh local ipc: endpoint path too long") diff --git a/internal/sshcontrol/localipc/localipc_unix.go b/internal/sshcontrol/localipc/localipc_unix.go new file mode 100644 index 00000000..db194c02 --- /dev/null +++ b/internal/sshcontrol/localipc/localipc_unix.go @@ -0,0 +1,221 @@ +//go:build darwin || linux + +// Package localipc owns the Atenea SSH desktop/controller endpoint. Native +// credential checks are separate from the version handshake: values sent by a +// peer do not establish its OS identity. +package localipc + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/Tutitoos/atenea/internal/ipc" + "github.com/Tutitoos/atenea/internal/pidlock" +) + +const socketName = "atenea-ssh.sock" + +// The shared Unix listener uses the shortest supported sun_path limit. +const maxSocketPath = 103 + +var ( + // ErrPrivateRoot means the endpoint's state root is unsafe for a user-owned socket. + ErrPrivateRoot = errors.New("ssh local ipc: private state root required") + // ErrPeer means the connected process is not the expected OS user. + ErrPeer = errors.New("ssh local ipc: peer identity mismatch") +) + +// Listener accepts only same-UID peers. A separate controller must own this +// listener and its state for the current logged-in account. +type Listener struct { + inner *ipc.Listener + address *net.UnixAddr + release func() + once sync.Once +} + +// Listen binds a private socket below root. The caller chooses a stable, +// absolute, user-owned state root; it must not contain an untrusted symlink. +func Listen(root string) (*Listener, error) { + if err := ensureRoot(root); err != nil { + return nil, err + } + if err := ValidateEndpoint(root); err != nil { + return nil, err + } + run := filepath.Join(root, "run") + if err := ensureRun(run); err != nil { + return nil, err + } + lock := filepath.Join(run, "atenea-ssh.lock") + if err := privateLock(lock); err != nil { + return nil, err + } + path := filepath.Join(run, socketName) + release, err := pidlock.Claim(lock) + if err != nil { + return nil, err + } + inner, err := ipc.Listen(path) + if err != nil { + release() + return nil, err + } + return &Listener{inner: inner, address: &net.UnixAddr{Name: path, Net: "unix"}, release: release}, nil +} + +// Accept returns a connection whose peer UID was verified by the kernel. +func (l *Listener) Accept() (net.Conn, error) { return l.inner.Accept() } + +// Close stops listening and removes the endpoint. +func (l *Listener) Close() error { + err := l.inner.Close() + l.once.Do(l.release) + return err +} + +// Addr identifies this user's socket. +func (l *Listener) Addr() net.Addr { return l.address } + +// Dial verifies the private path, socket owner and server peer UID. It never +// trusts an alias, path string or self-reported handshake identity as a login. +func Dial(root string, timeout time.Duration) (net.Conn, error) { + if timeout <= 0 { + return nil, ErrPeer + } + if err := ValidateEndpoint(root); err != nil { + return nil, err + } + if err := privateDir(root); err != nil { + return nil, err + } + run := filepath.Join(root, "run") + if err := privateDir(run); err != nil { + return nil, err + } + path := filepath.Join(run, socketName) + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0o077 != 0 || !ownedByUs(info) { + return nil, ErrPeer + } + conn, err := ipc.DialTimeout(path, timeout) + if err != nil { + return nil, err + } + unixConn, ok := conn.(*net.UnixConn) + if !ok { + _ = conn.Close() + return nil, ErrPeer + } + same, err := samePeer(unixConn) + if err != nil || !same { + _ = conn.Close() + return nil, ErrPeer + } + // Refuse a socket path swapped during the connection. The peer credential is + // authoritative, and this second check preserves the private endpoint route. + after, err := os.Lstat(path) + if err != nil || !os.SameFile(info, after) { + _ = conn.Close() + return nil, ErrPeer + } + return conn, nil +} + +func ensureRoot(root string) error { + if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root { + return ErrPrivateRoot + } + if err := os.MkdirAll(root, 0o700); err != nil { + return err + } + info, err := os.Lstat(root) + if err != nil { + return err + } + if !info.IsDir() || !ownedByUs(info) { + return ErrPrivateRoot + } + // The dedicated root may have been created under a permissive umask. Tighten + // it before the socket is bound; never chmod a symlink or another owner. + if err := os.Chmod(root, 0o700); err != nil { + return err + } + return privateDir(root) +} + +func privateDir(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode().Perm()&0o077 != 0 || !ownedByUs(info) { + return ErrPrivateRoot + } + return nil +} + +func ensureRun(run string) error { + if err := os.Mkdir(run, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + // Lstat rejects a pre-existing symlink before pidlock or ipc can follow it. + return privateDir(run) +} + +func privateLock(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || !ownedByUs(info) || info.Mode().Perm()&0o077 != 0 || st.Nlink != 1 { + return ErrPrivateRoot + } + return nil +} + +func ownedByUs(info os.FileInfo) bool { + st, ok := info.Sys().(*syscall.Stat_t) + return ok && st.Uid == uint32(os.Geteuid()) +} + +// Endpoint returns the socket path for diagnostics and tests, not for access +// control. Dial still rechecks filesystem and kernel peer identity. +func Endpoint(root string) string { return filepath.Join(root, "run", socketName) } + +// ValidateEndpoint rejects a path that the kernel cannot bind as a socket. +func ValidateEndpoint(root string) error { + if len(Endpoint(root)) > maxSocketPath { + return ErrEndpointTooLong + } + return nil +} + +// Refused reports connections rejected by the native server peer check. +func (l *Listener) Refused() int64 { return l.inner.Refused() } + +var _ net.Listener = (*Listener)(nil) + +// CheckRoot reports configuration errors without creating an endpoint. +func CheckRoot(root string) error { + if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root { + return ErrPrivateRoot + } + if err := privateDir(root); err != nil { + return fmt.Errorf("ssh local ipc: %w", err) + } + return nil +} diff --git a/internal/sshcontrol/localipc/localipc_unix_test.go b/internal/sshcontrol/localipc/localipc_unix_test.go new file mode 100644 index 00000000..4553d0e2 --- /dev/null +++ b/internal/sshcontrol/localipc/localipc_unix_test.go @@ -0,0 +1,298 @@ +//go:build darwin || linux + +package localipc + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Tutitoos/atenea/internal/pidlock" + "github.com/Tutitoos/atenea/internal/sshcontrol/handshake" +) + +func TestLongEndpointFailsBeforeSocketSideEffects(t *testing.T) { + root := filepath.Join(t.TempDir(), strings.Repeat("x", maxSocketPath)) + if err := ValidateEndpoint(root); !errors.Is(err, ErrEndpointTooLong) { + t.Fatalf("endpoint validation: %v", err) + } + if _, err := Listen(root); !errors.Is(err, ErrEndpointTooLong) { + t.Fatalf("listener: %v", err) + } + if _, err := Dial(root, time.Second); !errors.Is(err, ErrEndpointTooLong) { + t.Fatalf("dial: %v", err) + } + if _, err := os.Lstat(filepath.Join(root, "run")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("long endpoint created a socket directory: %v", err) + } +} + +func TestNativeSocketAndSingleOwner(t *testing.T) { + root := shortRoot(t) + listener, err := Listen(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + done := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + done <- err + return + } + defer func() { _ = conn.Close() }() + var data [1]byte + if _, err = io.ReadFull(conn, data[:]); err == nil && data[0] != 42 { + err = ErrPeer + } + if err == nil { + _, err = conn.Write([]byte{43}) + } + done <- err + }() + conn, err := Dial(root, time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + if _, err := conn.Write([]byte{42}); err != nil { + t.Fatal(err) + } + var data [1]byte + if _, err := io.ReadFull(conn, data[:]); err != nil || data[0] != 43 { + t.Fatalf("reply: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("accept did not finish") + } + if listener.Refused() != 0 { + t.Fatal("same user was refused") + } + if _, err := Listen(root); !errors.Is(err, pidlock.ErrHeld) { + t.Fatalf("second owner: %v", err) + } +} + +func TestPrivateRootAndSocketMode(t *testing.T) { + t.Run("relative", func(t *testing.T) { + if _, err := Listen("relative"); !errors.Is(err, ErrPrivateRoot) { + t.Fatal(err) + } + }) + t.Run("world-readable", func(t *testing.T) { + root := shortRoot(t) + if err := os.Chmod(root, 0o755); err != nil { + t.Fatal(err) + } + listener, err := Listen(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + info, err := os.Lstat(root) + if err != nil || info.Mode().Perm() != 0o700 { + t.Fatalf("root was not tightened: %v", err) + } + if err := os.Chmod(root, 0o755); err != nil { + t.Fatal(err) + } + if _, err := Dial(root, time.Second); !errors.Is(err, ErrPrivateRoot) { + t.Fatalf("widened root accepted: %v", err) + } + }) + t.Run("symlink", func(t *testing.T) { + root := shortRoot(t) + link := filepath.Join(t.TempDir(), "linked") + if err := os.Symlink(root, link); err != nil { + t.Fatal(err) + } + if _, err := Listen(link); !errors.Is(err, ErrPrivateRoot) { + t.Fatal(err) + } + }) + t.Run("run symlink", func(t *testing.T) { + root := shortRoot(t) + target := shortRoot(t) + if err := os.Symlink(target, filepath.Join(root, "run")); err != nil { + t.Fatal(err) + } + if _, err := Listen(root); !errors.Is(err, ErrPrivateRoot) { + t.Fatalf("run symlink accepted: %v", err) + } + if _, err := os.Lstat(filepath.Join(target, "atenea-ssh.lock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("lock created outside private root: %v", err) + } + }) + t.Run("lock symlink", func(t *testing.T) { + root := shortRoot(t) + run := filepath.Join(root, "run") + if err := os.Mkdir(run, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "numeric-file") + if err := os.WriteFile(target, []byte("99999999"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(run, "atenea-ssh.lock")); err != nil { + t.Fatal(err) + } + if _, err := Listen(root); !errors.Is(err, ErrPrivateRoot) { + t.Fatalf("lock symlink accepted: %v", err) + } + got, err := os.ReadFile(target) + if err != nil || string(got) != "99999999" { + t.Fatalf("linked file changed: %q, %v", got, err) + } + }) + t.Run("lock hardlink", func(t *testing.T) { + root := shortRoot(t) + run := filepath.Join(root, "run") + if err := os.Mkdir(run, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "numeric-file") + if err := os.WriteFile(target, []byte("99999999"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(target, filepath.Join(run, "atenea-ssh.lock")); err != nil { + t.Fatal(err) + } + if _, err := Listen(root); !errors.Is(err, ErrPrivateRoot) { + t.Fatalf("lock hardlink accepted: %v", err) + } + got, err := os.ReadFile(target) + if err != nil || string(got) != "99999999" { + t.Fatalf("linked file changed: %q, %v", got, err) + } + }) + t.Run("socket widened", func(t *testing.T) { + root := shortRoot(t) + listener, err := Listen(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + path := Endpoint(root) + if err := os.Chmod(path, 0o666); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(path, 0o600) }() + if _, err := Dial(root, time.Second); !errors.Is(err, ErrPeer) { + t.Fatal(err) + } + }) +} + +func TestExistingFileAndRestart(t *testing.T) { + root := shortRoot(t) + run := filepath.Join(root, "run") + if err := os.Mkdir(run, 0o700); err != nil { + t.Fatal(err) + } + path := Endpoint(root) + if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Listen(root); err == nil { + t.Fatal("overwrote existing file") + } + content, err := os.ReadFile(path) + if err != nil || string(content) != "keep" { + t.Fatalf("foreign file changed: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + listener, err := Listen(root) + if err != nil { + t.Fatal(err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + listener, err = Listen(root) + if err != nil { + t.Fatalf("restart: %v", err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } +} + +func shortRoot(t *testing.T) string { + t.Helper() + base, err := filepath.EvalSymlinks("/tmp") + if err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(base, "as-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + return root +} + +func TestNativeConnectionAndProtocolHandshake(t *testing.T) { + root := shortRoot(t) + listener, err := Listen(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + controllerHello, err := handshake.New(handshake.Controller, "c29b3c96-5b91-4a31-a6a4-c8df574acabb") + if err != nil { + t.Fatal(err) + } + desktopHello, err := handshake.New(handshake.Desktop, controllerHello.InstallationID) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + done <- err + return + } + defer func() { _ = conn.Close() }() + result, err := handshake.Exchange(ctx, conn, controllerHello) + if err == nil && result.Peer != desktopHello { + err = ErrPeer + } + done <- err + }() + conn, err := Dial(root, time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + result, err := handshake.Exchange(ctx, conn, desktopHello) + if err != nil || result.Peer != controllerHello { + t.Fatalf("native handshake: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} diff --git a/internal/sshcontrol/localipc/localipc_windows.go b/internal/sshcontrol/localipc/localipc_windows.go new file mode 100644 index 00000000..b8178a6c --- /dev/null +++ b/internal/sshcontrol/localipc/localipc_windows.go @@ -0,0 +1,233 @@ +//go:build windows + +// Package localipc owns the Atenea SSH desktop/controller endpoint. Windows +// pipe ACLs and peer process tokens are checked before protocol handshakes. +package localipc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net" + "path/filepath" + "sync/atomic" + "time" + + winio "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +var ( + // ErrPrivateRoot means an invalid installation scope was passed. + ErrPrivateRoot = errors.New("ssh local ipc: absolute installation root required") + // ErrPeer means the connected process is not this Windows account and session. + ErrPeer = errors.New("ssh local ipc: peer identity mismatch") +) + +// Listener accepts named-pipe clients after a kernel process/token check. +type Listener struct { + inner net.Listener + identity peerIdentity + refused atomic.Int64 +} + +type peerIdentity struct { + sid string + session uint32 +} + +// Listen creates one instance per installation/account/logon session. The pipe +// DACL allows only the current user's SID; Accept independently verifies the +// client process token and session. A GUI cannot run in session zero by default. +func Listen(root string) (*Listener, error) { + path, id, err := pipePath(root) + if err != nil { + return nil, err + } + config := &winio.PipeConfig{SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;%s)", id.sid), InputBufferSize: 64 << 10, OutputBufferSize: 64 << 10} + inner, err := winio.ListenPipe(path, config) + if err != nil { + return nil, err + } + return &Listener{inner: inner, identity: id}, nil +} + +// Accept discards connections whose pipe peer cannot be independently mapped +// to the current account and graphical logon session. +func (l *Listener) Accept() (net.Conn, error) { + for { + conn, err := l.inner.Accept() + if err != nil { + return nil, err + } + if verifyPeer(conn, l.identity, false) == nil { + return conn, nil + } + _ = conn.Close() + l.refused.Add(1) + } +} + +// Close stops accepting and releases the pipe name. +func (l *Listener) Close() error { return l.inner.Close() } + +// Addr identifies the named pipe. +func (l *Listener) Addr() net.Addr { return l.inner.Addr() } + +// Refused reports connections rejected after the DACL check. +func (l *Listener) Refused() int64 { return l.refused.Load() } + +// Dial verifies both the pipe's private name and the kernel-reported server +// process token/session. The process cannot authenticate itself with a hello. +func Dial(root string, timeout time.Duration) (net.Conn, error) { + if timeout <= 0 { + return nil, ErrPeer + } + path, id, err := pipePath(root) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := winio.DialPipeContext(ctx, path) + if err != nil { + return nil, err + } + if err := verifyPeer(conn, id, true); err != nil { + _ = conn.Close() + return nil, ErrPeer + } + return conn, nil +} + +// Endpoint returns a name for diagnostics, never authority to connect. +func Endpoint(root string) string { + path, _, err := pipePath(root) + if err != nil { + return "" + } + return path +} + +// Windows named-pipe names have no Unix sun_path limit. +func ValidateEndpoint(string) error { return nil } + +// CheckRoot validates the installation scope without opening a pipe. +func CheckRoot(root string) error { + if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root { + return ErrPrivateRoot + } + return nil +} + +func pipePath(root string) (string, peerIdentity, error) { + if err := CheckRoot(root); err != nil { + return "", peerIdentity{}, err + } + physicalID, err := rootIdentity(root) + if err != nil { + return "", peerIdentity{}, err + } + id, err := currentIdentity() + if err != nil { + return "", peerIdentity{}, err + } + // Directory file identity is stable across case, short-path and junction + // aliases. Hashing the path text would let two controllers own one store. + digest := sha256.Sum256([]byte(physicalID + ":" + id.sid)) + name := `\\.\pipe\atenea-ssh-` + hex.EncodeToString(digest[:12]) + fmt.Sprintf("-%d", id.session) + return name, id, nil +} + +func rootIdentity(root string) (string, error) { + path, err := windows.UTF16PtrFromString(root) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrPrivateRoot, err) + } + handle, err := windows.CreateFile(path, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrPrivateRoot, err) + } + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "", fmt.Errorf("%w: %v", ErrPrivateRoot, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 || + (info.VolumeSerialNumber == 0 && info.FileIndexHigh == 0 && info.FileIndexLow == 0) { + return "", ErrPrivateRoot + } + return fmt.Sprintf("%08x-%08x-%08x", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil +} + +func currentIdentity() (peerIdentity, error) { + token, err := windows.OpenCurrentProcessToken() + if err != nil { + return peerIdentity{}, err + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return peerIdentity{}, err + } + var session uint32 + if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &session); err != nil { + return peerIdentity{}, err + } + if session == 0 { + return peerIdentity{}, ErrPeer + } + return peerIdentity{sid: user.User.Sid.String(), session: session}, nil +} + +func verifyPeer(conn net.Conn, want peerIdentity, server bool) error { + withHandle, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return ErrPeer + } + handle := windows.Handle(withHandle.Fd()) + var pid uint32 + var err error + if server { + err = windows.GetNamedPipeServerProcessId(handle, &pid) + } else { + err = windows.GetNamedPipeClientProcessId(handle, &pid) + } + if err != nil || pid == 0 { + return ErrPeer + } + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return ErrPeer + } + defer windows.CloseHandle(process) + var token windows.Token + if err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token); err != nil { + return ErrPeer + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil || user == nil || user.User.Sid == nil || user.User.Sid.String() != want.sid { + return ErrPeer + } + var session uint32 + if err := windows.ProcessIdToSessionId(pid, &session); err != nil || session != want.session { + return ErrPeer + } + var again uint32 + if server { + err = windows.GetNamedPipeServerProcessId(handle, &again) + } else { + err = windows.GetNamedPipeClientProcessId(handle, &again) + } + if err != nil || again != pid { + return ErrPeer + } + return nil +} + +var _ net.Listener = (*Listener)(nil) diff --git a/internal/sshcontrol/localipc/localipc_windows_test.go b/internal/sshcontrol/localipc/localipc_windows_test.go new file mode 100644 index 00000000..76ab9e82 --- /dev/null +++ b/internal/sshcontrol/localipc/localipc_windows_test.go @@ -0,0 +1,61 @@ +//go:build windows + +package localipc + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRootIdentityUsesDirectoryNotPathSpelling(t *testing.T) { + root := t.TempDir() + want, err := rootIdentity(root) + if err != nil { + t.Fatal(err) + } + caseAlias := strings.ToUpper(root) + got, err := rootIdentity(caseAlias) + if err != nil || got != want { + t.Fatalf("case alias has a different identity: %q, %v", got, err) + } + t.Run("symlink alias", func(t *testing.T) { + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("directory symlink requires permission on this Windows runner: %v", err) + } + got, err := rootIdentity(alias) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("one installation has two pipe identities: %q and %q", want, got) + } + }) +} + +func TestRootIdentityRejectsNonDirectory(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := rootIdentity(file); !errors.Is(err, ErrPrivateRoot) { + t.Fatalf("ordinary file accepted as an installation root: %v", err) + } +} + +func TestNamedPipeHasOneOwner(t *testing.T) { + root := t.TempDir() + first, err := Listen(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = first.Close() }() + second, err := Listen(root) + if err == nil { + _ = second.Close() + t.Fatal("second controller took the same endpoint") + } +} diff --git a/internal/sshcontrol/localipc/peer_darwin.go b/internal/sshcontrol/localipc/peer_darwin.go new file mode 100644 index 00000000..fe4a0eff --- /dev/null +++ b/internal/sshcontrol/localipc/peer_darwin.go @@ -0,0 +1,26 @@ +//go:build darwin + +package localipc + +import ( + "net" + "os" + + "golang.org/x/sys/unix" +) + +func samePeer(conn *net.UnixConn) (bool, error) { + raw, err := conn.SyscallConn() + if err != nil { + return false, err + } + var cred *unix.Xucred + var readErr error + if err := raw.Control(func(fd uintptr) { cred, readErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) }); err != nil { + return false, err + } + if readErr != nil { + return false, readErr + } + return cred != nil && cred.Uid == uint32(os.Geteuid()), nil +} diff --git a/internal/sshcontrol/localipc/peer_linux.go b/internal/sshcontrol/localipc/peer_linux.go new file mode 100644 index 00000000..1669de23 --- /dev/null +++ b/internal/sshcontrol/localipc/peer_linux.go @@ -0,0 +1,27 @@ +//go:build linux + +package localipc + +import ( + "net" + "os" + "syscall" +) + +func samePeer(conn *net.UnixConn) (bool, error) { + raw, err := conn.SyscallConn() + if err != nil { + return false, err + } + var cred *syscall.Ucred + var readErr error + if err := raw.Control(func(fd uintptr) { + cred, readErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) + }); err != nil { + return false, err + } + if readErr != nil { + return false, readErr + } + return cred != nil && int(cred.Uid) == os.Geteuid(), nil +} diff --git a/internal/sshcontrol/wire/frame.go b/internal/sshcontrol/wire/frame.go new file mode 100644 index 00000000..a398d0bd --- /dev/null +++ b/internal/sshcontrol/wire/frame.go @@ -0,0 +1,171 @@ +// Package wire implements the bounded JSON framing shared by the SSH desktop +// app, its local controller, and the remote connector. Framing is not +// authentication: callers must separately verify the transport peer and validate +// operation-specific schemas before invoking any action. Transport owners must +// also impose read/write deadlines and connection concurrency limits. +package wire + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "io" + "unicode/utf8" +) + +const ( + // MaxFrameBytes is checked before allocating the payload buffer. + MaxFrameBytes = 1 << 20 + // MaxDepth bounds object/array nesting independently of the byte limit. + MaxDepth = 64 +) + +var ( + // ErrFrameSize reports a zero-length or oversized frame. + ErrFrameSize = errors.New("ssh wire: invalid frame size") + // ErrInvalidJSON reports malformed or ambiguous JSON without payload text. + ErrInvalidJSON = errors.New("ssh wire: invalid JSON object") +) + +// ReadFrame reads exactly one frame. On any non-EOF error the caller must close +// the connection, rather than attempt to resynchronize or dispatch partial data. +// JSON errors deliberately omit payload fragments, which may contain prompts. +func ReadFrame(r io.Reader) ([]byte, error) { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, err + } + size := binary.BigEndian.Uint32(header[:]) + if size == 0 || size > MaxFrameBytes { + return nil, ErrFrameSize + } + payload := make([]byte, int(size)) + if _, err := io.ReadFull(r, payload); err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return nil, err + } + if err := Validate(payload); err != nil { + return nil, err + } + return payload, nil +} + +// WriteFrame validates before writing any bytes. A partial transport write is an +// uncertain delivery, not permission to replay an execution on a new connection. +// One writer must serialize frames for a connection; this function does not lock. +func WriteFrame(w io.Writer, payload []byte) error { + if err := Validate(payload); err != nil { + return err + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) + if err := writeAll(w, header[:]); err != nil { + return err + } + return writeAll(w, payload) +} + +func writeAll(w io.Writer, data []byte) error { + for len(data) > 0 { + n, err := w.Write(data) + if n < 0 || n > len(data) { + return io.ErrShortWrite + } + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} + +// Validate accepts one UTF-8 JSON object, rejecting duplicate keys at every +// nesting level (including escaped equivalents), extra JSON values and excessive +// depth. It does not deserialize numbers into floating point values. +func Validate(payload []byte) error { + if len(payload) == 0 || len(payload) > MaxFrameBytes { + return ErrFrameSize + } + if !utf8.Valid(payload) { + return ErrInvalidJSON + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return ErrInvalidJSON + } + if err := object(decoder, 1); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return ErrInvalidJSON + } + return nil +} + +func object(decoder *json.Decoder, depth int) error { + if depth > MaxDepth { + return ErrInvalidJSON + } + keys := make(map[string]struct{}) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return ErrInvalidJSON + } + key, ok := token.(string) + if !ok { + return ErrInvalidJSON + } + if _, exists := keys[key]; exists { + return ErrInvalidJSON + } + keys[key] = struct{}{} + if err := value(decoder, depth); err != nil { + return err + } + } + token, err := decoder.Token() + if err != nil || token != json.Delim('}') { + return ErrInvalidJSON + } + return nil +} + +func value(decoder *json.Decoder, depth int) error { + token, err := decoder.Token() + if err != nil { + return ErrInvalidJSON + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + return object(decoder, depth+1) + case '[': + if depth+1 > MaxDepth { + return ErrInvalidJSON + } + for decoder.More() { + if err := value(decoder, depth+1); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil || end != json.Delim(']') { + return ErrInvalidJSON + } + return nil + default: + return ErrInvalidJSON + } +} diff --git a/internal/sshcontrol/wire/frame_test.go b/internal/sshcontrol/wire/frame_test.go new file mode 100644 index 00000000..2ee4e704 --- /dev/null +++ b/internal/sshcontrol/wire/frame_test.go @@ -0,0 +1,144 @@ +package wire + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "strings" + "testing" +) + +func TestFramesPreserveBytesAndBoundaries(t *testing.T) { + first := []byte(`{"prompt":"literal spacing \n café","body":{"n":1}}`) + second := []byte(`{"operation":"status"}`) + var stream bytes.Buffer + for _, payload := range [][]byte{first, second} { + if err := WriteFrame(&stream, payload); err != nil { + t.Fatal(err) + } + } + for _, want := range [][]byte{first, second} { + got, err := ReadFrame(&stream) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("round trip: %v", err) + } + } + if _, err := ReadFrame(&stream); !errors.Is(err, io.EOF) { + t.Fatalf("end: %v", err) + } +} + +func TestInvalidFramesWriteNothing(t *testing.T) { + cases := []string{ + "", `[]`, `null`, `{"a":1,"a":2}`, `{"a":1,"\u0061":2}`, + `{"nested":[{"x":1,"x":2}]}`, `{"ok":1} {"extra":2}`, + `{"secret":"DO_NOT_LOG",}`, `{"n":NaN}`, `{"n":01}`, "{\"x\":\"\xff\"}", + `{"x":` + strings.Repeat("[", MaxDepth) + "0" + strings.Repeat("]", MaxDepth) + "}", + `{"x":"` + strings.Repeat("a", MaxFrameBytes) + `"}`, + } + for index, input := range cases { + var dst bytes.Buffer + err := WriteFrame(&dst, []byte(input)) + if err == nil || dst.Len() != 0 { + t.Fatalf("case %d accepted or wrote partial frame", index) + } + if strings.Contains(err.Error(), "DO_NOT_LOG") { + t.Fatal("payload in error") + } + } +} + +type headerOnlyReader struct { + header *bytes.Reader + payloadReads int +} + +func (r *headerOnlyReader) Read(p []byte) (int, error) { + if r.header.Len() == 0 { + r.payloadReads++ + return 0, errors.New("payload must not be read") + } + return r.header.Read(p) +} + +func TestOversizeRejectedBeforePayloadRead(t *testing.T) { + for _, size := range []uint32{0, MaxFrameBytes + 1, ^uint32(0)} { + var header [4]byte + binary.BigEndian.PutUint32(header[:], size) + reader := &headerOnlyReader{header: bytes.NewReader(header[:])} + payload, err := ReadFrame(reader) + if !errors.Is(err, ErrFrameSize) || payload != nil || reader.payloadReads != 0 { + t.Fatal("invalid length reached payload reader") + } + } +} + +func TestTruncationNeverReturnsPayload(t *testing.T) { + var stream bytes.Buffer + if err := WriteFrame(&stream, []byte(`{"prompt":"private"}`)); err != nil { + t.Fatal(err) + } + complete := stream.Bytes() + for n := 1; n < len(complete); n++ { + payload, err := ReadFrame(bytes.NewReader(complete[:n])) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("prefix %d: %v", n, err) + } + if payload != nil { + t.Fatal("returned truncated payload") + } + } +} + +type shortWriter struct{ bytes.Buffer } + +func (w *shortWriter) Write(p []byte) (int, error) { + if len(p) > 2 { + p = p[:2] + } + return w.Buffer.Write(p) +} + +type stuckWriter struct{} + +func (stuckWriter) Write([]byte) (int, error) { return 0, nil } + +func TestShortWritesAndStalledWriter(t *testing.T) { + var writer shortWriter + payload := []byte(`{"operation":"hello"}`) + if err := WriteFrame(&writer, payload); err != nil { + t.Fatal(err) + } + got, err := ReadFrame(&writer) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("short writes: %v", err) + } + if err := WriteFrame(stuckWriter{}, payload); !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("stalled writer: %v", err) + } +} + +func FuzzReadFrame(f *testing.F) { + f.Add([]byte{0, 0, 0, 2, '{', '}'}) + f.Add([]byte{255, 255, 255, 255}) + f.Fuzz(func(t *testing.T, input []byte) { + payload, err := ReadFrame(bytes.NewReader(input)) + if err != nil { + if payload != nil { + t.Fatal("partial payload on error") + } + return + } + if len(payload) > MaxFrameBytes { + t.Fatal("oversized frame") + } + var output bytes.Buffer + if err := WriteFrame(&output, payload); err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(input, output.Bytes()) { + t.Fatal("framing changed input") + } + }) +}