From f1c949ba1c93984cf362198821551888efce1123 Mon Sep 17 00:00:00 2001 From: Freecode100Year Date: Tue, 10 Nov 2026 21:57:48 -0500 Subject: [PATCH] v0.11.5: never kick people for being quiet or for a locked phone - -idle-timeout now defaults to 0 (was 1h); the installer drops the old '-idle-timeout 1h' default on upgrade and leaves other values alone - SSH keepalives are still sent every 30 s, but unanswered ones no longer end the session (keepalive_max 0): a phone app suspended on lock screen can't answer, yet its TCP stack keeps acknowledging, so the session is still there on unlock. Peers that are really gone are closed by the kernel's retransmission timeout (~15 min), which frees the slot - docs updated Co-Authored-By: Claude Opus 5.5 --- README.en.md | 1 + README.md | 1 + VERSION | 2 +- docs/agents.en.md | 2 +- docs/agents.md | 2 +- docs/deploy.en.md | 2 +- docs/deploy.md | 2 +- docs/development.en.md | 2 +- docs/development.md | 2 +- docs/security.en.md | 4 ++-- docs/security.md | 4 ++-- install.sh | 9 ++++++++- src/main.rs | 4 ++-- src/server/mod.rs | 15 ++++++++++----- 14 files changed, 33 insertions(+), 19 deletions(-) diff --git a/README.en.md b/README.en.md index 1c5197b..a9847e4 100644 --- a/README.en.md +++ b/README.en.md @@ -92,6 +92,7 @@ Improvements chosen after studying similar projects (ssh-chat, devzat, SoftRoom, - [x] **Hardening after a security review**: modern SSH algorithms only, invisible and tag characters stripped (with a fuzz test), global TCP cap, fresh ban key when no ban is active - [x] **Renamed to VIVANT**: old servers migrate by running the installer - [x] **Offline one-click install**: one self-extracting file, `--to root@SERVER` deploys straight from your computer; plus Windows / macOS binaries for testing and LANs +- [x] **Nobody is kicked** (v0.11.5): quiet sessions and locked phones stay connected and are back in their room on unlock; a slot is freed only when the peer is really gone (about 15 minutes without a network). Upgrading removes the old "disconnect after 1 hour without posting" default ## Documentation diff --git a/README.md b/README.md index e1be976..c651fc5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ curl -fsSL https://raw.githubusercontent.com/Freecode100Year/vivant/main/install - [x] **安全审计后的加固**:只用现代 SSH 算法、剔除隐形字符和标签字符(附模糊测试)、TCP 总连接上限、没有生效封禁时自动换封禁密钥 - [x] **改名 VIVANT**:旧服务器运行一键安装命令自动迁移 - [x] **离线一键安装**:一个自解压文件,`--to root@服务器` 从自己电脑直接部署;另附 Windows / macOS 程序供测试和局域网使用 +- [x] **不踢人下线**(v0.11.5):不发言挂机、手机锁屏都不会被断开,解锁后还在原来的房间;只有对方真正掉线(网络消失约 15 分钟)才释放名额。旧服务器升级时自动去掉原来"1 小时不发言断开"的默认设置 ## 文档 diff --git a/VERSION b/VERSION index 35ad344..62d5dbd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.4 +0.11.5 diff --git a/docs/agents.en.md b/docs/agents.en.md index 2325b30..258c4f0 100644 --- a/docs/agents.en.md +++ b/docs/agents.en.md @@ -66,7 +66,7 @@ echo '{"text":"deploy finished ✅"}' | ssh -T -p 2222 bot@HOST ## Good to know -- **Staying resident:** bot-mode sessions are not disconnected for being quiet (`idle_kick` in `hello` is 0; the operator can set a limit with `-bot-idle-timeout`). The server sends an SSH keepalive every 30 seconds, so dead connections are cleaned up within about 2 minutes. +- **Staying resident:** bot-mode sessions are not disconnected for being quiet (`idle_kick` in `hello` is 0; the operator can set a limit with `-bot-idle-timeout`). The server sends an SSH keepalive every 30 seconds; an unanswered one does not end the session, and a connection whose peer is really gone is closed by the kernel after about 15 minutes. - **Reconnecting:** after `disconnect` or when the connection ends, reconnect, backing off (with some randomness, so not every bot rushes in after a server restart) if it keeps failing. You start with a new random nick; send `/nick` again: a nickname you chose stays reserved for your address for 10 minutes after a disconnect, so nobody else can take it (`-nick-hold`). If it says the nick is taken, your previous connection probably hasn't been cleaned up yet — try again in 30 seconds. - **De-duplicating:** joining a room replays its history; recognise messages you've already seen by `id` plus `time` (ids start over when the server restarts). - **Rate limits:** the same as everyone's — burst 5, then 1 per second; the same text three times in a row is blocked. `{"text":...}` is queued for you; plain text lines are not. diff --git a/docs/agents.md b/docs/agents.md index eb18b70..61650ab 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -66,7 +66,7 @@ echo '{"text":"部署完成 ✅"}' | ssh -T -p 2222 bot@HOST ## 注意事项 -- **常驻:** bot 模式的会话默认不会因为不发言被断开(`hello` 里 `idle_kick` 为 0;部署者可以用 `-bot-idle-timeout` 设上限)。服务器每 30 秒发一次 SSH 保活,掉线的连接约 2 分钟内会被清理。 +- **常驻:** bot 模式的会话默认不会因为不发言被断开(`hello` 里 `idle_kick` 为 0;部署者可以用 `-bot-idle-timeout` 设上限)。服务器每 30 秒发一次 SSH 保活;没回应不会断开,真正掉线(对方网络消失)的连接约 15 分钟后由系统清理。 - **重连:** 收到 `disconnect` 或连接结束后请自动重连,失败时逐渐加长等待时间(加点随机,免得服务器重启后所有 bot 同时涌入)。重连后先是新的随机名,再发一次 `/nick`:自己选的昵称在断线后 10 分钟内为同一地址保留,别人拿不走(`-nick-hold`)。如果提示已被占用,多半是上一个连接还没被清理,过 30 秒再试。 - **去重:** 重连进房间会回放历史,用 `id` 加 `time` 识别已经见过的消息(服务器重启后 `id` 从头编号)。 - **限流:** 和所有人一样,突发 5 条、每秒 1 条,同一句话连发 3 次会被拦。`{"text":...}` 会自动排队;直接发的纯文本行不会排队。 diff --git a/docs/deploy.en.md b/docs/deploy.en.md index 10fe169..356fa77 100644 --- a/docs/deploy.en.md +++ b/docs/deploy.en.md @@ -167,7 +167,7 @@ Config file `/etc/vivant.env`; after editing run `systemctl restart vivant`: | `-max-clients` / `-max-per-ip` / `-max-tcp-per-ip` / `-conn-per-min` | auto (`VIVANT_MAX_CLIENTS`) / 3 / 8 / 10 | connection limits; the installer works out the most people online for the machine and writes it to `VIVANT_MAX_CLIENTS`, e.g. 512 MB → 900, 1 GB → 2200, 2 GB 2 cores → 4900, 4 GB 2 cores → 5000, 8 GB 4 cores → 10000 | | `-read-rate` | 32768 | bytes per second a connection may send before it's slowed down, not dropped (burst 8×; 0 = off). Typing and pasting never get near it | | `-msg-rate` / `-msg-burst` / `-max-len` | 1/s / 5 / 500 | posting limits | -| `-idle-timeout` | 1h | disconnect after this long without posting (0 = never) | +| `-idle-timeout` | 0 | disconnect after this long without posting (0 = never; the default since v0.11.5) | | `-bot-idle-timeout` | 0 | the same for bot mode, i.e. resident agents (0 = never) | | `-nick-hold` | 10m | how long a chosen nickname stays reserved for its address after a disconnect | diff --git a/docs/deploy.md b/docs/deploy.md index dc9a73f..45455a8 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -167,7 +167,7 @@ sudo vivant-ctl status # 输出里的 Host key: SHA256:… | `-max-clients` / `-max-per-ip` / `-max-tcp-per-ip` / `-conn-per-min` | 自动(`VIVANT_MAX_CLIENTS`)/ 3 / 8 / 10 | 连接限制;安装脚本按机器算出最多在线人数写进 `VIVANT_MAX_CLIENTS`,参考:512 MB → 900,1 GB → 2200,2 GB 2 核 → 4900,4 GB 2 核 → 5000,8 GB 4 核 → 10000 | | `-read-rate` | 32768 | 每个连接每秒最多发来多少字节,超出会被放慢而不是断开(突发 8 倍;0 = 不限)。正常打字和粘贴远远用不到 | | `-msg-rate` / `-msg-burst` / `-max-len` | 1/s / 5 / 500 | 发言限制 | -| `-idle-timeout` | 1h | 不发言多久后断开(0 = 不断开) | +| `-idle-timeout` | 0 | 不发言多久后断开(0 = 不断开;v0.11.5 起默认不断开) | | `-bot-idle-timeout` | 0 | bot 模式(常驻智能体)不发言多久后断开(0 = 不断开) | | `-nick-hold` | 10m | 断线后自选昵称为原地址保留多久 | diff --git a/docs/development.en.md b/docs/development.en.md index 46f4320..7d6518d 100644 --- a/docs/development.en.md +++ b/docs/development.en.md @@ -16,7 +16,7 @@ To release: change the `VERSION` file (e.g. `1.0.1`) and push to main, or push a ``` src/main.rs flags, health endpoint, graceful shutdown -src/flags.rs flag parsing with Go's flag syntax (-addr :2222, -idle-timeout 1h) +src/flags.rs flag parsing with Go's flag syntax (-addr :2222, -idle-timeout 30m) install.sh one-line install / upgrade / uninstall (also vivant-ctl) src/chat/ rooms, identities, private messages, history, rate limits, bans, reports (no SSH) src/ui/term.rs full interface: small terminal renderer diff --git a/docs/development.md b/docs/development.md index bd9fc89..dfa2d39 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,7 +16,7 @@ make bench # 压测 ``` src/main.rs 参数、健康检查、优雅退出 -src/flags.rs 与 Go flag 包相同语法的参数解析(-addr :2222、-idle-timeout 1h) +src/flags.rs 与 Go flag 包相同语法的参数解析(-addr :2222、-idle-timeout 30m) install.sh 一键安装 / 升级 / 卸载(也是 vivant-ctl) src/chat/ 房间、身份、私聊、历史、限流、封禁、举报(与 SSH 无关) src/ui/term.rs 完整界面:轻量终端渲染器 diff --git a/docs/security.en.md b/docs/security.en.md index 3bbf2a3..3810841 100644 --- a/docs/security.en.md +++ b/docs/security.en.md @@ -17,8 +17,8 @@ - **Messages never touch disk.** Each room keeps the last 500 messages, for at most 24 hours, in memory (private rooms at most 100), and a restart wipes them; private rooms vanish when empty; private messages are not stored at all. - **Terminal injection protection.** Control characters in nicknames and messages are stripped — and with them every ANSI/OSC/DCS escape sequence, including OSC 52 (clipboard access) and `ESC [6n` (cursor position report) — as are Unicode bidi overrides, invisible characters such as zero-width spaces, and Unicode "tag" characters that can hide a line of invisible text. Zero-width joiners needed by emoji sequences, Persian and Indic scripts are kept. A test walks every code point in the control, format and tag ranges and checks none survives (`cargo test`, run by CI every time). - **Modern SSH algorithms only.** Key exchange is limited to mlkem768x25519 (post-quantum hybrid), curve25519 and ECDH; SHA-1 and finite-field Diffie-Hellman are gone (the latter costs 2048–4096-bit modular exponentiation before the user even authenticates, the easiest way to burn server CPU). hmac-sha1 is no longer accepted. Every SSH client from the last decade (OpenSSH 6.5+, PuTTY 0.68+, Termius, iSH) supports these. -- **Abuse protection.** Per address: at most 3 sessions, 8 TCP connections and 10 new connections a minute; each connection may send 32 KB/s before it is slowed down (so junk floods can't burn CPU); a message token bucket (burst 5, 1 per second); repeated messages blocked; automatic mute for repeated flooding; disconnect after 1 hour without posting. -- **Handshake timeout and a global connection cap.** Connections that haven't entered the chat within 30 seconds (port scanners, stalled handshakes) are closed instead of holding a slot until the 1-hour idle timeout. The total number of TCP connections is capped too (twice the session limit plus 500), so a flood from many different addresses can't exhaust file descriptors. +- **Abuse protection.** Per address: at most 3 sessions, 8 TCP connections and 10 new connections a minute; each connection may send 32 KB/s before it is slowed down (so junk floods can't burn CPU); a message token bucket (burst 5, 1 per second); repeated messages blocked; automatic mute for repeated flooding. Nobody is disconnected for being quiet or for a locked phone (`-idle-timeout` sets a limit, default 0); when a peer is really gone the kernel closes the connection after about 15 minutes and frees the slot. +- **Handshake timeout and a global connection cap.** Connections that haven't entered the chat within 30 seconds (port scanners, stalled handshakes) are closed instead of holding a slot. The total number of TCP connections is capped too (twice the session limit plus 500), so a flood from many different addresses can't exhaust file descriptors. - **Design rule: passphrases are asked in the chat, never at SSH authentication.** If a passphrase-protected private instance is ever added, the connection will be accepted with `none` first and the passphrase typed in the chat. The reason: once an SSH server stops accepting `none`, clients start offering every public key on the machine, and the user's identity is exposed. ssh-chat's server password and sshoosh's invite tokens are both asked at authentication; this project doesn't do that. - **Tor onion service (optional).** On a normal connection the VPS and the cloud provider still see the source IP; that can't be removed at the application level. Install with `--tor` and the chat is also published at a `.onion` address: for users arriving through Tor the server only sees "from Tor", never their real IP. The cost: those users can't be banned by address (`/ban` kicks instead), and they share one set of more generous limits. diff --git a/docs/security.md b/docs/security.md index c872486..bd8c8c7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -17,8 +17,8 @@ - **消息不落盘。** 每个房间只在内存保留最近 500 条、最长 24 小时(私人房间最多 100 条),服务器重启即清空;私人房间清空即销毁;私聊不保存。 - **终端注入防护。** 昵称和消息里的控制字符(因此所有 ANSI/OSC/DCS 转义序列,包括读写剪贴板的 OSC 52、让终端回报光标位置的 `ESC [6n`)、Unicode 双向覆盖字符、零宽空格之类的隐形字符、可以藏一段看不见文字的 Unicode “标签”字符一律剔除;表情组合和波斯语、印度诸文字需要的零宽连接符保留。有测试遍历控制字符、格式字符和标签字符所在的全部码位,确认没有一个能留下来(`cargo test`,CI 每次都跑)。 - **只用现代 SSH 算法。** 密钥交换只接受 mlkem768x25519(抗量子混合)、curve25519 和 ECDH;去掉了 SHA-1 和有限域 Diffie-Hellman(后者在用户认证前就要做 2048–4096 位模幂运算,最容易被用来消耗服务器 CPU)。MAC 不再接受 hmac-sha1。近十年的 SSH 客户端(OpenSSH 6.5+、PuTTY 0.68+、Termius、iSH)都支持。 -- **防滥用。** 每个地址最多 3 个会话、8 个 TCP 连接、每分钟 10 次新连接;每个连接每秒最多收 32 KB,超出的数据会被放慢(防止有人灌垃圾数据消耗 CPU);消息令牌桶(突发 5 条、每秒 1 条);重复消息拦截;频繁超限自动禁言;1 小时不发言自动断开。 -- **握手超时与总连接上限。** 连上来 30 秒内没有进入聊天的连接(端口扫描器、卡住的握手)会被断开,不会占着连接名额等到 1 小时空闲超时。另外全服务器的 TCP 连接总数有上限(会话上限的 2 倍再加 500),来自大量不同地址的连接洪水也耗不尽文件描述符。 +- **防滥用。** 每个地址最多 3 个会话、8 个 TCP 连接、每分钟 10 次新连接;每个连接每秒最多收 32 KB,超出的数据会被放慢(防止有人灌垃圾数据消耗 CPU);消息令牌桶(突发 5 条、每秒 1 条);重复消息拦截;频繁超限自动禁言。不发言挂机、手机锁屏都不会被断开(`-idle-timeout` 可设上限,默认 0);对方真正掉线时,系统约 15 分钟后关闭连接、释放名额。 +- **握手超时与总连接上限。** 连上来 30 秒内没有进入聊天的连接(端口扫描器、卡住的握手)会被断开,不会一直占着连接名额。另外全服务器的 TCP 连接总数有上限(会话上限的 2 倍再加 500),来自大量不同地址的连接洪水也耗不尽文件描述符。 - **设计原则:口令在聊天界面里问,不在 SSH 认证层问。** 如果将来要做需要口令的私有实例,会先用 `none` 认证放行连接,再在聊天界面里输入口令。原因是:SSH 服务器一旦不放行 `none` 认证,客户端就会先把本机所有公钥逐个出示给服务器,用户的身份就暴露了。ssh-chat 的整站口令和 sshoosh 的邀请令牌都在认证层索取,本项目不这样做。 - **Tor 洋葱服务(可选)。** 普通连接下,VPS 和云服务商仍然能看到来源 IP,这在应用层无法消除。安装时加 `--tor`,聊天室会同时发布为 `.onion` 地址:通过 Tor 进来的用户,服务器只能看到“来自 Tor”,看不到真实 IP。代价是这些用户无法按地址封禁(`/ban` 会改为踢出),他们共用一组更宽松的限流额度。 diff --git a/install.sh b/install.sh index d6ac5f2..25fbdc5 100755 --- a/install.sh +++ b/install.sh @@ -475,13 +475,20 @@ GOMEMLIMIT=$(mem_limit) # 其他参数 · Any other flags. # 聊天记录默认只在内存保留 24 小时、每房间 500 条(-history / -history-ttl 可改)。 # Chat history: memory only, 24 hours, 500 per room by default (-history / -history-ttl). -VIVANT_ARGS=-max-per-ip 3 -idle-timeout 1h +VIVANT_ARGS=-max-per-ip 3 EOF chmod 0644 "$ENV_FILE" ok "已创建 Created $ENV_FILE" elif [ -n "$PORT" ]; then set_env VIVANT_ADDR ":$PORT" fi + # Upgrades: the old default of disconnecting people after 1 hour without + # posting is dropped (v0.11.5: nobody is kicked for being quiet or for a + # locked phone). A different value someone chose themselves is left alone. + if grep -q '^VIVANT_ARGS=.*-idle-timeout 1h\b' "$ENV_FILE" 2>/dev/null; then + sed -i '/^VIVANT_ARGS=/s/ *-idle-timeout 1h\b//' "$ENV_FILE" + ok "不再断开不发言的人 · Quiet sessions are no longer disconnected" + fi # Upgrades: the old built-in 1-hour history setting gives way to the new # 24-hour default. Values someone chose themselves are left alone. if grep -q '^VIVANT_ARGS=.*-history 50 -history-ttl 1h' "$ENV_FILE" 2>/dev/null; then diff --git a/src/main.rs b/src/main.rs index bad3694..e176b1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,8 +111,8 @@ fn main() { fs.int("max-len", 500, "单条消息最大字数 · maximum message length in characters"); fs.duration( "idle-timeout", - Duration::from_secs(3600), - "多久不发言就断开(0 = 不断开)· disconnect sessions silent for this long (0 = never)", + Duration::ZERO, + "多久不发言就断开(默认 0 = 不断开,挂机也不踢)· disconnect sessions silent for this long (default 0 = never)", ); fs.duration( "bot-idle-timeout", diff --git a/src/server/mod.rs b/src/server/mod.rs index fa11d5f..2550afb 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -145,12 +145,17 @@ impl Server { auth_rejection_time_initial: Some(Duration::ZERO), keys: vec![host_key], inactivity_timeout: (!opt.idle_timeout.is_zero()).then_some(opt.idle_timeout), - // Detects dead connections (a laptop that went to sleep, a NAT that - // forgot the mapping) within about two minutes, freeing their - // slots, and keeps idle long-lived sessions — resident agents — - // from being dropped by middleboxes. + // A keepalive every 30 s keeps NAT mappings and middleboxes from + // dropping quiet sessions. Unanswered keepalives never end a session + // (keepalive_max 0): a phone app that the OS suspends on lock screen + // cannot answer them, but the phone's TCP stack still acknowledges + // the packets, so the session survives and is there on unlock. A + // peer that is really gone (network lost, laptop powered off) stops + // acknowledging; the kernel then gives up retransmitting the + // keepalives (net.ipv4.tcp_retries2, about 15 minutes) and closes + // the connection, which frees the slot. keepalive_interval: Some(Duration::from_secs(30)), - keepalive_max: 4, + keepalive_max: 0, max_auth_attempts: 6, nodelay: true, preferred: preferred(),