chore(docker): update environment variables and configurations for web deployment

- Removed NYATERM_WEB_PUBLIC_URL from docker-compose.yml and related files, replacing it with NYATERM_WEB_BASE_PATH for improved path management.
- Adjusted Dockerfile and README to reflect changes in environment variable usage.
- Enhanced validation logic in auth module to support multiple host configurations without a public URL.
- Updated tests to ensure compatibility with the new base path configuration.
This commit is contained in:
Kang committed 2026-10-07 20:33:49 +08:00
1 parent 158270df76
commit b35e71906a
15 files changed
+213 -187

No files matched your search

+2 -1
View File
@@ -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
+7 -43
View File
@@ -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)。
+1 -2
View File
@@ -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:
+13 -17
View File
@@ -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 证书配置按已有站点设置):
-2
View File
@@ -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",
+1 -1
View File
@@ -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;
+71 -19
View File
@@ -77,18 +77,25 @@ pub fn cookie_owner(headers: &HeaderMap) -> Option<String> {
(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<Arc<State>>,
request: Request<Body>,
next: Next,
) -> Response {
match validate_boundary(&state, request.headers(), false) {
pub async fn site_guard(request: Request<Body>, 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<LoginRequest>,
) -> Result<Response> {
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<Arc<State>>,
axum::Extension(owner): axum::Extension<Owner>,
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());
}
}
+1 -1
View File
@@ -115,7 +115,7 @@ pub fn router(state: Arc<State>, 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")))
+8 -25
View File
@@ -31,31 +31,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
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()),
@@ -28,10 +28,7 @@ pub struct Prompt {
pub reply: oneshot::Sender<Value>,
}
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<HashMap<String, Arc<Login>>>,
pub sessions: Mutex<HashMap<String, Arc<WebSession>>>,
@@ -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())
+80 -35
View File
@@ -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::<serde_json::Value>().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::<serde_json::Value>().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());
@@ -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()),
+11 -14
View File
@@ -18,12 +18,9 @@ use tokio_tungstenite::{
};
use tokio_util::sync::CancellationToken;
use tower::ServiceExt;
fn state(host: String, base_path: &str) -> Arc<State> {
fn state(base_path: &str) -> Arc<State> {
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<State> {
}
async fn request(
app: &Router,
state: &State,
_state: &State,
path: &str,
body: Option<Value>,
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<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
@@ -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<tokio_tungstenite::MaybeTlsStream<TcpStream>>;
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;
+17 -20
View File
@@ -467,12 +467,9 @@ impl russh_sftp::server::Handler for MemoryFiles {
})
}
}
fn state(host: String, base_path: &str) -> Arc<State> {
fn state(base_path: &str) -> Arc<State> {
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<State> {
}
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<std::sync::Mutex<HashMap<String, Vec<u8>>>>) {
}
async fn request(
app: &Router,
state: &State,
_state: &State,
path: &str,
body: Option<Value>,
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<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
@@ -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"), "<html>NyaTerm</html>").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)