feat(#1906): non-root weston gui on a fixed vnc port
This commit is contained in:
parent
5caec9c1a9
commit
3d2e0ef561
5 changed files with 127 additions and 80 deletions
|
|
@ -253,18 +253,27 @@ compositor with the VNC backend, surfaced as
|
||||||
`/screen/ws` WebSocket relay (`docs/web-ui/agent.md::Per-agent endpoints`)
|
`/screen/ws` WebSocket relay (`docs/web-ui/agent.md::Per-agent endpoints`)
|
||||||
connects to the compositor at `127.0.0.1:<vnc_port>`.
|
connects to the compositor at `127.0.0.1:<vnc_port>`.
|
||||||
|
|
||||||
- **Port allocation**: deterministic FNV-1a of the agent name
|
- **Port allocation**: a **fixed** port (`hyperhive.gui.vncPort`,
|
||||||
(read from `/etc/hostname`, leading `h-` stripped) mapped into
|
default 5900). No per-agent hashing: network isolation is
|
||||||
`[15900, 16799]`. Mirrors the agent web-UI port pattern from
|
unconditional (each agent has its own netns — see
|
||||||
`docs/gotchas.md::Web UI ports collide on hash` — same FNV-1a
|
`docs/network.md#container-isolation`), so the VNC port is
|
||||||
constant, different range. The compositor's startup script writes
|
container-local and can't collide across agents. The harness learns
|
||||||
`/etc/hyperhive/gui.json = {"vnc_port":N,"auth":"none","wayland_display":"wayland-0"}`
|
the port from the `HIVE_GUI_VNC_PORT` env var (set on the harness
|
||||||
so the harness reads the port at runtime; no nix-side / harness-side hash
|
service when `gui.enable`) — no marker file, no runtime hash. (Unlike
|
||||||
duplication.
|
the agent **web-UI** port, which is still an FNV-1a hash because those
|
||||||
|
listen on the shared host stack — see `Web UI ports collide on hash`.)
|
||||||
|
- **Non-root, shared user session**: weston runs as the agent's own
|
||||||
|
user (`hyperhive.user.name`, the same user hive-ag3nt runs as), not
|
||||||
|
root, so the GUI and the agent share one session. The runtime dir is a
|
||||||
|
fixed `/run/gui` (systemd `RuntimeDirectory=gui`, `0700`,
|
||||||
|
`RuntimeDirectoryPreserve=yes` so it survives weston restarts for the
|
||||||
|
wayland client sharing the `/run/gui/wayland-0` socket). Wayland
|
||||||
|
clients in the agent's config (e.g. bitburner electron) must run as the
|
||||||
|
same user with `XDG_RUNTIME_DIR=/run/gui`.
|
||||||
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
|
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
|
||||||
launched with `--socket=wayland-0` so the socket path is
|
launched with `--socket=wayland-0` so the socket path is
|
||||||
deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
||||||
and `XDG_RUNTIME_DIR=/run/user/0` as global system environment
|
and `XDG_RUNTIME_DIR=/run/gui` as global system environment
|
||||||
variables (gated on `hyperhive.gui.enable`) so every systemd service
|
variables (gated on `hyperhive.gui.enable`) so every systemd service
|
||||||
in the container inherits them. Without this, services starting
|
in the container inherits them. Without this, services starting
|
||||||
Wayland clients could not find the compositor — libwayland falls
|
Wayland clients could not find the compositor — libwayland falls
|
||||||
|
|
|
||||||
|
|
@ -355,9 +355,9 @@ shaped).
|
||||||
land on the right pixel regardless of CSS scale.
|
land on the right pixel regardless of CSS scale.
|
||||||
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
|
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
|
||||||
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
||||||
Transparent to any RFB variant. VNC port comes from
|
Transparent to any RFB variant. VNC port comes from the
|
||||||
`/etc/hyperhive/gui.json` (written by the weston startup
|
`HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
|
||||||
script in `weston-vnc.nix`).
|
service when `hyperhive.gui.enable`; see `weston-vnc.nix`).
|
||||||
|
|
||||||
Bus events (new vocabulary on `/events/stream`):
|
Bus events (new vocabulary on `/events/stream`):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ struct AppState {
|
||||||
files: TurnFiles,
|
files: TurnFiles,
|
||||||
/// Prevents `/api/compact` from racing with an in-flight normal turn.
|
/// Prevents `/api/compact` from racing with an in-flight normal turn.
|
||||||
turn_lock: TurnLock,
|
turn_lock: TurnLock,
|
||||||
/// VNC port read from `/etc/hyperhive/gui.json` at startup.
|
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
|
||||||
/// `None` when the file is absent (gui not enabled for this agent).
|
/// `None` when unset (gui not enabled for this agent).
|
||||||
gui_vnc_port: Option<u16>,
|
gui_vnc_port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ pub async fn serve(
|
||||||
files: TurnFiles,
|
files: TurnFiles,
|
||||||
turn_lock: TurnLock,
|
turn_lock: TurnLock,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let gui_vnc_port = read_gui_json();
|
let gui_vnc_port = read_gui_vnc_port();
|
||||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.context(
|
.context(
|
||||||
|
|
@ -277,12 +277,14 @@ async fn serve_icon() -> impl IntoResponse {
|
||||||
([("content-type", "image/svg+xml")], body)
|
([("content-type", "image/svg+xml")], body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field.
|
/// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var
|
||||||
/// Returns `None` if the file is absent or unparseable — GUI not enabled.
|
/// the harness service sets when gui is enabled (see weston-vnc.nix).
|
||||||
fn read_gui_json() -> Option<u16> {
|
/// `None` when unset (gui not enabled for this agent) or unparseable.
|
||||||
let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?;
|
/// The port is a fixed, container-local value — no per-agent hashing, no
|
||||||
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
|
/// marker file — because network isolation is unconditional (each agent
|
||||||
val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok())
|
/// has its own netns, so the port can't collide across containers).
|
||||||
|
fn read_gui_vnc_port() -> Option<u16> {
|
||||||
|
std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebSocket handler: upgrade then pump bytes between the WS client and
|
/// WebSocket handler: upgrade then pump bytes between the WS client and
|
||||||
|
|
|
||||||
|
|
@ -1772,7 +1772,16 @@ in
|
||||||
# bind-mounts and gateway upstream config stay in sync.
|
# bind-mounts and gateway upstream config stay in sync.
|
||||||
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
||||||
}
|
}
|
||||||
// otelEnv;
|
// otelEnv
|
||||||
|
// lib.optionalAttrs config.hyperhive.gui.enable {
|
||||||
|
# Tells the harness which fixed VNC port weston bound, and (by
|
||||||
|
# its presence) that gui is enabled — the harness `/screen/ws`
|
||||||
|
# relay reads this instead of a runtime marker file. The port is
|
||||||
|
# container-local + fixed (network isolation is unconditional),
|
||||||
|
# so the same value for every gui agent is fine. See
|
||||||
|
# nix/templates/weston-vnc.nix::hyperhive.gui.vncPort.
|
||||||
|
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
||||||
|
};
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
|
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
|
||||||
# Pin the journal identity to the binary name. Without this,
|
# Pin the journal identity to the binary name. Without this,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,27 @@
|
||||||
config,
|
config,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
let
|
||||||
|
# GUI processes run as the agent's own non-root user — the same user
|
||||||
|
# hive-ag3nt runs as (declared + home-chowned by harness-base.nix) — so
|
||||||
|
# weston, the wayland client, and the agent share one user session.
|
||||||
|
# `hyperhive.user.name` is set per-agent by the meta-flake renderer.
|
||||||
|
userName = config.hyperhive.user.name;
|
||||||
|
|
||||||
|
# Static weston config. `[core] idle-time=0` disables the 300s idle /
|
||||||
|
# lock screen; `[vnc] auth-method=none` + the `--disable-transport-
|
||||||
|
# layer-security` flag below advertise plain auth types directly. A
|
||||||
|
# store file (not a runtime mktemp) so weston's ExecStart is a direct
|
||||||
|
# exec — no wrapper script, which keeps the journal SyslogIdentifier
|
||||||
|
# clean (`weston`, not a store-path basename).
|
||||||
|
westonIni = pkgs.writeText "weston.ini" ''
|
||||||
|
[core]
|
||||||
|
idle-time=0
|
||||||
|
|
||||||
|
[vnc]
|
||||||
|
auth-method=none
|
||||||
|
'';
|
||||||
|
in
|
||||||
{
|
{
|
||||||
# Optional Weston (Wayland compositor) with the VNC backend,
|
# Optional Weston (Wayland compositor) with the VNC backend,
|
||||||
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
|
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
|
||||||
|
|
@ -25,16 +46,49 @@
|
||||||
relay. Renders in software (pixman) — no GPU, DRM, or VT
|
relay. Renders in software (pixman) — no GPU, DRM, or VT
|
||||||
access, so no extra container capabilities are needed.
|
access, so no extra container capabilities are needed.
|
||||||
|
|
||||||
The VNC port is a deterministic FNV-1a hash of the agent name
|
Weston binds a fixed VNC port (`hyperhive.gui.vncPort`) on the
|
||||||
mapped into `[15900, 16799]`, written to
|
container's own loopback. Network isolation is unconditional
|
||||||
`/etc/hyperhive/gui.json` at service start so the harness can
|
(each agent has its own netns), so a fixed port can't collide
|
||||||
relay connections without a separate config flag. The unit is
|
across containers — no per-agent hashing needed. The harness
|
||||||
`Type = "simple"` so a misconfigured weston degrades to a
|
learns the port from the `HIVE_GUI_VNC_PORT` env var (set by the
|
||||||
restart loop instead of blocking `nixos-container update`.
|
harness service when gui is enabled). The unit is
|
||||||
|
`Type = "simple"` so a misconfigured weston degrades to a restart
|
||||||
|
loop instead of blocking `nixos-container update`.
|
||||||
|
|
||||||
|
Weston and the wayland client run as the agent's own non-root
|
||||||
|
user (`hyperhive.user.name`), sharing one user session with a
|
||||||
|
fixed `XDG_RUNTIME_DIR=/run/gui`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Fixed VNC port weston binds inside the container. Safe to be the
|
||||||
|
# same for every agent because network isolation is unconditional
|
||||||
|
# (private netns per container — see hive-network.nix), so the port
|
||||||
|
# is container-local and can't collide. Internal: the harness reads
|
||||||
|
# the value via the `HIVE_GUI_VNC_PORT` env var the harness service
|
||||||
|
# injects from this option, not directly.
|
||||||
|
options.hyperhive.gui.vncPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 5900;
|
||||||
|
internal = true;
|
||||||
|
description = ''
|
||||||
|
VNC port weston binds inside the container (default 5900, the
|
||||||
|
standard VNC port). Container-local, so the same value for every
|
||||||
|
agent is fine. Surfaced to the harness as `HIVE_GUI_VNC_PORT`.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf config.hyperhive.gui.enable {
|
config = lib.mkIf config.hyperhive.gui.enable {
|
||||||
|
# The GUI must run non-root: weston + the wayland client share the
|
||||||
|
# agent's own user session. `user.name` is the agent name for every
|
||||||
|
# spawned agent; only a misconfigured root-named agent would trip this.
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = userName != "root";
|
||||||
|
message = "hyperhive.gui.enable requires a non-root hyperhive.user.name (the GUI runs as that user).";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
# neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
|
# neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
|
||||||
# (type 30), regardless of weston.ini auth-method=none.
|
# (type 30), regardless of weston.ini auth-method=none.
|
||||||
# pam_permit.so accepts the browser's empty Apple-DH credentials.
|
# pam_permit.so accepts the browser's empty Apple-DH credentials.
|
||||||
|
|
@ -53,59 +107,32 @@
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
StateDirectory = "weston";
|
StateDirectory = "weston";
|
||||||
Environment = "XDG_RUNTIME_DIR=/run/user/0";
|
# Run as the agent's own user; share a fixed runtime dir at
|
||||||
# Wrapper script: computes the deterministic VNC port, writes
|
# /run/gui (RuntimeDirectory creates+chowns it). Preserve it
|
||||||
# /etc/hyperhive/gui.json for the harness, then execs weston.
|
# across weston restarts so the wayland client sharing the
|
||||||
# `exec` keeps the PID stable so systemd tracks the weston
|
# /run/gui/wayland-0 socket doesn't lose the dir under it. 0700
|
||||||
# process correctly under Type=simple.
|
# because a wayland XDG_RUNTIME_DIR must not be group/world-accessible.
|
||||||
ExecStart = pkgs.writeShellScript "weston-vnc" ''
|
User = userName;
|
||||||
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true
|
Group = userName;
|
||||||
|
RuntimeDirectory = "gui";
|
||||||
# --- Compute deterministic VNC port via FNV-1a ---
|
RuntimeDirectoryMode = "0700";
|
||||||
# Agent name = container hostname with leading `h-` stripped.
|
RuntimeDirectoryPreserve = "yes";
|
||||||
# Read from /etc/hostname (always present in NixOS containers)
|
Environment = "XDG_RUNTIME_DIR=/run/gui";
|
||||||
# to avoid depending on `hostname` (lives in pkgs.inetutils,
|
# Direct exec (no wrapper script): fixed `--port`, static config.
|
||||||
# not pkgs.coreutils).
|
# `--socket=wayland-0` pins the compositor's Wayland socket name
|
||||||
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname)
|
# (weston otherwise picks any free name like `wayland-1`), so the
|
||||||
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \
|
# `WAYLAND_DISPLAY=wayland-0` globalEnvironment injection below
|
||||||
| ${pkgs.gnused}/bin/sed 's/^h-//')
|
# reaches every wayland client in the container deterministically
|
||||||
hash=2166136261
|
# (fixes double-screen: VNC showing a blank weston desktop while a
|
||||||
for byte in $(${pkgs.coreutils}/bin/printf '%s' "$AGENT_NAME" \
|
# client renders on a different seat). `--disable-transport-layer-
|
||||||
| ${pkgs.coreutils}/bin/od -An -tu1 \
|
# security` skips the VeNCrypt TLS wrapper so plain auth types
|
||||||
| ${pkgs.coreutils}/bin/tr -s ' \n' ' '); do
|
# (incl. Apple-DH type 30) are advertised directly.
|
||||||
[ -n "$byte" ] || continue
|
ExecStart = ''
|
||||||
hash=$(( ((hash ^ byte) * 16777619) & 4294967295 ))
|
${pkgs.weston}/bin/weston \
|
||||||
done
|
--config=${westonIni} \
|
||||||
VNC_PORT=$((15900 + hash % 900))
|
|
||||||
|
|
||||||
# Marker file the harness reads at startup. Also records the
|
|
||||||
# fixed Wayland socket name (`wayland-0`) so other tooling can
|
|
||||||
# read it without inspecting the env-var injection below.
|
|
||||||
${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive
|
|
||||||
${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none","wayland_display":"wayland-0"}\n' \
|
|
||||||
"$VNC_PORT" > /etc/hyperhive/gui.json || true
|
|
||||||
|
|
||||||
# --disable-transport-layer-security: skips the VeNCrypt TLS
|
|
||||||
# wrapper so plain auth types (incl. Apple-DH type 30) are
|
|
||||||
# advertised directly. [core] idle-time=0 disables the
|
|
||||||
# compositor's 300s idle/lock screen.
|
|
||||||
WESTON_INI=$(${pkgs.coreutils}/bin/mktemp /tmp/weston-XXXXXX.ini)
|
|
||||||
${pkgs.coreutils}/bin/printf '[core]\nidle-time=0\n\n[vnc]\nauth-method=none\n' > "$WESTON_INI"
|
|
||||||
|
|
||||||
# --socket=wayland-0: pin the compositor's Wayland socket name
|
|
||||||
# to `wayland-0` (weston default is to pick any free name such
|
|
||||||
# as `wayland-1`). Pinning lets the WAYLAND_DISPLAY=wayland-0
|
|
||||||
# global env injection below (see systemd.globalEnvironment) take
|
|
||||||
# effect unconditionally — any Wayland client launched by any
|
|
||||||
# systemd service in this container automatically connects to
|
|
||||||
# this compositor instead of failing or starting a second
|
|
||||||
# isolated display (fixes double-screen: VNC showing blank
|
|
||||||
# weston desktop while services render on a different seat).
|
|
||||||
exec ${pkgs.weston}/bin/weston \
|
|
||||||
--config="$WESTON_INI" \
|
|
||||||
--backend=vnc-backend.so \
|
--backend=vnc-backend.so \
|
||||||
--renderer=pixman \
|
--renderer=pixman \
|
||||||
--port="$VNC_PORT" \
|
--port=${toString config.hyperhive.gui.vncPort} \
|
||||||
--socket=wayland-0 \
|
--socket=wayland-0 \
|
||||||
--disable-transport-layer-security
|
--disable-transport-layer-security
|
||||||
'';
|
'';
|
||||||
|
|
@ -127,7 +154,7 @@
|
||||||
# shows a blank desktop.
|
# shows a blank desktop.
|
||||||
systemd.globalEnvironment = {
|
systemd.globalEnvironment = {
|
||||||
WAYLAND_DISPLAY = "wayland-0";
|
WAYLAND_DISPLAY = "wayland-0";
|
||||||
XDG_RUNTIME_DIR = "/run/user/0";
|
XDG_RUNTIME_DIR = "/run/gui";
|
||||||
};
|
};
|
||||||
|
|
||||||
# weston on the agent's interactive PATH so claude can run Wayland
|
# weston on the agent's interactive PATH so claude can run Wayland
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue