diff --git a/install-hub.sh b/install-hub.sh index f3285574..72a410a3 100755 --- a/install-hub.sh +++ b/install-hub.sh @@ -152,8 +152,8 @@ install_hub() { # depends on useradd having created one. install -d -m 0700 -o "$USER_NAME" "$DATA" - # A first run prints the one-time password, and only a missing database - # constitutes one. Checked before anything is installed. + # Only a missing database makes this a first install, which sets the + # password. Checked before anything is installed. first="" [ -f "$DATA/monitor.db" ] || first=1 @@ -238,17 +238,41 @@ MemoryMax=256M WantedBy=multi-user.target UNIT + # Taken from the binary's stdout rather than the journal, which some hosts + # keep nowhere. Set before the service starts, so the hub finds a password in + # place and does not generate a second one. A reset refuses a missing + # database, so the file is created first under the service user; SQLite reads + # an empty file as an empty database. + pw="" + if [ -n "$first" ]; then + install -m 0600 -o "$USER_NAME" /dev/null "$DATA/monitor.db" + # Quiet: a release predating the flag is handled after the start below. + pw="$(new_password 2>/dev/null)" + fi + systemctl daemon-reload systemctl enable "$SERVICE" >/dev/null 2>&1 || true # Not left to set -e: a binary that cannot exec fails the job itself, which is # precisely the case the rollback below exists for. Unguarded, the script # would exit here with a raw systemd error and leave the hub down on the # binary that just failed. + started="$(date '+%Y-%m-%d %H:%M:%S')" systemctl restart "$SERVICE" || true - # is-active answers before a unit that exits immediately has done so, and the - # first run also computes an argon2 hash. Wait, then query. + # is-active answers before a unit that exits immediately has done so. Wait, + # then query. sleep 3 if ! systemctl is-active --quiet "$SERVICE"; then + # A first install has nothing to keep serving, so there is no rollback. + # The new database holds nothing but a password never shown; it is removed + # so that a rerun is again a first install and shows one, and the unit is + # disabled, since any binary it started later -- on a reboot as well -- + # would set a password on an empty database and print it only to the + # journal. A backup, if any, stays for the next run. + if [ -n "$first" ]; then + systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true + rm -f "$DATA/monitor.db" "$DATA/monitor.db-wal" "$DATA/monitor.db-shm" + die "服务启动失败。日志:journalctl -u $SERVICE -n 50" + fi if [ -n "$backup" ]; then install -m 0755 "$backup" "$BIN" rm -f "$backup" @@ -259,6 +283,13 @@ UNIT fi rm -f "$BIN.old" ok "服务" "已启动并开机自启" + # A release predating --reset-password refuses it, and its first start sets + # the password and prints it to the journal instead. Only this start's lines: + # an earlier failed attempt printed a password for a database since removed. + if [ -n "$first" ] && [ -z "$pw" ]; then + pw="$(journalctl -u "$SERVICE" --since "$started" --no-pager 2>/dev/null | + sed -n 's/.*Emergency password: //p' | tail -1)" + fi if [ -n "$first" ]; then done_title="安装完成"; else done_title="升级完成"; fi printf '\n %s%s%s\n' "$B" "$done_title" "$N" @@ -266,13 +297,11 @@ UNIT printf '\n' field "面板" "${SITE:-http://127.0.0.1:$PORT}/admin" if [ -n "$first" ]; then - pw="$(journalctl -u "$SERVICE" --since '-2 min' --no-pager 2>/dev/null | - sed -n 's/.*Emergency password: //p' | tail -1)" if [ -n "$pw" ]; then field "密码" "$pw" - field " " "${D}只显示这一次,登录后到「设置」里改掉${N}" + field " " "${D}记下来,登录后到「安全」里改掉${N}" else - field "密码" "journalctl -u $SERVICE | grep Emergency" + field "密码" "没取到,重跑安装器选「重置密码」" fi fi field "数据" "$DATA/monitor.db" @@ -288,60 +317,31 @@ UNIT printf ' %s还差一步:配个反向代理%s\n' "$B" "$N" printf ' 面板只监听本机,公网访问不到——这是故意的,凭证不会在链路上裸奔。\n' printf ' 用 nginx / caddy / cf tunnel 任选一种,把 hub.example.com 换成你的域名,\n' - printf ' 配好之后用域名访问面板,我相信这难不倒你。\n\n' - proxy_configs - printf '\n %s四点注意与完整说明见 README 的「反向代理」一节。%s\n' "$D" "$N" + printf ' 配好之后用域名访问面板,我相信这难不倒你。\n' + # The documented configurations use the default port. + [ "$PORT" = 28080 ] || printf ' 文档里的 28080 换成 %s。\n' "$PORT" + printf ' 反向代理文档:https://monitor-document.pages.dev/install/reverse-proxy\n' fi } -# The three configurations, printed where they are needed rather than described: -# the hub is unreachable until one of them is in place, so an installer that -# stops at "put a proxy in front of it" leaves the install half done. -# -# The heredocs are unquoted so $PORT lands in them; every variable belonging to -# the proxy is escaped, since nginx and caddy read those themselves. -proxy_configs() { - printf ' %scaddy%s Caddyfile\n' "$B" "$N" - cat < 本机监听端口,默认 $PORT --site 一般不用填。面板拼安装命令用的是浏览器地址栏,配好反代 @@ -429,7 +433,7 @@ monitor hub 安装器 --help, -h 显示这段 hub 只监听 127.0.0.1,公网访问不到,需要自己配 nginx / caddy / CF 隧道把 -域名指过来。装完会打印具体怎么配。 +域名指过来,配法见 https://monitor-document.pages.dev/install/reverse-proxy 重跑一次就是升级:校验通过后才替换二进制,起不来会自动回滚到上一版; 没写的参数沿用上次的,所以升级不会把端口和 --site 冲掉。 @@ -446,6 +450,7 @@ while [ $# -gt 0 ]; do --site) [ $# -ge 2 ] || die "--site 后面要跟地址"; SITE="$2"; SITE_SET=1; shift 2 ;; --uninstall) ACTION=uninstall; shift ;; --purge) ACTION=uninstall; PURGE=1; shift ;; + --reset-password) ACTION=reset; shift ;; --yes | -y) YES=1; shift ;; -h | --help) usage; exit 0 ;; *) die "未知参数:$1(--help 看用法)" ;; @@ -486,6 +491,7 @@ command -v systemctl >/dev/null 2>&1 || case "$ACTION" in uninstall) banner; uninstall_hub ;; +reset) banner; reset_password ;; *) if [ -t 0 ]; then menu diff --git a/src/api.rs b/src/api.rs index 703832c3..63aaef3e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -434,7 +434,8 @@ async fn stream_live(app: Shared, mut socket: WebSocket, session: Option /// from an otherwise valid https domain entry. `main` warns about that at /// startup; this is for whoever reads the panel rather than the journal. const PROVISIONING_DENIED: &str = "请通过 HTTPS 域名访问面板后添加或安装节点;\ - 如果已经是域名访问,检查反向代理是否透传了 Host 与 X-Forwarded-Proto(见 README 的反代配置);\ + 如果已经是域名访问,检查反向代理是否透传了 Host 与 X-Forwarded-Proto\ + (见 https://monitor-document.pages.dev/install/reverse-proxy);\ 两者都没问题就检查 hub 的启动参数 --site,它必须是 https:// 加域名,不能是 IP、不能带路径"; pub(crate) fn https_domain(site: &str) -> Option { @@ -1466,8 +1467,7 @@ pub async fn save_settings( // receives a replacement. if key == "admin_password" { match hash_password(value).and_then(|h| { - app.db.set("admin_password_hash", &h)?; - app.db.drop_all_sessions()?; + app.db.replace_password(&h)?; issue_session(&app, &headers) }) { Ok(cookie) => reissued = cookie, diff --git a/src/db.rs b/src/db.rs index 7ef3c138..87183832 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1477,7 +1477,24 @@ impl Db { Ok(()) } - /// Invalidates every login. Used when the admin password changes. + /// Replaces the admin password hash and signs every session out, both or + /// neither: a reset that stored the hash and then failed would report failure + /// while the old password no longer works. + pub fn replace_password(&self, hash: &str) -> Result<()> { + let mut conn = self.conn(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO setting (key, value) VALUES ('admin_password_hash', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [hash], + )?; + tx.execute("DELETE FROM session", [])?; + tx.commit()?; + Ok(()) + } + + /// Invalidates every login. Used after a restore, which would otherwise + /// revive every session the backup holds. pub fn drop_all_sessions(&self) -> Result<()> { self.conn().execute("DELETE FROM session", [])?; Ok(()) diff --git a/src/main.rs b/src/main.rs index a21c7931..363a65ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -262,6 +262,7 @@ struct Args { database: String, site: String, themes: PathBuf, + reset_password: bool, } /// The default listen address. A v6 wildcard also accepts IPv4 through @@ -284,6 +285,7 @@ fn parse_args() -> Result { let mut database = "monitor.db".to_owned(); let mut site = String::new(); let mut themes = None; + let mut reset_password = false; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { let mut value = || it.next().unwrap_or_default(); @@ -292,17 +294,21 @@ fn parse_args() -> Result { "--db" => database = value(), "--site" => site = value(), "--themes" => themes = Some(PathBuf::from(value())), + "--reset-password" => reset_password = true, "-h" | "--help" => { println!( "monitor-hub {}\n\n\ - Usage: monitor-hub [--listen [::]:28080] [--db monitor.db] [--themes themes] [--site https://hub.example.com]\n\n\ + Usage: monitor-hub [--listen [::]:28080] [--db monitor.db] [--themes themes] [--site https://hub.example.com]\n \ + monitor-hub --db monitor.db --reset-password\n\n\ --listen defaults to [::]:28080, one socket serving IPv6 and IPv4\n\ both; where the kernel has no dual-stack sockets it is 0.0.0.0:28080.\n\ --themes defaults to a themes/ directory beside the database.\n\ --site is only needed behind a reverse proxy, where the address the\n\ panel is reached on is not the one agents should use. Left out, the\n\ hub answers on whatever ip:port it is asked, and the panel builds\n\ - install commands from the address in the browser's bar.", + install commands from the address in the browser's bar.\n\ + --reset-password replaces the emergency password, signs every session\n\ + out, prints the new password and exits. The database must exist.", env!("CARGO_PKG_VERSION") ); std::process::exit(0); @@ -315,7 +321,14 @@ fn parse_args() -> Result { let themes = themes.unwrap_or_else(|| { std::path::Path::new(&database).parent().unwrap_or_else(|| std::path::Path::new(".")).join("themes") }); - Ok(Args { listen, listen_defaulted, database, site: site.trim_end_matches('/').to_owned(), themes }) + Ok(Args { + listen, + listen_defaulted, + database, + site: site.trim_end_matches('/').to_owned(), + themes, + reset_password, + }) } #[tokio::main] @@ -328,6 +341,17 @@ async fn main() -> Result<()> { .init(); let args = parse_args()?; + // Delivered on the terminal rather than through the service log, which some + // hosts do not keep. A running hub reads the hash and its sessions from the + // database on every request, so the change applies without a restart. + if args.reset_password { + // A mistyped path would otherwise create an empty database and print a + // password no running hub reads. + anyhow::ensure!(std::path::Path::new(&args.database).is_file(), "no database at {}", args.database); + // install-hub.sh extracts the password by this exact line prefix. + println!("Emergency password: {}", new_password(&Db::open(&args.database)?)?); + return Ok(()); + } std::fs::create_dir_all(&args.themes)?; let (notes, inbox) = tokio::sync::mpsc::channel(notify::QUEUE); let app = Arc::new(App::new(Db::open(&args.database)?, args.site.clone(), args.themes, notes)); @@ -548,8 +572,7 @@ fn first_run(app: &App, url: &str) -> Result<()> { if app.db.get("admin_password_hash").is_some() { return Ok(()); } - let password = auth::random_token()[..24].to_owned(); - app.db.set("admin_password_hash", &auth::hash_password(&password)?)?; + let password = new_password(&app.db)?; println!( "\n Monitor hub is ready.\n\n \ Sign in at {url}/admin\n \ @@ -559,6 +582,13 @@ fn first_run(app: &App, url: &str) -> Result<()> { Ok(()) } +/// Sets a random 24-character admin password and signs every session out. +fn new_password(db: &Db) -> Result { + let password = auth::random_token()[..24].to_owned(); + db.replace_password(&auth::hash_password(&password)?)?; + Ok(password) +} + /// Billing cycles as whole months. `once` has none, so it never rolls over. fn cycle_months(cycle: &str) -> Option { Some(match cycle { @@ -857,5 +887,10 @@ mod tests { assert!(hash.starts_with("$argon2")); first_run(&app, "http://x").unwrap(); assert_eq!(app.db.get("admin_password_hash").unwrap(), hash, "must not rotate on restart"); + + app.db.create_session("s", i64::MAX).unwrap(); + new_password(&app.db).unwrap(); + assert_ne!(app.db.get("admin_password_hash").unwrap(), hash, "a reset must rotate it"); + assert!(!app.db.session_valid("s"), "a reset must sign every session out"); } }