diff --git a/deploy/web/Dockerfile b/deploy/web/Dockerfile index 1daf7249..cb4b2ef3 100644 --- a/deploy/web/Dockerfile +++ b/deploy/web/Dockerfile @@ -22,13 +22,14 @@ COPY src-tauri/vendor/vnc-rs src-tauri/vendor/vnc-rs RUN cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked FROM debian:bookworm-slim AS runtime +ARG NYATERM_WEB_BASE_PATH=/ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 libfontconfig1 libfreetype6 && rm -rf /var/lib/apt/lists/* \ && useradd --system --uid 10001 --create-home nyaterm && mkdir -p /data && chown nyaterm:nyaterm /data WORKDIR /app COPY --from=frontend /app/dist /app/dist COPY --from=backend /app/src-tauri/crates/nyaterm-web/target/release/nyaterm-web /usr/local/bin/nyaterm-web USER 10001:10001 -ENV NYATERM_WEB_BIND=0.0.0.0:8080 NYATERM_WEB_DATA_DIR=/data NYATERM_WEB_DIST=/app/dist +ENV NYATERM_WEB_BIND=0.0.0.0:8080 NYATERM_WEB_DATA_DIR=/data NYATERM_WEB_DIST=/app/dist NYATERM_WEB_BASE_PATH=$NYATERM_WEB_BASE_PATH EXPOSE 8080 VOLUME ["/data"] STOPSIGNAL SIGTERM diff --git a/deploy/web/README.md b/deploy/web/README.md index 8fa91ef3..7daab6b2 100644 --- a/deploy/web/README.md +++ b/deploy/web/README.md @@ -1,22 +1,11 @@ -# Web / Docker 部署入口 +# Web / Docker 部署 -此目录提供 Web Beta 的镜像构建与 Compose 配置。完整的环境变量、秘密生成、HTTPS / 子路径代理、备份和能力范围见 [部署指南](../../docs/web-deployment.md)。 - -## 文件与执行目录 - -- `Dockerfile`:Node、Rust、Debian 三阶段构建,生成前端资源与 `nyaterm-web` 服务。 -- `docker-compose.yml`:服务运行配置与持久数据卷。 -- 仓库根目录 `.dockerignore`:排除依赖、构建产物、秘密与本地数据。 - -以下命令均在仓库根目录执行。Dockerfile 的位置与构建上下文不同:上下文必须是仓库根目录,才能复制前端和 Rust 源码。 - -## Compose 启动 - -安装 Docker Engine / Docker Desktop 和 Compose V2。提前在当前 shell 或仓库根目录的受限 `.env` 中设置: +安装 Docker 和 Compose,在仓库根目录的 `.env` 中设置: - `NYATERM_WEB_PASSWORD`:至少 32 字符的随机登录密码。 -- `NYATERM_WEB_ENCRYPTION_KEY`:标准 Base64 编码的 32 字节随机密钥,需持久保存。 -- `NYATERM_WEB_PUBLIC_URL`:浏览器实际访问地址,默认 `http://localhost:8080/`;非回环地址默认要求 HTTPS。 +- `NYATERM_WEB_ENCRYPTION_KEY`:Base64 编码的 32 字节随机密钥,需持久保存。 + +在仓库根目录执行: ```sh docker compose -f deploy/web/docker-compose.yml up --build -d @@ -24,31 +13,6 @@ docker compose -f deploy/web/docker-compose.yml logs --tail 200 nyaterm docker compose -f deploy/web/docker-compose.yml down ``` -使用外部环境文件时,在 Compose 命令中添加 `--env-file /absolute/private/nyaterm.env`。生产环境推荐 `secrets` 挂载与 `_FILE` 变量,操作示例见部署指南。 +访问 `http://localhost:8080/` 或 `http://服务器IP:8080/`,使用设置的密码登录。数据保存在 Docker 卷中,普通 `down` 不会删除数据。 -默认项目名为 `nyaterm`,持久数据卷为 `nyaterm_nyaterm-data`。从旧版根目录 Compose 迁移时,如果原项目名不是 `nyaterm`,继续使用 `-p <原项目名>` 或 `COMPOSE_PROJECT_NAME`,并沿用原登录密码和加密密钥。普通 `down` 保留数据卷;`down -v` 会删除数据卷。 - -## 手工构建镜像 - -```sh -docker build -f deploy/web/Dockerfile -t nyaterm-web . -``` - -子路径部署示例: - -```sh -docker build -f deploy/web/Dockerfile --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web . -``` - -运行时的 `NYATERM_WEB_PUBLIC_URL` 路径须与构建路径一致。单独运行容器、挂载秘密和配置反向代理见部署指南。 - -## 部署验收 - -在已安装项目依赖及 Playwright Chromium 的 Docker 环境执行: - -```sh -pnpm exec playwright install chromium -pnpm test:web:e2e -``` - -验收脚本分别构建根路径与 `/nyaterm/` 镜像,通过临时 OpenSSH 容器验证浏览器功能,失败产物写入 `artifacts/web-e2e/`。 +HTTPS、子路径、备份及其他配置见 [部署指南](../../docs/web-deployment.md)。 diff --git a/deploy/web/docker-compose.yml b/deploy/web/docker-compose.yml index e25af900..33e8bcb9 100644 --- a/deploy/web/docker-compose.yml +++ b/deploy/web/docker-compose.yml @@ -10,8 +10,7 @@ services: ports: - "8080:8080" environment: - NYATERM_WEB_ALLOW_INSECURE_HTTP: ${NYATERM_WEB_ALLOW_INSECURE_HTTP:-false} - NYATERM_WEB_PUBLIC_URL: ${NYATERM_WEB_PUBLIC_URL:-http://localhost:8080/} + NYATERM_WEB_BASE_PATH: ${NYATERM_WEB_BASE_PATH:-/} NYATERM_WEB_PASSWORD: ${NYATERM_WEB_PASSWORD:?Set a random login password of at least 32 characters} NYATERM_WEB_ENCRYPTION_KEY: ${NYATERM_WEB_ENCRYPTION_KEY:?Set a persistent base64-encoded 32-byte encryption key} volumes: diff --git a/docs/web-deployment.md b/docs/web-deployment.md index c0fd5614..eaac8f2d 100644 --- a/docs/web-deployment.md +++ b/docs/web-deployment.md @@ -23,7 +23,6 @@ $keyBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) [System.IO.File]::WriteAllText((Join-Path $secretDir 'encryption'), [Convert]::ToBase64String($keyBytes)) $env:NYATERM_WEB_PASSWORD_FILE = Join-Path $secretDir 'login' $env:NYATERM_WEB_ENCRYPTION_KEY_FILE = Join-Path $secretDir 'encryption' -$env:NYATERM_WEB_PUBLIC_URL = 'http://localhost:8080/' $env:NYATERM_WEB_BIND = '127.0.0.1:8080' $env:NYATERM_WEB_DATA_DIR = Join-Path $env:LOCALAPPDATA 'NyaTermWebData' pnpm web:serve @@ -33,17 +32,16 @@ pnpm web:serve ## 环境变量 -| 变量 | 含义 | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `NYATERM_WEB_PASSWORD` / `NYATERM_WEB_PASSWORD_FILE` | 登录密码,至少 32 字符;推荐随机生成。指定 `_FILE` 时优先读取文件。 | -| `NYATERM_WEB_ENCRYPTION_KEY` / `NYATERM_WEB_ENCRYPTION_KEY_FILE` | 标准 Base64 编码的 32 字节随机密钥,必须持久保存,禁止重新生成后直接复用旧数据。 | -| `NYATERM_WEB_PUBLIC_URL` | 浏览器实际访问地址,含端口和 base path;默认 `http://localhost:8080/`。非回环地址默认必须为 HTTPS。 | -| `NYATERM_WEB_BIND` | 服务监听地址,默认 `127.0.0.1:8080`,容器为 `0.0.0.0:8080`。 | -| `NYATERM_WEB_DATA_DIR` | redb 数据目录,默认 `./nyaterm-web-data`,容器为 `/data`。 | -| `NYATERM_WEB_DIST` | 已构建 React 资源目录,默认 `dist`,容器为 `/app/dist`。 | -| `NYATERM_WEB_BASE_PATH` | **前端构建时**使用的路径,默认 `/`,必须与 PUBLIC_URL 的路径一致并以 `/` 结尾。 | +| 变量 | 含义 | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `NYATERM_WEB_PASSWORD` / `NYATERM_WEB_PASSWORD_FILE` | 登录密码,至少 32 字符;推荐随机生成。指定 `_FILE` 时优先读取文件。 | +| `NYATERM_WEB_ENCRYPTION_KEY` / `NYATERM_WEB_ENCRYPTION_KEY_FILE` | 标准 Base64 编码的 32 字节随机密钥,必须持久保存,禁止重新生成后直接复用旧数据。 | +| `NYATERM_WEB_BIND` | 服务监听地址,默认 `127.0.0.1:8080`,容器为 `0.0.0.0:8080`。 | +| `NYATERM_WEB_DATA_DIR` | redb 数据目录,默认 `./nyaterm-web-data`,容器为 `/data`。 | +| `NYATERM_WEB_DIST` | 已构建 React 资源目录,默认 `dist`,容器为 `/app/dist`。 | +| `NYATERM_WEB_BASE_PATH` | 前端构建及服务运行时使用的子路径,默认 `/`,两者必须一致并以 `/` 结尾;Docker 构建参数会同时设置两者。 | -`NYATERM_WEB_ALLOW_INSECURE_HTTP=true` 可显式允许局域网 HTTP 地址;同时将 `NYATERM_WEB_PUBLIC_URL` 设置为浏览器实际使用的 IP/域名和端口,并将监听地址设为 `0.0.0.0:8080`。前端请求 ID 使用 `crypto.getRandomValues` 兼容 HTTP,但剪贴板等浏览器能力仍可能要求 HTTPS。Host/Origin 校验继续生效,访问地址必须与 PUBLIC_URL 一致。 +HTTP 和 HTTPS 地址均可直接使用,无需配置访问 URL 或域名白名单。容器默认监听 `0.0.0.0:8080`,发布端口后可通过任意主机 IP 或域名访问;本地运行如需局域网访问,将监听地址设为 `0.0.0.0:8080`。前端请求 ID 使用 `crypto.getRandomValues` 兼容 HTTP,但剪贴板等浏览器能力仍可能要求 HTTPS。API 和 WebSocket 按当前请求的 Host 校验浏览器 Origin,并保留登录和 CSRF 校验。 服务启动会验证数据密钥;错误密钥会阻止启动。文件方式会去掉末尾换行。登录密码可独立替换,服务器重启使所有旧登录失效;加密密钥目前没有在线轮换 API。 @@ -56,7 +54,6 @@ docker build -f deploy/web/Dockerfile -t nyaterm-web . docker volume create nyaterm-data docker run -d --name nyaterm-web --init \ -p 127.0.0.1:8080:8080 \ - -e NYATERM_WEB_PUBLIC_URL=http://localhost:8080/ \ -e NYATERM_WEB_PASSWORD_FILE=/run/secrets/login \ -e NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption \ --mount type=bind,src=/absolute/private/secrets,dst=/run/secrets,readonly \ @@ -66,7 +63,7 @@ docker run -d --name nyaterm-web --init \ nyaterm-web ``` -容器使用 UID / GID `10001:10001`。绑定目录时,确保该用户可以读取秘密文件和写入数据目录;新 named volume 会继承镜像中 `/data` 的所有权。默认端口映射仅暴露本机。如需远程访问,使用下面的 HTTPS 反向代理配置。 +容器使用 UID / GID `10001:10001`。绑定目录时,确保该用户可以读取秘密文件和写入数据目录;新 named volume 会继承镜像中 `/data` 的所有权。默认端口映射仅暴露本机。如需远程访问,可发布到所需主机接口,或使用下面的 HTTPS 反向代理配置。 仓库还提供 `deploy/web/docker-compose.yml`,它通过外部环境变量注入密码和密钥: @@ -90,7 +87,6 @@ services: ports: - "127.0.0.1:8080:8080" environment: - NYATERM_WEB_PUBLIC_URL: https://terminal.example.com/ NYATERM_WEB_PASSWORD_FILE: /run/secrets/login NYATERM_WEB_ENCRYPTION_KEY_FILE: /run/secrets/encryption secrets: @@ -120,7 +116,7 @@ volumes: docker compose -f /absolute/private/compose.secrets.yml up -d ``` -将 PUBLIC_URL 换为实际 HTTPS 地址,并配置下面的反向代理。Compose 文件秘密以只读挂载提供;宿主机文件权限仍决定容器能否读取,不能依赖 Compose 的 `uid` / `gid` 为绑定文件改属主。 +如需 HTTPS,可配置下面的反向代理,无需在服务中指定访问域名。Compose 文件秘密以只读挂载提供;宿主机文件权限仍决定容器能否读取,不能依赖 Compose 的 `uid` / `gid` 为绑定文件改属主。 ## HTTPS 和子路径 @@ -130,9 +126,9 @@ docker compose -f /absolute/private/compose.secrets.yml up -d docker build -f deploy/web/Dockerfile --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web . ``` -运行时设置 `NYATERM_WEB_PUBLIC_URL=https://terminal.example.com/nyaterm/`。前端和后端必须使用相同的 origin 和路径;后端会校验原始 Host / Origin,不使用转发头放宽校验。代理应保留完整路径,不能剥掉 `/nyaterm`。 +Docker 镜像会沿用构建时的 `NYATERM_WEB_BASE_PATH`;直接运行服务时也设置 `NYATERM_WEB_BASE_PATH=/nyaterm/`。代理应保留浏览器使用的 Host 和完整路径,不能剥掉 `/nyaterm`。访问域名和端口无需在服务中配置。 -使用仓库 Compose 时,构建前同时设置 `NYATERM_WEB_BASE_PATH=/nyaterm/` 和上述 PUBLIC_URL,再执行 `up --build -d`。HTTPS 地址使登录 Cookie 带上 Secure;TLS 可在代理终止,后端仍使用内部 HTTP。 +使用仓库 Compose 时,设置 `NYATERM_WEB_BASE_PATH=/nyaterm/`,再执行 `up --build -d`。登录 Cookie 根据浏览器请求的 Origin 自动添加 Secure;TLS 可在代理终止,后端仍使用内部 HTTP。 Nginx 示例(TLS 证书配置按已有站点设置): diff --git a/scripts/web-e2e.mjs b/scripts/web-e2e.mjs index 017d9e7a..fdb96a64 100644 --- a/scripts/web-e2e.mjs +++ b/scripts/web-e2e.mjs @@ -116,8 +116,6 @@ try { "-p", `127.0.0.1:${port}:8080`, "-e", - `NYATERM_WEB_PUBLIC_URL=${url}`, - "-e", "NYATERM_WEB_PASSWORD_FILE=/run/secrets/login", "-e", "NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption", diff --git a/scripts/web-local-e2e.mjs b/scripts/web-local-e2e.mjs index 503e2664..f5b5aa96 100644 --- a/scripts/web-local-e2e.mjs +++ b/scripts/web-local-e2e.mjs @@ -25,7 +25,7 @@ const env = { NYATERM_WEB_ENCRYPTION_KEY: randomBytes(32).toString("base64"), NYATERM_WEB_DATA_DIR: data, NYATERM_WEB_DIST: resolve("dist"), - NYATERM_WEB_PUBLIC_URL: base, + NYATERM_WEB_BASE_PATH: basePath, NYATERM_WEB_BIND: `127.0.0.1:${port}`, }; delete env.NYATERM_WEB_PASSWORD_FILE; diff --git a/src-tauri/crates/nyaterm-web/src/auth.rs b/src-tauri/crates/nyaterm-web/src/auth.rs index bee70e7a..fd6b3cd0 100644 --- a/src-tauri/crates/nyaterm-web/src/auth.rs +++ b/src-tauri/crates/nyaterm-web/src/auth.rs @@ -77,18 +77,25 @@ pub fn cookie_owner(headers: &HeaderMap) -> Option { (name == COOKIE && value.len() == 43).then(|| value.to_owned()) }) } -pub fn validate_boundary(state: &State, headers: &HeaderMap, require_origin: bool) -> Result<()> { +pub fn validate_boundary(headers: &HeaderMap, require_origin: bool) -> Result<()> { let host = headers .get(header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if host != state.host { - return Err(WebError::forbidden()); - } - match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { - Some(origin) if origin != state.origin => Err(WebError::forbidden()), + match headers.get(header::ORIGIN) { + Some(value) => { + let origin = value.to_str().map_err(|_| WebError::forbidden())?; + let url = url::Url::parse(origin).map_err(|_| WebError::forbidden())?; + if !matches!(url.scheme(), "http" | "https") + || url.origin().ascii_serialization() != origin + || url[url::Position::BeforeHost..url::Position::AfterPort] != *host + { + return Err(WebError::forbidden()); + } + Ok(()) + } None if require_origin => Err(WebError::forbidden()), - _ => Ok(()), + None => Ok(()), } } pub async fn guard( @@ -102,7 +109,7 @@ pub async fn guard( ); let websocket = request.headers().contains_key(header::UPGRADE); let result = async { - validate_boundary(&state, request.headers(), unsafe_method || websocket)?; + validate_boundary(request.headers(), unsafe_method || websocket)?; let token = cookie_owner(request.headers()).ok_or(WebError( StatusCode::UNAUTHORIZED, "Sign in required".into(), @@ -127,12 +134,8 @@ pub async fn guard( Err(error) => error.into_response(), } } -pub async fn site_guard( - ExtractState(state): ExtractState>, - request: Request, - next: Next, -) -> Response { - match validate_boundary(&state, request.headers(), false) { +pub async fn site_guard(request: Request, next: Next) -> Response { + match validate_boundary(request.headers(), false) { Ok(()) => next.run(request).await, Err(error) => error.into_response(), } @@ -147,7 +150,7 @@ pub async fn sign_in( Json(mut payload): Json, ) -> Result { use zeroize::Zeroize; - validate_boundary(&state, &headers, true)?; + validate_boundary(&headers, true)?; if headers .get("x-nyaterm-request") .and_then(|v| v.to_str().ok()) @@ -208,16 +211,23 @@ pub async fn sign_in( ); tracing::info!(event = "auth.accepted", "Web sign-in accepted"); Ok(( - [(header::SET_COOKIE, cookie(&state, &owner, 8 * 3600))], + [( + header::SET_COOKIE, + cookie(&state, &headers, &owner, 8 * 3600), + )], Json(json!({"csrf":csrf})), ) .into_response()) } -fn cookie(state: &State, token: &str, max_age: u32) -> String { +fn cookie(state: &State, headers: &HeaderMap, token: &str, max_age: u32) -> String { + let secure = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .is_some_and(|origin| origin.starts_with("https://")); format!( "{COOKIE}={token}; Path={}/; HttpOnly; SameSite=Strict; Max-Age={max_age}{}", state.base_path, - if state.secure_cookie { "; Secure" } else { "" } + if secure { "; Secure" } else { "" } ) } pub async fn current( @@ -229,12 +239,54 @@ pub async fn current( pub async fn sign_out( ExtractState(state): ExtractState>, axum::Extension(owner): axum::Extension, + headers: HeaderMap, ) -> Response { state.close_owner(&owner.0).await; tracing::info!(event = "auth.signed_out", "Web administrator signed out"); ( - [(header::SET_COOKIE, cookie(&state, "", 0))], + [(header::SET_COOKIE, cookie(&state, &headers, "", 0))], Json(json!({})), ) .into_response() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boundary_accepts_any_host_but_rejects_cross_origin_requests() { + for (host, origin, allowed) in [ + ("192.168.1.10:8080", "http://192.168.1.10:8080", true), + ("terminal.example", "https://terminal.example", true), + ("[2001:db8::1]:8080", "http://[2001:db8::1]:8080", true), + ("terminal.example", "https://evil.example", false), + ( + "terminal.example:8080", + "http://terminal.example:8081", + false, + ), + ("terminal.example", "null", false), + ("terminal.example", "https://terminal.example/path", false), + ("terminal.example", "https://user@terminal.example", false), + ] { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, host.parse().unwrap()); + headers.insert(header::ORIGIN, origin.parse().unwrap()); + assert_eq!( + validate_boundary(&headers, true).is_ok(), + allowed, + "{origin}" + ); + } + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "any.example".parse().unwrap()); + assert!(validate_boundary(&headers, false).is_ok()); + assert!(validate_boundary(&headers, true).is_err()); + headers.insert( + header::ORIGIN, + axum::http::HeaderValue::from_bytes(b"\xff").unwrap(), + ); + assert!(validate_boundary(&headers, false).is_err()); + } +} diff --git a/src-tauri/crates/nyaterm-web/src/lib.rs b/src-tauri/crates/nyaterm-web/src/lib.rs index 57b6cb60..bbd9689f 100644 --- a/src-tauri/crates/nyaterm-web/src/lib.rs +++ b/src-tauri/crates/nyaterm-web/src/lib.rs @@ -115,7 +115,7 @@ pub fn router(state: Arc, dist: std::path::PathBuf) -> Router { }), ) }; - app.layer(middleware::from_fn_with_state(state.clone(), auth::site_guard)) + app.layer(middleware::from_fn(auth::site_guard)) .layer(SetResponseHeaderLayer::overriding(axum::http::header::CONTENT_SECURITY_POLICY,HeaderValue::from_static("default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"))) .layer(SetResponseHeaderLayer::overriding(axum::http::header::CACHE_CONTROL,HeaderValue::from_static("no-store"))) .layer(SetResponseHeaderLayer::overriding(axum::http::header::X_CONTENT_TYPE_OPTIONS,HeaderValue::from_static("nosniff"))) diff --git a/src-tauri/crates/nyaterm-web/src/main.rs b/src-tauri/crates/nyaterm-web/src/main.rs index 87980c2f..1cef9c11 100644 --- a/src-tauri/crates/nyaterm-web/src/main.rs +++ b/src-tauri/crates/nyaterm-web/src/main.rs @@ -31,31 +31,17 @@ async fn main() -> Result<(), Box> { .try_into() .map_err(|_| "Encryption key must decode to 32 bytes")?; nyaterm_core::utils::crypto::set_server_key_material(key); - let public = - std::env::var("NYATERM_WEB_PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8080/".into()); - let url = url::Url::parse(&public)?; - let origin = url.origin().ascii_serialization(); - if !matches!(url.scheme(), "http" | "https") - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() + let base_path = std::env::var("NYATERM_WEB_BASE_PATH") + .unwrap_or_else(|_| "/".into()) + .trim_end_matches('/') + .to_owned(); + if (!base_path.is_empty() && !base_path.starts_with('/')) + || base_path.contains(['%', '?', '#', '\\']) + || base_path.contains("//") + || base_path.split('/').any(|part| matches!(part, "." | "..")) { - return Err("Invalid public URL".into()); - } - let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); - let allow_insecure_http = std::env::var("NYATERM_WEB_ALLOW_INSECURE_HTTP") - .unwrap_or_else(|_| "false".into()) - .parse() - .unwrap_or(false); - if url.scheme() != "https" && !loopback && !allow_insecure_http { - return Err("Non-loopback public URLs require HTTPS".into()); - } - let base_path = url.path().trim_end_matches('/').to_owned(); - if base_path.contains('%') || base_path.contains("//") { return Err("Invalid base path".into()); } - let host = url[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); let data = PathBuf::from( std::env::var("NYATERM_WEB_DATA_DIR").unwrap_or_else(|_| "./nyaterm-web-data".into()), ); @@ -86,10 +72,7 @@ async fn main() -> Result<(), Box> { return Err("React dist/index.html is missing; run pnpm build:web".into()); } let state = Arc::new(State { - origin, - host, base_path, - secure_cookie: url.scheme() == "https", password_hash: auth::digest(&password), logins: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), diff --git a/src-tauri/crates/nyaterm-web/src/state.rs b/src-tauri/crates/nyaterm-web/src/state.rs index 98cd64fc..986b2dff 100644 --- a/src-tauri/crates/nyaterm-web/src/state.rs +++ b/src-tauri/crates/nyaterm-web/src/state.rs @@ -28,10 +28,7 @@ pub struct Prompt { pub reply: oneshot::Sender, } pub struct State { - pub origin: String, - pub host: String, pub base_path: String, - pub secure_cookie: bool, pub password_hash: [u8; 32], pub logins: Mutex>>, pub sessions: Mutex>>, diff --git a/src-tauri/crates/nyaterm-web/tests/backups.rs b/src-tauri/crates/nyaterm-web/tests/backups.rs index 023ccc41..96dba7f8 100644 --- a/src-tauri/crates/nyaterm-web/tests/backups.rs +++ b/src-tauri/crates/nyaterm-web/tests/backups.rs @@ -47,7 +47,7 @@ impl Server { .env("NYATERM_WEB_ENCRYPTION_KEY", STANDARD.encode([key; 32])) .env("NYATERM_WEB_DATA_DIR", data) .env("NYATERM_WEB_DIST", dist) - .env("NYATERM_WEB_PUBLIC_URL", &base) + .env("NYATERM_WEB_BASE_PATH", format!("/{subpath}")) .env("NYATERM_WEB_BIND", address.to_string()) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/src-tauri/crates/nyaterm-web/tests/bootstrap.rs b/src-tauri/crates/nyaterm-web/tests/bootstrap.rs index be2044ed..0b2945ed 100644 --- a/src-tauri/crates/nyaterm-web/tests/bootstrap.rs +++ b/src-tauri/crates/nyaterm-web/tests/bootstrap.rs @@ -25,7 +25,7 @@ fn command(data: &Path, dist: &Path) -> Command { cmd } #[tokio::test] -async fn bootstrap_uses_external_secret_files_and_rejects_wrong_key() { +async fn bootstrap_accepts_multiple_hosts_and_secret_files_and_rejects_wrong_key() { let temporary = tempfile::tempdir().unwrap(); let data = temporary.path().join("data"); let dist = temporary.path().join("dist"); @@ -40,17 +40,21 @@ async fn bootstrap_uses_external_secret_files_and_rejects_wrong_key() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let host = listener.local_addr().unwrap(); drop(listener); - let origin = format!("http://{host}"); + let origin = format!("http://terminal.example:{}", host.port()); let mut process = Process( command(&data, &dist) .env("NYATERM_WEB_PASSWORD_FILE", &password_file) .env("NYATERM_WEB_ENCRYPTION_KEY_FILE", &key_file) - .env("NYATERM_WEB_PUBLIC_URL", format!("{origin}/terminal/")) + .env("NYATERM_WEB_BASE_PATH", "/terminal/") .env("NYATERM_WEB_BIND", host.to_string()) .spawn() .unwrap(), ); - let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let client = reqwest::Client::builder() + .no_proxy() + .resolve("terminal.example", host) + .build() + .unwrap(); let mut ready = false; for _ in 0..100 { if let Ok(response) = client.get(format!("{origin}/terminal/")).send().await { @@ -67,46 +71,87 @@ async fn bootstrap_uses_external_secret_files_and_rejects_wrong_key() { tokio::time::sleep(Duration::from_millis(50)).await; } assert!(ready, "Server did not start"); - let response = client - .post(format!("{origin}/terminal/api/auth/login")) - .header("origin", &origin) - .header("x-nyaterm-request", "1") - .json(&json!({"password":password})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let cookie = response.headers()["set-cookie"] - .to_str() - .unwrap() - .split(';') - .next() - .unwrap() - .to_owned(); - let csrf = response.json::().await.unwrap()["csrf"] - .as_str() - .unwrap() - .to_owned(); - assert_eq!( - client - .post(format!("{origin}/terminal/api/commands/list_sessions")) - .header("origin", &origin) - .header("cookie", cookie) - .header("x-nyaterm-csrf", csrf) - .json(&json!({})) + // One server serves different IPs/domains and HTTPS proxies without a public URL. + for access_origin in [ + origin.clone(), + format!("http://{host}"), + format!("http://localhost:{}", host.port()), + "https://proxy.example".into(), + "https://[2001:db8::1]:8443".into(), + ] { + let url = url::Url::parse(&access_origin).unwrap(); + let authority = &url[url::Position::BeforeHost..url::Position::AfterPort]; + let secure = url.scheme() == "https"; + let response = client + .post(format!("{origin}/terminal/api/auth/login")) + .header("host", authority) + .header("origin", &access_origin) + .header("x-nyaterm-request", "1") + .json(&json!({"password":password})) .send() .await + .unwrap(); + assert_eq!(response.status(), 200); + assert!( + response.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .any(|attribute| attribute.trim() == "Secure") + == secure + ); + let cookie = response.headers()["set-cookie"] + .to_str() .unwrap() - .status(), - 200 - ); + .split(';') + .next() + .unwrap() + .to_owned(); + let csrf = response.json::().await.unwrap()["csrf"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + client + .post(format!("{origin}/terminal/api/commands/list_sessions")) + .header("host", authority) + .header("origin", &access_origin) + .header("cookie", &cookie) + .header("x-nyaterm-csrf", &csrf) + .json(&json!({})) + .send() + .await + .unwrap() + .status(), + 200 + ); + let response = client + .post(format!("{origin}/terminal/api/auth/logout")) + .header("host", authority) + .header("origin", &access_origin) + .header("cookie", &cookie) + .header("x-nyaterm-csrf", &csrf) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let cleared = response.headers()["set-cookie"].to_str().unwrap(); + assert!(cleared.contains("Path=/terminal/")); + assert!(cleared.contains("Max-Age=0")); + assert_eq!( + cleared + .split(';') + .any(|attribute| attribute.trim() == "Secure"), + secure + ); + } process.0.kill().unwrap(); process.0.wait().unwrap(); std::fs::write(&key_file, STANDARD.encode([20u8; 32])).unwrap(); let output = command(&data, &dist) .env("NYATERM_WEB_PASSWORD_FILE", &password_file) .env("NYATERM_WEB_ENCRYPTION_KEY_FILE", &key_file) - .env("NYATERM_WEB_PUBLIC_URL", &origin) + .env("NYATERM_WEB_BASE_PATH", "/terminal/") .output() .unwrap(); assert!(!output.status.success()); diff --git a/src-tauri/crates/nyaterm-web/tests/commands.rs b/src-tauri/crates/nyaterm-web/tests/commands.rs index b4a25cbd..0f66f6e0 100644 --- a/src-tauri/crates/nyaterm-web/tests/commands.rs +++ b/src-tauri/crates/nyaterm-web/tests/commands.rs @@ -56,10 +56,7 @@ async fn persistent_web_commands_preserve_contracts_and_verify_real_passwords() cancel: cancel.child_token(), }); let state = Arc::new(State { - origin: "http://localhost:8080".into(), - host: "localhost:8080".into(), base_path: String::new(), - secure_cookie: false, password_hash: auth::digest(PASSWORD), logins: Mutex::new(HashMap::from([("o".repeat(43), login)])), sessions: Mutex::new(HashMap::new()), diff --git a/src-tauri/crates/nyaterm-web/tests/protocols.rs b/src-tauri/crates/nyaterm-web/tests/protocols.rs index 305415ee..4b5635d5 100644 --- a/src-tauri/crates/nyaterm-web/tests/protocols.rs +++ b/src-tauri/crates/nyaterm-web/tests/protocols.rs @@ -18,12 +18,9 @@ use tokio_tungstenite::{ }; use tokio_util::sync::CancellationToken; use tower::ServiceExt; -fn state(host: String, base_path: &str) -> Arc { +fn state(base_path: &str) -> Arc { Arc::new(State { - origin: format!("http://{host}"), - host, base_path: base_path.into(), - secure_cookie: false, password_hash: auth::digest("login-password-at-least-32-characters"), logins: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), @@ -36,7 +33,7 @@ fn state(host: String, base_path: &str) -> Arc { } async fn request( app: &Router, - state: &State, + _state: &State, path: &str, body: Option, owner: Option<&str>, @@ -44,8 +41,8 @@ async fn request( ) -> (StatusCode, axum::http::HeaderMap, Value) { let mut builder = Request::builder() .uri(format!("/nyaterm/api/{path}")) - .header("host", &state.host) - .header("origin", &state.origin) + .header("host", "terminal.example") + .header("origin", "http://terminal.example") .header("x-nyaterm-request", "1"); if let Some(owner) = owner { builder = builder.header("cookie", format!("{}={owner}", auth::COOKIE)); @@ -103,7 +100,7 @@ async fn login(app: &Router, state: &State) -> (String, String) { } async fn ws( host: &str, - state: &State, + _state: &State, id: &str, owner: &str, ) -> tokio_tungstenite::WebSocketStream> { @@ -111,7 +108,7 @@ async fn ws( .into_client_request() .unwrap(); r.headers_mut() - .insert("origin", state.origin.parse().unwrap()); + .insert("origin", format!("http://{host}").parse().unwrap()); r.headers_mut().insert( "cookie", format!("{}={owner}", auth::COOKIE).parse().unwrap(), @@ -138,12 +135,12 @@ async fn binary( } type Socket = tokio_tungstenite::WebSocketStream>; -async fn vnc_ws(host: &str, state: &State, id: &str, owner: &str) -> Socket { +async fn vnc_ws(host: &str, _state: &State, id: &str, owner: &str) -> Socket { let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/vnc") .into_client_request() .unwrap(); r.headers_mut() - .insert("origin", state.origin.parse().unwrap()); + .insert("origin", format!("http://{host}").parse().unwrap()); r.headers_mut().insert( "cookie", format!("{}={owner}", auth::COOKIE).parse().unwrap(), @@ -152,7 +149,7 @@ async fn vnc_ws(host: &str, state: &State, id: &str, owner: &str) -> Socket { } async fn rejected_ws( host: &str, - state: &State, + _state: &State, id: &str, endpoint: &str, owner: &str, @@ -162,7 +159,7 @@ async fn rejected_ws( .into_client_request() .unwrap(); r.headers_mut() - .insert("origin", state.origin.parse().unwrap()); + .insert("origin", format!("http://{host}").parse().unwrap()); r.headers_mut().insert( "cookie", format!("{}={owner}", auth::COOKIE).parse().unwrap(), @@ -519,7 +516,7 @@ async fn web_telnet_vnc_proxy_and_lifecycle() { std::fs::write(dist.path().join("index.html"), "test").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let host = listener.local_addr().unwrap().to_string(); - let state = state(host.clone(), "/nyaterm"); + let state = state("/nyaterm"); let app = nyaterm_web::router(state.clone(), dist.path().into()); let serving = tokio::spawn(axum::serve(listener, app.clone()).into_future()); let (owner, csrf) = login(&app, &state).await; diff --git a/src-tauri/crates/nyaterm-web/tests/transport.rs b/src-tauri/crates/nyaterm-web/tests/transport.rs index 4c5f3c3e..a3a8dbdd 100644 --- a/src-tauri/crates/nyaterm-web/tests/transport.rs +++ b/src-tauri/crates/nyaterm-web/tests/transport.rs @@ -467,12 +467,9 @@ impl russh_sftp::server::Handler for MemoryFiles { }) } } -fn state(host: String, base_path: &str) -> Arc { +fn state(base_path: &str) -> Arc { Arc::new(State { - origin: format!("http://{host}"), - host, base_path: base_path.into(), - secure_cookie: false, password_hash: auth::digest("login-password-at-least-32-characters"), logins: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), @@ -485,7 +482,7 @@ fn state(host: String, base_path: &str) -> Arc { } async fn upload_file( app: &Router, - state: &State, + _state: &State, id: &str, path: &str, owner: &str, @@ -498,8 +495,8 @@ async fn upload_file( Request::builder() .method("POST") .uri(format!("/nyaterm/api/sessions/{id}/upload?path={path}")) - .header("host", &state.host) - .header("origin", &state.origin) + .header("host", "terminal.example") + .header("origin", "http://terminal.example") .header("cookie", format!("{}={owner}", auth::COOKIE)) .header("x-nyaterm-csrf", csrf) .body(body) @@ -520,7 +517,7 @@ async fn wait_clean(files: &Arc>>>) { } async fn request( app: &Router, - state: &State, + _state: &State, path: &str, body: Option, owner: Option<&str>, @@ -528,8 +525,8 @@ async fn request( ) -> (StatusCode, axum::http::HeaderMap, Value) { let mut builder = Request::builder() .uri(format!("/nyaterm/api/{path}")) - .header("host", &state.host) - .header("origin", &state.origin) + .header("host", "terminal.example") + .header("origin", "http://terminal.example") .header("x-nyaterm-request", "1"); if let Some(owner) = owner { builder = builder.header("cookie", format!("{}={owner}", auth::COOKIE)); @@ -587,7 +584,7 @@ async fn login(app: &Router, state: &State) -> (String, String) { } async fn ws( host: &str, - state: &State, + _state: &State, id: &str, owner: &str, ) -> tokio_tungstenite::WebSocketStream> { @@ -595,7 +592,7 @@ async fn ws( .into_client_request() .unwrap(); r.headers_mut() - .insert("origin", state.origin.parse().unwrap()); + .insert("origin", format!("http://{host}").parse().unwrap()); r.headers_mut().insert( "cookie", format!("{}={owner}", auth::COOKIE).parse().unwrap(), @@ -630,7 +627,7 @@ async fn authenticated_ssh_vertical_slice_and_security() { std::fs::write(dist.path().join("index.html"), "NyaTerm").unwrap(); let web = TcpListener::bind("127.0.0.1:0").await.unwrap(); let host = web.local_addr().unwrap().to_string(); - let state = state(host.clone(), "/nyaterm"); + let state = state("/nyaterm"); let app = nyaterm_web::router(state.clone(), dist.path().into()); let serving = tokio::spawn(axum::serve(web, app.clone()).into_future()); let (resize, mut resized) = mpsc::unbounded_channel(); @@ -696,14 +693,14 @@ async fn authenticated_ssh_vertical_slice_and_security() { .0, StatusCode::FORBIDDEN ); - let bad = Request::builder() + let alternate_host = Request::builder() .uri("/nyaterm/") - .header("host", "evil.example") + .header("host", "alternate.example") .body(Body::empty()) .unwrap(); assert_eq!( - app.clone().oneshot(bad).await.unwrap().status(), - StatusCode::FORBIDDEN + app.clone().oneshot(alternate_host).await.unwrap().status(), + StatusCode::OK ); let static_response = app .clone() @@ -736,7 +733,7 @@ async fn authenticated_ssh_vertical_slice_and_security() { let id = value["session_id"].as_str().unwrap(); // WebSocket ownership and exact Origin must be enforced during upgrade. for (token, origin, expected) in [ - (&other, state.origin.as_str(), 404), + (&other, format!("http://{host}").as_str(), 404), (&owner, "https://evil.example", 403), ] { let mut upgrade = format!("ws://{host}/nyaterm/api/sessions/{id}/terminal") @@ -831,7 +828,7 @@ async fn authenticated_ssh_vertical_slice_and_security() { .method("POST") .uri(format!("/nyaterm/api/sessions/{id}/upload?path=/large.bin")) .header("host", &host) - .header("origin", &state.origin) + .header("origin", &format!("http://{host}")) .header("cookie", format!("{}={owner}", auth::COOKIE)) .header("x-nyaterm-csrf", &csrf) .header("content-length", payload.len()) @@ -1550,7 +1547,7 @@ async fn authenticated_ssh_vertical_slice_and_security() { "/nyaterm/api/sessions/{id}/upload?path=/interrupted.bin" )) .header("host", &host) - .header("origin", &state.origin) + .header("origin", &format!("http://{host}")) .header("cookie", format!("{}={owner}", auth::COOKIE)) .header("x-nyaterm-csrf", &csrf) .body(body)