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(),