Skip to content

Commit f86a2dd

Browse files
committed
feat: secure dav access
1 parent 4ab8a72 commit f86a2dd

15 files changed

Lines changed: 429 additions & 70 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ unused-async = "warn"
1818

1919
[package]
2020
name = "ii"
21-
version = "0.3.2"
21+
version = "0.3.3"
2222
edition.workspace = true
2323
rust-version.workspace = true
2424
license.workspace = true
@@ -52,6 +52,7 @@ tar = { version = "0.4", default-features = false }
5252
tempfile = "3"
5353
time = { version = "0.3", default-features = false, features = ["formatting", "macros", "std"] }
5454
tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt", "signal", "time"] }
55+
tokio-rustls = { version = "0.26", default-features = false }
5556
tokio-util = { version = "0.7", features = ["io"] }
5657
toml = "1.0"
5758
tracing = "0.1"

README.en.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,14 @@ ii web .\shared --upload --path .\uploads
158158
# Mount a directory in a file manager; writable by default
159159
ii dav .\shared
160160
ii dav .\shared --read-only
161+
ii dav .\shared --port 8443 --username alice --password secret --tls --domain dav.example.com --cert D:\certs\fullchain.pem --key D:\certs\privkey.pem
161162
```
162163

163164
`ii send --web` serves a download page for one file or folder. `ii web` displays an nginx-style directory listing and serves the current directory when no directory is given. `--upload` enables multi-file uploads only; their default destination is `./ii/` under the startup directory, while `--path <dir>` selects another directory. `ii dav` reads and writes its served directory directly, not the web upload directory.
164165

165-
`--port 8080` fixes the port, `--bind ::` listens on IPv6 only, and bare `--token` generates a path token while `--token <value>` uses the supplied token. These services have no account authentication and are for short-lived, trusted LAN use only.
166+
`ii dav --username <username> --password <password>` enables HTTP Basic Auth; both options are required together. `--password` is visible in shell history and process listings, so use it only where that is acceptable. `--tls` enables HTTPS. Without `--cert` and `--key`, ii generates a self-signed certificate valid only for the running process, which clients must explicitly trust. For public access, use a trusted PEM certificate or terminate HTTPS in a reverse proxy and bind `ii dav` to `127.0.0.1`. Without `--tls`, Basic Auth credentials travel in clear text.
167+
168+
`--port 8080` fixes the port, `--bind ::` listens on IPv6 only, and bare `--token` generates a path token while `--token <value>` uses the supplied token. A path token is not account authentication.
166169

167170
### LAN Discovery
168171

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,14 @@ ii web .\shared --upload --path .\uploads
158158
# 用系统文件管理器挂载目录;默认可读写
159159
ii dav .\shared
160160
ii dav .\shared --read-only
161+
ii dav .\shared --port 8443 --username alice --password secret --tls --domain dav.example.com --cert D:\certs\fullchain.pem --key D:\certs\privkey.pem
161162
```
162163

163164
`ii send --web` 为单个文件或目录提供下载页。`ii web` 显示 nginx 风格目录列表,省略目录时服务当前目录;`--upload` 才开放多文件上传,默认写入启动目录的 `./ii/``--path <dir>` 可改为指定目录。`ii dav` 直接读写所服务目录,不使用网页上传目录。
164165

165-
`--port 8080` 固定端口,`--bind ::` 只监听 IPv6;裸 `--token` 生成路径令牌,`--token <value>` 使用指定令牌。它们都没有账号鉴权,只适合临时可信局域网。
166+
`ii dav --username <username> --password <password>` 启用 HTTP Basic Auth,两个参数必须同时提供。`--password` 会出现在 shell 历史和进程列表中,只适合你明确接受这一风险的场景。`--tls` 开启 HTTPS;未提供 `--cert``--key` 时生成仅当前进程有效的自签证书,客户端必须手动信任。公网请使用受信任 PEM 证书,或让 HTTPS 反向代理终止 TLS 并将 `ii dav` 绑定到 `127.0.0.1`。未启用 `--tls` 时,Basic Auth 凭据会以明文传输。
167+
168+
`--port 8080` 固定端口,`--bind ::` 只监听 IPv6;裸 `--token` 生成路径令牌,`--token <value>` 使用指定令牌。路径 token 不是账号鉴权。
166169

167170
### 局域网发现
168171

ii.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
ii help [<command>]
1313
ii send [<path>...] [--name <name>] [--include <glob>] [--exclude <glob>] [--rate <bytes/s>] [--json] [-t] [-c] [-o <path>] [--web [--port <port>] [--bind <ip>] [--token [<value>]] [--upload] [--path <path>] | --s3 | --r2 | --azure | --webdav | --ftp | --sftp] [--profile <name>] [-d] [-p] [--local] [--relay <url> [-k]] [--no-relay]
1414
ii web [<目录>] [--port <port>] [--bind <ip>] [--token [<value>]] [--upload] [--path <目录>]
15-
ii dav [<目录>] [--port <port>] [--bind <ip>] [--token [<value>]] [--read-only]
15+
ii dav [<目录>] [--port <port>] [--bind <ip>] [--token [<value>]] [--read-only] [--username <username> --password <password>] [--tls [--domain <name>] [--cert <path> --key <path>]]
1616
ii webrtc [--port <port>] [--bind <ip>] [--token [<value>]]
1717
ii tunnel -s <target-host:port> [--relay <url> [-k]]
1818
ii tunnel -c <ticket> [--listen <ip:port>]
@@ -232,9 +232,14 @@ ii web .\shared --port 8080 --token A1b2C3d4E5f6G7h8 --upload --path .\uploads
232232
ii dav
233233
ii dav .\shared --port 8080 --bind 192.168.1.20 --token A1b2C3d4E5f6G7h8
234234
ii dav .\shared --read-only
235+
ii dav .\shared --port 8443 --username alice --password secret --tls --domain dav.example.com --cert .\fullchain.pem --key .\privkey.pem
235236
```
236237

237-
`ii dav` 把当前目录或指定目录作为局域网 WebDAV 根目录,默认可读写;支持 `OPTIONS``PROPFIND``GET``HEAD`、Range、`PUT``MKCOL``DELETE``MOVE``COPY``LOCK``UNLOCK`,上传支持 `Content-Length`、chunked body 和 `100-continue``--read-only` 禁止所有改写。`--token` 是 URL 路径令牌,不是账户认证;服务默认无 TLS 和 Basic Auth,只适合可信局域网。
238+
`ii dav` 把当前目录或指定目录作为 WebDAV 根目录,默认可读写;支持 `OPTIONS``PROPFIND``GET``HEAD`、Range、`PUT``MKCOL``DELETE``MOVE``COPY``LOCK``UNLOCK`,上传支持 `Content-Length`、chunked body 和 `100-continue``--read-only` 禁止所有改写。`--token` 是 URL 路径令牌,不是账户认证。
239+
240+
`--username <username>``--password <password>` 必须成对提供,启用所有 DAV 方法的 HTTP Basic Auth。用户名不能为空,且不能包含 `:`、CR 或 LF;密码不能为空,且不能包含 CR 或 LF。`--password` 会进入 shell 历史和进程列表。
241+
242+
`--tls` 开启 HTTPS。没有 `--cert``--key` 时,`ii` 仅为当前进程生成自签证书;客户端必须手动信任。`--domain` 指定证书 DNS 名称和输出 URL;只允许与 `--tls` 同用。已有 PEM 完整证书链和私钥时,成对提供 `--cert``--key`;它们也要求 `--tls`。对公网提供服务时必须使用 TLS:可由 `ii dav --tls` 直接终止,或由 HTTPS 反向代理终止并把 `ii dav` 绑定到 `127.0.0.1`。明文 HTTP 下 Basic Auth 凭据可被窃听。
238243

239244
## `ii discover`
240245

src/cli/dav.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ pub(super) fn parse(args: Vec<String>) -> Result<DavArgs, ParseAction> {
88
Some(("port", value)) => out.web_port = Some(parse_port("--port", value)?),
99
Some(("bind", value)) => out.web_bind = Some(parse_bind("--bind", value)?),
1010
Some(("token", value)) => out.web_token = Some(value.to_string()),
11+
Some(("username", value)) => out.username = Some(value.to_string()),
12+
Some(("password", value)) => out.password = Some(value.to_string()),
13+
Some(("domain", value)) => out.domain = Some(parse_tls_domain(value)?),
14+
Some(("cert", value)) => out.cert = Some(PathBuf::from(value)),
15+
Some(("key", value)) => out.key = Some(PathBuf::from(value)),
1116
Some(("read-only", _)) => {
1217
return Err(ParseAction::error("--read-only does not take a value"));
1318
}
19+
Some(("tls", _)) => return Err(ParseAction::error("--tls does not take a value")),
1420
Some((flag, _)) => {
1521
return Err(ParseAction::error(format!("unknown option `--{flag}`")));
1622
}
@@ -20,6 +26,12 @@ pub(super) fn parse(args: Vec<String>) -> Result<DavArgs, ParseAction> {
2026
"--bind" => out.web_bind = Some(parse_bind("--bind", &iter.value("--bind")?)?),
2127
"--token" => out.web_token = Some(web_token(&mut iter)),
2228
"--read-only" => out.read_only = true,
29+
"--username" => out.username = Some(iter.value("--username")?),
30+
"--password" => out.password = Some(iter.value("--password")?),
31+
"--tls" => out.tls = true,
32+
"--domain" => out.domain = Some(parse_tls_domain(&iter.value("--domain")?)?),
33+
"--cert" => out.cert = Some(PathBuf::from(iter.value("--cert")?)),
34+
"--key" => out.key = Some(PathBuf::from(iter.value("--key")?)),
2335
_ if arg.starts_with('-') => {
2436
return Err(ParseAction::error(format!("unknown option `{arg}`")));
2537
}
@@ -37,5 +49,35 @@ pub(super) fn parse(args: Vec<String>) -> Result<DavArgs, ParseAction> {
3749
"--token must contain 16 to 128 ASCII letters, digits, '-' or '_'",
3850
));
3951
}
52+
if out.username.is_some() != out.password.is_some() {
53+
return Err(ParseAction::error(
54+
"--username and --password must be provided together",
55+
));
56+
}
57+
if let Some(username) = out.username.as_deref()
58+
&& (username.is_empty() || username.contains([':', '\r', '\n']))
59+
{
60+
return Err(ParseAction::error(
61+
"--username must not be empty or contain ':', CR, or LF",
62+
));
63+
}
64+
if let Some(password) = out.password.as_deref()
65+
&& (password.is_empty() || password.contains(['\r', '\n']))
66+
{
67+
return Err(ParseAction::error(
68+
"--password must not be empty or contain CR or LF",
69+
));
70+
}
71+
if out.domain.is_some() && !out.tls {
72+
return Err(ParseAction::error("--domain requires --tls"));
73+
}
74+
if (out.cert.is_some() || out.key.is_some()) && !out.tls {
75+
return Err(ParseAction::error("--cert and --key require --tls"));
76+
}
77+
if out.cert.is_some() != out.key.is_some() {
78+
return Err(ParseAction::error(
79+
"--cert and --key must be provided together",
80+
));
81+
}
4082
Ok(out)
4183
}

src/cli/help.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Usage:
55
ii help [command]
66
ii send [options] [path...]
77
ii web [directory] [--port <port>] [--bind <ip>] [--token [value]] [--upload] [--path <dir>]
8-
ii dav [directory] [--port <port>] [--bind <ip>] [--token [value]] [--read-only]
8+
ii dav [directory] [--port <port>] [--bind <ip>] [--token [value]] [--read-only] [--username <username> --password <password>] [--tls [--domain <name>] [--cert <path> --key <path>]]
99
ii webrtc [--port <port>] [--bind <ip>] [--token [value]]
1010
ii tunnel (-s <target-host:port> | -c <ticket>) [--listen <ip:port>] [--relay <url> [-k]]
1111
ii recv [options] <ticket>
@@ -113,13 +113,19 @@ Options:
113113

114114
pub(crate) const DAV_HELP: &str = "\
115115
Usage:
116-
ii dav [directory] [--port <port>] [--bind <ip>] [--token [value]] [--read-only]
116+
ii dav [directory] [--port <port>] [--bind <ip>] [--token [value]] [--read-only] [--username <username> --password <password>] [--tls [--domain <name>] [--cert <path> --key <path>]]
117117
118118
Options:
119119
--port <port>
120120
--bind <ip>
121121
--token [value]
122122
--read-only
123+
--username <username> HTTP Basic Auth username; requires --password
124+
--password <password> HTTP Basic Auth password; requires --username
125+
--tls Enable HTTPS with an ii-generated self-signed certificate
126+
--domain <name> TLS DNS name used for the advertised URL and self-signed certificate
127+
--cert <path> PEM certificate chain; replaces the generated certificate
128+
--key <path> PEM private key; requires --cert
123129
";
124130

125131
pub(crate) const DISCOVER_HELP: &str = "\

src/cli/mod.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,4 +908,52 @@ mod tests {
908908
_ => panic!("expected dav command"),
909909
}
910910
}
911+
912+
#[test]
913+
fn dav_accepts_basic_auth_and_tls_options() {
914+
let cli = Cli::parse_from([
915+
"ii",
916+
"dav",
917+
"share",
918+
"--username",
919+
"alice",
920+
"--password=secret",
921+
"--tls",
922+
"--domain",
923+
"dav.example.com",
924+
"--cert",
925+
"fullchain.pem",
926+
"--key",
927+
"privkey.pem",
928+
]);
929+
match cli.command {
930+
Command::Dav(args) => {
931+
assert_eq!(args.username.as_deref(), Some("alice"));
932+
assert_eq!(args.password.as_deref(), Some("secret"));
933+
assert!(args.tls);
934+
assert_eq!(args.domain.as_deref(), Some("dav.example.com"));
935+
assert_eq!(args.cert, Some(PathBuf::from("fullchain.pem")));
936+
assert_eq!(args.key, Some(PathBuf::from("privkey.pem")));
937+
}
938+
_ => panic!("expected dav command"),
939+
}
940+
941+
for args in [
942+
vec!["ii", "dav", "--username", "alice"],
943+
vec!["ii", "dav", "--password", "secret"],
944+
vec!["ii", "dav", "--domain", "dav.example.com"],
945+
vec!["ii", "dav", "--tls", "--cert", "fullchain.pem"],
946+
vec![
947+
"ii",
948+
"dav",
949+
"--tls",
950+
"--username",
951+
"",
952+
"--password",
953+
"secret",
954+
],
955+
] {
956+
assert!(parse_args(args).is_err());
957+
}
958+
}
911959
}

src/command.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ pub struct DavArgs {
7979
pub web_bind: Option<IpAddr>,
8080
pub web_token: Option<String>,
8181
pub read_only: bool,
82+
pub username: Option<String>,
83+
pub password: Option<String>,
84+
pub tls: bool,
85+
pub domain: Option<String>,
86+
pub cert: Option<PathBuf>,
87+
pub key: Option<PathBuf>,
8288
}
8389

8490
#[derive(Debug, Clone)]

src/relay/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use logging::LogFilter;
66
#[cfg(test)]
77
pub(crate) use server::build_server_config;
88
pub use server::run;
9+
pub(crate) use server::tls_server_config;
910
#[cfg(test)]
1011
use server::{advertised_urls, load_tls_server_config};
1112

0 commit comments

Comments
 (0)