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:
1 parent
158270df76
commit
b35e71906a
15 files changed
+213
-187
No files matched your search
@@ -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
@@ -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)。
|
||||
@@ -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
@@ -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 证书配置按已有站点设置):
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user