diff --git a/Makefile b/Makefile index bc4a39e..2271adc 100644 --- a/Makefile +++ b/Makefile @@ -104,7 +104,7 @@ ifneq ($(ROMFS),) export _3DSXFLAGS += --romfs=$(CURDIR)/$(ROMFS) endif -.PHONY: $(BUILD) clean all ime-dict test-ime cia cia-tools cia-clean +.PHONY: $(BUILD) clean all ime-dict test-ime test-config test-terminal cia cia-tools cia-clean all: $(BUILD) @@ -118,6 +118,14 @@ ime-dict: test-ime: @bash tools/test_ime.sh +# Host-side config parser tests (including quoted keychain passwords). +test-config: + @bash tools/test_config.sh + +# Host-side terminal protocol tests (scrolling + fish cursor queries). +test-terminal: + @bash tools/test_terminal.sh + # ── M9: CIA packaging ─────────────────────────────────────────────── # # `make cia` produces DSSH.cia from the already-built ELF + romfs. diff --git a/README.md b/README.md index d0a109f..d8212c2 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,9 @@ port = 22 user = ubuntu key_path = sdmc:/3ds/3dssh/id_rsa passphrase = + +# Optional: unlock the current SSH user's macOS login keychain +macos_keychain_password = ``` | Field | Meaning | @@ -192,6 +195,32 @@ passphrase = | `user` | SSH login user | | `key_path` | Private key path; `sdmc:/...` is the 3DS standard SD prefix | | `passphrase` | Optional key passphrase; leave empty (typing one on the soft keyboard is awkward) | +| `macos_keychain_password` | Optional macOS login password; setting it enables automatic unlock for the current SSH user | + +When `macos_keychain_password` is set, DSSH waits for the current interactive PTY +shell to finish initialization, then runs +`/usr/bin/security unlock-keychain "$HOME/Library/Keychains/login.keychain-db"`. +It waits until macOS prints `password to unlock` before sending the password +through the PTY—not `-p`, the remote process arguments, or shell history. This +matches macOS Keychain's interactive-session requirement and works with fish, +zsh, and bash. DSSH then clears its in-memory copy. If unlock fails, the normal +SSH shell remains available and a warning is shown. A later `claude` launch can +then read the credential already stored in that login keychain. + +Successful unlock diagnostics are hidden and the bootstrap output is cleared +before fish redraws its prompt. On failure, DSSH keeps one concise warning with +the unlock and verification status codes. The password itself is never shown. + +Quote a password containing `#` or leading/trailing spaces: + +```ini +macos_keychain_password = "my # password" +``` + +> ⚠️ This option stores your **macOS login password in plaintext on the +> removable SD card**. That is more sensitive than a dedicated SSH key. Use a +> dedicated macOS account where practical, keep the SD card physically secure, +> and leave this field empty unless you accept this risk. Final SD layout: diff --git a/README.zh.md b/README.zh.md index cf4cc32..265eeeb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -166,6 +166,9 @@ port = 22 user = ubuntu key_path = sdmc:/3ds/3dssh/id_rsa passphrase = + +# 可选:自动解锁当前 SSH 用户的 macOS 登录 keychain +macos_keychain_password = ``` | 字段 | 说明 | @@ -175,6 +178,30 @@ passphrase = | `user` | 服务器登录用户名 | | `key_path` | 私钥路径,`sdmc:/...` 是 3DS 标准 SD 路径前缀 | | `passphrase` | 私钥口令;建议留空(SD 卡上输 passphrase 体验差) | +| `macos_keychain_password` | 可选的 macOS 登录密码;填写后为当前 SSH 用户启用自动解锁 | + +填写 `macos_keychain_password` 后,DSSH 会等待当前交互式 PTY shell 初始化完成, +然后执行 +`/usr/bin/security unlock-keychain "$HOME/Library/Keychains/login.keychain-db"`。 +只有检测到 macOS 输出 `password to unlock` 后才会通过 PTY 发送密码;不使用 +`-p`,不会进入远端进程参数或 shell history。这满足 macOS Keychain 对交互式 +会话的要求,并兼容 fish、zsh 和 bash。之后 DSSH 会清除内存中的密码副本。 +即使解锁失败,普通 SSH shell 仍可正常使用,并会显示一条警告。之后再启动 +`claude`,它就可以读取登录 keychain 中已有的 credential。 + +解锁成功时不会再显示诊断日志,DSSH 会清理 bootstrap 输出后再由 fish +重绘提示符。失败时只保留一条包含 unlock/verify 状态码的简短警告;密码 +本身永远不会显示。 + +如果密码包含 `#` 或首尾空格,请加引号: + +```ini +macos_keychain_password = "my # password" +``` + +> ⚠️ 此功能会把你的 **macOS 登录密码以明文保存在可移除的 SD 卡上**,其 +> 敏感程度高于一把专用 SSH 密钥。建议尽量使用专门的 macOS 账户,妥善保管 +> SD 卡;只有接受这一风险时才填写该字段。 最终 SD 卡布局: diff --git a/sd_template/3ds/3dssh/config.ini.example b/sd_template/3ds/3dssh/config.ini.example index b1d270d..8fb74c4 100644 --- a/sd_template/3ds/3dssh/config.ini.example +++ b/sd_template/3ds/3dssh/config.ini.example @@ -25,3 +25,14 @@ key_path = sdmc:/3ds/3dssh/id_rsa # Passphrase for the key, or leave empty for an unencrypted key. # (Recommended: unencrypted, since the SD card is the only place the key lives.) passphrase = + +# Optional: automatically unlock the current SSH user's macOS login keychain. +# Leave empty to disable it. +# The password is sent only after the interactive PTY shows its unlock prompt; +# it is never placed in a command-line argument or shell history. +# Quote it if it contains # or leading/trailing spaces, for example: +# macos_keychain_password = "my # password" +# +# SECURITY WARNING: this stores your macOS login password in plaintext on the +# removable SD card. Prefer a dedicated macOS account and keep the SD card safe. +macos_keychain_password = diff --git a/source/config.c b/source/config.c index 0247c07..8bef7bc 100644 --- a/source/config.c +++ b/source/config.c @@ -12,6 +12,49 @@ static void trim(char *s) { while (len > 0 && isspace((unsigned char)s[len - 1])) s[--len] = 0; } +/* Remove an inline comment while preserving # inside quoted values. */ +static void strip_comment(char *s) { + char quote = 0; + int escaped = 0; + for (; *s; s++) { + if (quote) { + if (escaped) { + escaped = 0; + } else if (*s == '\\') { + escaped = 1; + } else if (*s == quote) { + quote = 0; + } + } else if (*s == '\'' || *s == '"') { + quote = *s; + } else if (*s == '#') { + *s = 0; + return; + } + } +} + +/* Strip matching quotes and decode only escaped quote/backslash pairs. + * Other backslashes remain literal so existing paths/passwords do not change. */ +static void unquote(char *s) { + size_t len = strlen(s); + if (len < 2 || (s[0] != '\'' && s[0] != '"') || s[len - 1] != s[0]) + return; + + char quote = s[0]; + char *src = s + 1; + char *end = s + len - 1; + char *dst = s; + while (src < end) { + if (*src == '\\' && src + 1 < end && + (src[1] == quote || src[1] == '\\')) { + src++; + } + *dst++ = *src++; + } + *dst = 0; +} + static void set_str(char *dst, const char *src) { snprintf(dst, CONFIG_STR_MAX, "%s", src); } @@ -24,14 +67,14 @@ int config_load(ssh_config_t *cfg, const char *path) { set_str(cfg->user, "ubuntu"); set_str(cfg->key_path, "sdmc:/3ds/3dssh/id_rsa"); cfg->passphrase[0] = 0; + cfg->macos_keychain_password[0] = 0; FILE *fp = fopen(path, "r"); if (!fp) return 0; char line[CONFIG_STR_MAX * 2]; while (fgets(line, sizeof(line), fp)) { - char *hash = strchr(line, '#'); - if (hash) *hash = 0; + strip_comment(line); char *eq = strchr(line, '='); if (!eq) continue; *eq = 0; @@ -39,6 +82,7 @@ int config_load(ssh_config_t *cfg, const char *path) { char *val = eq + 1; trim(key); trim(val); + unquote(val); if (!*key) continue; if (!strcmp(key, "host")) set_str(cfg->host, val); @@ -46,6 +90,8 @@ int config_load(ssh_config_t *cfg, const char *path) { else if (!strcmp(key, "user")) set_str(cfg->user, val); else if (!strcmp(key, "key_path")) set_str(cfg->key_path, val); else if (!strcmp(key, "passphrase")) set_str(cfg->passphrase, val); + else if (!strcmp(key, "macos_keychain_password")) + set_str(cfg->macos_keychain_password, val); } fclose(fp); return 1; diff --git a/source/config.h b/source/config.h index 3e90e24..c19c444 100644 --- a/source/config.h +++ b/source/config.h @@ -11,6 +11,7 @@ * user = ubuntu * key_path = sdmc:/3ds/3dssh/id_rsa * passphrase = # leave empty for unencrypted keys + * macos_keychain_password = # quote values containing # or edge spaces */ #define CONFIG_STR_MAX 256 @@ -21,6 +22,7 @@ typedef struct { char user[CONFIG_STR_MAX]; char key_path[CONFIG_STR_MAX]; char passphrase[CONFIG_STR_MAX]; + char macos_keychain_password[CONFIG_STR_MAX]; } ssh_config_t; /* Fills cfg with defaults then overlays values from config file (if present). diff --git a/source/keychain_protocol.h b/source/keychain_protocol.h new file mode 100644 index 0000000..a58291d --- /dev/null +++ b/source/keychain_protocol.h @@ -0,0 +1,8 @@ +#ifndef DSSH_KEYCHAIN_PROTOCOL_H +#define DSSH_KEYCHAIN_PROTOCOL_H + +/* Match the emitted OSC control sequence, not the printable marker text in + * the interactive shell's echoed command line. */ +#define DSSH_KEYCHAIN_RESULT_MARKER "\x1b]777;DSSH_KEYCHAIN_RESULT" + +#endif /* DSSH_KEYCHAIN_PROTOCOL_H */ diff --git a/source/main.c b/source/main.c index 402375d..4c00699 100644 --- a/source/main.c +++ b/source/main.c @@ -24,6 +24,7 @@ #include "ssh_client.h" #include "config.h" +#include "keychain_protocol.h" #include "terminal.h" #include "renderer.h" #include "keyboard.h" @@ -39,6 +40,12 @@ #define CONFIG_PATH "sdmc:/3ds/3dssh/config.ini" #define READ_BUFSZ 2048 +/* Interactive shell startup can be noticeably slower over Tailscale/DERP, + * especially when fish/zsh startup hooks perform network or filesystem I/O. */ +#define SHELL_READY_TIMEOUT_MS 60000 +#define KEYCHAIN_PROMPT_TIMEOUT_MS 30000 +#define KEYCHAIN_RESULT_TIMEOUT_MS 30000 + #define COLOR_OK 0xa6e3a1ff #define COLOR_WARN 0xfab387ff #define COLOR_ERR 0xf38ba8ff @@ -99,6 +106,232 @@ static void feed_terminal(terminal_t *term, const char *raw, int raw_len) { terminal_write_n(term, buf, valid_end); } +typedef struct { + int unlock_status; + int verify_status; +} keychain_report_t; + +static int startup_write_all(ssh_client_t *ssh, const char *data, int len, + int timeout_ms); + +static void flush_terminal_responses(ssh_client_t *ssh, terminal_t *term) { + char reply[TERM_RESPONSE_MAX]; + int n; + while ((n = terminal_take_response(term, reply, sizeof(reply))) > 0) + if (startup_write_all(ssh, reply, n, 1000) != 0) break; +} + +static int startup_write_all(ssh_client_t *ssh, const char *data, int len, + int timeout_ms) { + int sent = 0; + u64 deadline = osGetTime() + (u64)timeout_ms; + while (sent < len && osGetTime() < deadline) { + int n = ssh_write(ssh, data + sent, len - sent); + if (n < 0) return -1; + if (n == 0) { + svcSleepThread(10 * 1000 * 1000LL); + } else { + sent += n; + } + } + return sent == len ? 0 : -1; +} + +static void append_startup_tail(char *tail, int cap, int *tail_len, + const char *data, int len) { + if (len >= cap - 1) { + data += len - (cap - 1); + len = cap - 1; + *tail_len = 0; + } else if (*tail_len + len >= cap) { + int drop = *tail_len + len - (cap - 1); + memmove(tail, tail + drop, (size_t)(*tail_len - drop)); + *tail_len -= drop; + } + memcpy(tail + *tail_len, data, (size_t)len); + *tail_len += len; + tail[*tail_len] = 0; +} + +/* Pump the main interactive PTY until either marker/prompt appears. Feeding + * the terminal while waiting is essential for fish: its startup asks CSI 6n + * and will not finish drawing the prompt until DSSH sends the queued CPR + * reply. The alternate marker is optional; a return value of 1 means needle, + * 2 means alternate, 0 means timeout, and -1 means SSH disconnected. */ +static int wait_for_remote_text_any(ssh_client_t *ssh, terminal_t *term, + const char *needle, + const char *alternate, + int timeout_ms, + char *capture, int capture_sz) { + char raw[READ_BUFSZ]; + char tail[768] = {0}; + int tail_len = 0; + u64 deadline = osGetTime() + (u64)timeout_ms; + + while (osGetTime() < deadline) { + int n = ssh_read(ssh, raw, sizeof(raw)); + if (n < 0) return -1; + if (n == 0) { + svcSleepThread(10 * 1000 * 1000LL); + continue; + } + append_startup_tail(tail, sizeof(tail), &tail_len, raw, n); + feed_terminal(term, raw, n); + flush_terminal_responses(ssh, term); + char *found = strstr(tail, needle); + char *found_alternate = alternate ? strstr(tail, alternate) : NULL; + if (found || found_alternate) { + if (capture && capture_sz > 0) + snprintf(capture, (size_t)capture_sz, "%s", tail); + if (!found) return 2; + if (!found_alternate) return 1; + return found <= found_alternate ? 1 : 2; + } + } + if (capture && capture_sz > 0) + snprintf(capture, (size_t)capture_sz, "%s", tail); + return 0; +} + +static int wait_for_remote_text(ssh_client_t *ssh, terminal_t *term, + const char *needle, int timeout_ms, + char *capture, int capture_sz) { + return wait_for_remote_text_any(ssh, term, needle, NULL, timeout_ms, + capture, capture_sz); +} + +static int parse_keychain_result(const char *capture, + keychain_report_t *report, + char *err, int err_sz) { + const char *result = strstr(capture, "DSSH_KEYCHAIN_RESULT"); + if (!result || sscanf(result, + "DSSH_KEYCHAIN_RESULT unlock=%d verify=%d", + &report->unlock_status, &report->verify_status) != 2) { + snprintf(err, (size_t)err_sz, "invalid keychain result marker"); + return -1; + } + if (report->unlock_status != 0) { + snprintf(err, (size_t)err_sz, + "security unlock-keychain failed (status=%d)", + report->unlock_status); + return -1; + } + if (report->verify_status != 0) { + snprintf(err, (size_t)err_sz, + "keychain verification failed (status=%d)", + report->verify_status); + return -1; + } + err[0] = 0; + return 0; +} + +/* Unlock through the already-open interactive PTY, mirroring ServerCC's + * proven macOS flow: wait for a prompt-ready shell, start security, wait for + * its password prompt, then write the password. An exec channel without a + * PTY can fail with "User interaction is not allowed" on macOS. */ +static int unlock_macos_keychain(ssh_client_t *ssh, terminal_t *term, + const char *password, + keychain_report_t *report, + char *err, int err_sz) { + static const char ready_command[] = + "printf '\\033]777;%s_READY_%s\\007' DSSH SHELL\n"; + static const char ready_marker[] = "DSSH_READY_SHELL"; + static const char unlock_command[] = + "sh -c '/usr/bin/security unlock-keychain " + "\"$HOME/Library/Keychains/login.keychain-db\"; u=$?; v=-1; " + "if [ \"$u\" -eq 0 ]; then /usr/bin/security show-keychain-info " + "\"$HOME/Library/Keychains/login.keychain-db\" >/dev/null 2>&1; " + "v=$?; fi; " + "printf \"\\033]777;DSSH_KEYCHAIN_RESULT unlock=%d verify=%d\\007\" " + "\"$u\" \"$v\"; " + "printf \"\\033[2J\\033[H\"; " + "if [ \"$u\" -ne 0 ] || [ \"$v\" -ne 0 ]; then " + "printf \"[keychain] unlock failed (unlock=%d verify=%d)\\n\" " + "\"$u\" \"$v\"; fi'\n"; + static const char result_marker[] = DSSH_KEYCHAIN_RESULT_MARKER; + char capture[768]; + + report->unlock_status = -1; + report->verify_status = -1; + if (!password || !*password) { + snprintf(err, (size_t)err_sz, "keychain password is empty"); + return -1; + } + + if (startup_write_all(ssh, ready_command, + sizeof(ready_command) - 1, 3000) != 0) { + snprintf(err, (size_t)err_sz, "shell readiness probe write failed"); + return -1; + } + int rc = wait_for_remote_text(ssh, term, ready_marker, + SHELL_READY_TIMEOUT_MS, + NULL, 0); + if (rc <= 0) { + if (rc < 0) { + snprintf(err, (size_t)err_sz, + "SSH disconnected while waiting for shell"); + } else { + snprintf(err, (size_t)err_sz, + "shell readiness probe timed out after %ds", + SHELL_READY_TIMEOUT_MS / 1000); + } + return -1; + } + + if (startup_write_all(ssh, unlock_command, + sizeof(unlock_command) - 1, 3000) != 0) { + snprintf(err, (size_t)err_sz, "unlock command write failed"); + return -1; + } + rc = wait_for_remote_text_any(ssh, term, "password to unlock", + result_marker, + KEYCHAIN_PROMPT_TIMEOUT_MS, + capture, sizeof(capture)); + if (rc == 2) { + /* security can fail before it ever asks for a password (for example, + * when the keychain path or utility is unavailable). Consume and + * report that result immediately instead of waiting for a prompt that + * will never arrive. */ + return parse_keychain_result(capture, report, err, err_sz); + } + if (rc <= 0) { + if (rc < 0) { + snprintf(err, (size_t)err_sz, + "SSH disconnected before keychain password prompt"); + } else { + snprintf(err, (size_t)err_sz, + "keychain password prompt timed out after %ds", + KEYCHAIN_PROMPT_TIMEOUT_MS / 1000); + } + return -1; + } + + /* The password is sent only after security owns the foreground PTY and + * has disabled echo, so it is neither displayed nor stored in history. */ + if (startup_write_all(ssh, password, (int)strlen(password), 3000) != 0 || + startup_write_all(ssh, "\n", 1, 3000) != 0) { + snprintf(err, (size_t)err_sz, "keychain password write failed"); + return -1; + } + + rc = wait_for_remote_text(ssh, term, result_marker, + KEYCHAIN_RESULT_TIMEOUT_MS, + capture, sizeof(capture)); + if (rc <= 0) { + if (rc < 0) { + snprintf(err, (size_t)err_sz, + "SSH disconnected during keychain verification"); + } else { + snprintf(err, (size_t)err_sz, + "keychain result timed out after %ds", + KEYCHAIN_RESULT_TIMEOUT_MS / 1000); + } + return -1; + } + return parse_keychain_result(capture, report, err, err_sz); +} + /* Wall-clock of the last successful ssh_write. Compared against * last_rx_at by the main loop's interactivity-stall detector — if we * sent input recently but haven't received anything back, the network @@ -106,6 +339,11 @@ static void feed_terminal(terminal_t *term, const char *raw, int raw_len) { * dead. Updated only by send_to_ssh below. */ static time_t g_last_tx_at = 0; +static void clear_secret(char *s, size_t len) { + volatile unsigned char *p = (volatile unsigned char *)s; + while (len-- > 0) *p++ = 0; +} + /* Snap the local terminal view to the bottom (canceling any user-side * scrollback peek) right before sending a key. This way the user always * sees what they just typed, even if they were glancing at history. */ @@ -216,7 +454,7 @@ int main(int argc, char *argv[]) { { char banner[160]; snprintf(banner, sizeof(banner), - "connecting to \x1b[33m%s@%s:%d\x1b[0m...\r\n", + "connecting to \x1b[33m%.48s@%.64s:%d\x1b[0m...\r\n", cfg.user, cfg.host, cfg.port); terminal_write(term, banner); } @@ -238,6 +476,7 @@ int main(int argc, char *argv[]) { cfg.host, cfg.port, cfg.user, cfg.key_path, NULL, cfg.passphrase[0] ? cfg.passphrase : NULL, + R_TOP_COLS, R_TOP_ROWS, err, sizeof(err)); if (!ssh) { @@ -247,13 +486,74 @@ int main(int argc, char *argv[]) { snprintf(status_buf, sizeof(status_buf), "ssh err"); status_color = COLOR_ERR; } else { - terminal_write(term, "\x1b[32mconnected.\x1b[0m\r\n"); + /* Local startup banners are not part of the remote PTY screen. Reset + * before parsing shell output so fish's cursor-position queries see + * the exact same coordinate system as sshd. */ + terminal_reset(term); ssh_set_pty_size(ssh, R_TOP_COLS, R_TOP_ROWS); - snprintf(status_buf, sizeof(status_buf), "connected %s:%d", + snprintf(status_buf, sizeof(status_buf), "connected %.56s:%d", cfg.host, cfg.port); status_color = COLOR_OK; + + if (cfg.macos_keychain_password[0]) { + /* Keep this local-only progress line visible while bootstrap + * blocks, then reset again so fish CPR uses remote coordinates. */ + terminal_write(term, + "\x1b[36mUnlocking macOS keychain...\x1b[0m\r\n"); + C3D_FrameBegin(C3D_FRAME_SYNCDRAW); + C2D_TargetClear(top, C2D_Color32(0x1a, 0x1b, 0x26, 0xff)); + C2D_SceneBegin(top); + renderer_draw_terminal(r, term); + C2D_TargetClear(bot, C2D_Color32(0x18, 0x18, 0x25, 0xff)); + C2D_SceneBegin(bot); + softkb_draw(kb, r, kbd); + C3D_FrameEnd(0); + terminal_reset(term); + + keychain_report_t report = { -1, -1 }; + int unlock_rc = unlock_macos_keychain( + ssh, term, cfg.macos_keychain_password, + &report, err, sizeof(err)); + + /* ssh_read()/ssh_write() clear the connected flag on a hard + * error. Do not leave a non-NULL but unusable session in the + * idle loop. */ + if (!ssh_is_connected(ssh)) { + if (unlock_rc == 0) + snprintf(err, sizeof(err), + "SSH disconnected during keychain bootstrap"); + char line[320]; + snprintf(line, sizeof(line), + "\x1b[31mSSH error:\x1b[0m %s\r\n", err); + terminal_write(term, line); + snprintf(status_buf, sizeof(status_buf), "ssh err"); + status_color = COLOR_ERR; + ssh_disconnect(ssh); + ssh = NULL; + } else if (unlock_rc != 0) { + /* Completed commands print FAILED + exit codes themselves. + * Only transport/prompt timeouts need a local fallback. */ + if (report.unlock_status < 0 && report.verify_status < 0) { + /* A prompt/result timeout can leave `security` owning the + * foreground PTY. Abort it before handing control to the + * user so keyboard input reaches the normal shell. */ + (void)startup_write_all(ssh, "\x03\n", 2, 2000); + char line[320]; + snprintf(line, sizeof(line), + "\x1b[33mkeychain bootstrap failed:\x1b[0m %s\r\n", + err); + terminal_write(term, line); + } + } + } } + /* Authentication is complete and there is no reconnect path, so these + * credentials no longer need to remain in the process stack. */ + clear_secret(cfg.passphrase, sizeof(cfg.passphrase)); + clear_secret(cfg.macos_keychain_password, + sizeof(cfg.macos_keychain_password)); + idle_loop: { char rbuf[READ_BUFSZ]; @@ -274,7 +574,7 @@ int main(int argc, char *argv[]) { time_t last_rx_at = time(NULL); g_last_tx_at = last_rx_at; int stall_alert = 0; - int ssh_dead = 0; + int ssh_dead = ssh ? 0 : 1; while (aptMainLoop()) { hidScanInput(); @@ -331,6 +631,10 @@ int main(int argc, char *argv[]) { * UTF-8 reassembler can chop the buffer up. */ softkb_record_recv(kb, rbuf, n); feed_terminal(term, rbuf, n); + /* Interactive shells such as fish query cursor/device + * state and wait for the terminal emulator to reply. + * Return any responses queued by terminal_write_n(). */ + flush_terminal_responses(ssh, term); last_rx_at = time(NULL); } else if (n < 0) { /* Hard disconnect — silent. Mascot raises ✕ via @@ -451,6 +755,9 @@ int main(int argc, char *argv[]) { net_fini(); cleanup: + clear_secret(cfg.passphrase, sizeof(cfg.passphrase)); + clear_secret(cfg.macos_keychain_password, + sizeof(cfg.macos_keychain_password)); if (aim) ai_modal_free(aim); if (voice) voice_free(voice); if (ime) ime_free(ime); diff --git a/source/ssh_client.c b/source/ssh_client.c index 3897eb4..ac40024 100644 --- a/source/ssh_client.c +++ b/source/ssh_client.c @@ -73,6 +73,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, const char *key_path, const char *pubkey_path, const char *passphrase, + int pty_cols, int pty_rows, char *err_buf, int err_sz) { if (libssh2_init(0) != 0) { copy_err(err_buf, err_sz, "libssh2_init failed"); @@ -217,7 +218,11 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, } libssh2_channel_setenv(channel, "COLORTERM", "truecolor"); - int pty_rc = libssh2_channel_request_pty(channel, "xterm-256color"); + if (pty_cols <= 0) pty_cols = 80; + if (pty_rows <= 0) pty_rows = 24; + int pty_rc = libssh2_channel_request_pty_ex( + channel, "xterm-256color", sizeof("xterm-256color") - 1, + NULL, 0, pty_cols, pty_rows, 0, 0); if (pty_rc != 0) { copy_libssh2_err(err_buf, err_sz, session, "pty", pty_rc); libssh2_channel_close(channel); diff --git a/source/ssh_client.h b/source/ssh_client.h index 4a8630f..56cf3a7 100644 --- a/source/ssh_client.h +++ b/source/ssh_client.h @@ -13,6 +13,7 @@ typedef struct ssh_client_t ssh_client_t; * key_path — path to PEM-format RSA private key (e.g. "sdmc:/3ds/3dssh/id_rsa"). * pubkey_path — public key path or NULL to let libssh2 derive from private. * passphrase — passphrase for encrypted key, or NULL for unencrypted keys. + * pty_cols/rows — initial PTY size, set before the remote shell starts. * err_buf, err_sz — optional human-readable error captured on failure. */ ssh_client_t *ssh_connect_pubkey(const char *host, int port, @@ -20,6 +21,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, const char *key_path, const char *pubkey_path, const char *passphrase, + int pty_cols, int pty_rows, char *err_buf, int err_sz); void ssh_disconnect(ssh_client_t *ssh); diff --git a/source/terminal.c b/source/terminal.c index 7a82bc5..616174a 100644 --- a/source/terminal.c +++ b/source/terminal.c @@ -105,6 +105,16 @@ static void put_char(terminal_t *t, uint32_t cp) { } } +static void queue_response(terminal_t *t, const char *s) { + int n = (int)strlen(s); + int room = TERM_RESPONSE_MAX - t->response_len; + if (n > room) n = room; + if (n > 0) { + memcpy(t->response_buf + t->response_len, s, (size_t)n); + t->response_len += n; + } +} + /* ── 256色変換 ── */ static uint32_t ansi_256_to_rgba(int idx) { static const uint32_t ansi16[16] = { @@ -181,8 +191,10 @@ static void handle_csi(terminal_t *t, char final, const char *param_str) { int params[32] = {0}; int nparams = 0; const char *p = param_str; - /* skip leading '?' '>' '!' */ - int priv = (*p == '?' || *p == '>' || *p == '!'); + /* CSI prefixes/intermediate bytes used by DEC/xterm/Kitty protocols. */ + char priv_prefix = (*p == '?' || *p == '>' || *p == '!' || + *p == '=' || *p == '<') ? *p : 0; + int priv = priv_prefix != 0; if (priv) p++; if (*p) { char tmp[256]; @@ -287,7 +299,10 @@ static void handle_csi(terminal_t *t, char final, const char *param_str) { break; } /* ── 属性 ── */ - case 'm': handle_sgr(t, params, nparams); break; + case 'm': + /* `CSI > 4 ; Ps m` is xterm modifyOtherKeys, not SGR. */ + if (!priv) handle_sgr(t, params, nparams); + break; /* ── スクロール領域 ── */ case 'r': t->scroll_top = (params[0]?params[0]:1)-1; @@ -300,12 +315,41 @@ static void handle_csi(terminal_t *t, char final, const char *param_str) { case 'T': for(int i=0;i<(p1?p1:1);i++) scroll_down_one(t); break; /* ── カーソル保存/復元 ── */ case 's': - t->saved_x=t->cur_x; t->saved_y=t->cur_y; - t->saved_fg=t->cur_fg; t->saved_bg=t->cur_bg; t->saved_flags=t->cur_flags; + if (!priv && !*param_str) { + t->saved_x=t->cur_x; t->saved_y=t->cur_y; + t->saved_fg=t->cur_fg; t->saved_bg=t->cur_bg; t->saved_flags=t->cur_flags; + } break; case 'u': - t->cur_x=t->saved_x; t->cur_y=t->saved_y; - t->cur_fg=t->saved_fg; t->cur_bg=t->saved_bg; t->cur_flags=t->saved_flags; + /* Fish enables Kitty's keyboard protocol with `CSI = 5 u`. + * Only a parameterless CSI u is the ANSI/SCO restore-cursor + * command. Treating Kitty's sequence as restore moved fish's + * input line to the default saved position at row 1. */ + if (!priv && !*param_str) { + t->cur_x=t->saved_x; t->cur_y=t->saved_y; + t->cur_fg=t->saved_fg; t->cur_bg=t->saved_bg; t->cur_flags=t->saved_flags; + } + break; + /* ── 终端查询回复 ── + * fish's interactive line editor asks for the cursor position with + * CSI 6n. Ignoring it makes fish assume row 1 and repaint every input + * line at the top of the screen. */ + case 'n': + if (p1 == 5 && !priv) { + queue_response(t, "\x1b[0n"); + } else if (p1 == 6) { + char reply[32]; + snprintf(reply, sizeof(reply), + priv_prefix == '?' ? "\x1b[?%d;%dR" : "\x1b[%d;%dR", + t->cur_y + 1, t->cur_x + 1); + queue_response(t, reply); + } + break; + case 'c': + if (priv_prefix == '>') + queue_response(t, "\x1b[>0;0;0c"); /* secondary DA */ + else + queue_response(t, "\x1b[?1;2c"); /* VT100 + advanced video */ break; /* ── DEC private モード ── */ case 'h': case 'l': { @@ -524,3 +568,14 @@ term_cell_t terminal_get_cell(terminal_t *t, int x, int y) { } return t->cells[y*t->cols+x]; } + +int terminal_take_response(terminal_t *t, char *buf, int len) { + if (!t || !buf || len <= 0 || t->response_len <= 0) return 0; + int n = t->response_len < len ? t->response_len : len; + memcpy(buf, t->response_buf, (size_t)n); + t->response_len -= n; + if (t->response_len > 0) + memmove(t->response_buf, t->response_buf + n, + (size_t)t->response_len); + return n; +} diff --git a/source/terminal.h b/source/terminal.h index 572fc39..d72da3c 100644 --- a/source/terminal.h +++ b/source/terminal.h @@ -10,6 +10,7 @@ * something long like `cat largefile`; for those cases 500 rows is * plenty. At 80 cols × 12 bytes/cell that's ~480 KB. */ #define TERM_SCROLLBACK 500 +#define TERM_RESPONSE_MAX 128 typedef struct { uint32_t codepoint; @@ -65,6 +66,12 @@ typedef struct terminal_t { * support transparent. */ int mouse_proto; /* 0 = off; otherwise 1000/1002/1003 (last set wins) */ int mouse_sgr; /* ESC[?1006h: encode as SGR (\x1b[<...M) */ + + /* Replies requested by the remote terminal application (for example + * CSI 6n cursor-position reports used by fish's line editor). The main + * loop drains this buffer back into the SSH channel after parsing input. */ + char response_buf[TERM_RESPONSE_MAX]; + int response_len; } terminal_t; /* Convenience: true when the server has enabled any mouse tracking mode. */ @@ -79,3 +86,6 @@ void terminal_write_n(terminal_t *term, const char *data, int len); void terminal_reset(terminal_t *term); void terminal_scroll_view(terminal_t *term, int delta); term_cell_t terminal_get_cell(terminal_t *term, int x, int y); + +/* Copy and consume queued terminal-protocol replies. Returns bytes copied. */ +int terminal_take_response(terminal_t *term, char *buf, int len); diff --git a/tools/test_config.c b/tools/test_config.c new file mode 100644 index 0000000..9ea00e7 --- /dev/null +++ b/tools/test_config.c @@ -0,0 +1,75 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include "../source/config.h" + +static int failures = 0; + +#define CHECK(condition, message) do { \ + if (!(condition)) { \ + fprintf(stderr, "FAIL: %s\n", message); \ + failures++; \ + } \ +} while (0) + +int main(void) { + ssh_config_t cfg; + CHECK(config_load(&cfg, "/definitely/not/a/dssh/config") == 0, + "missing config should use defaults"); + CHECK(cfg.macos_keychain_password[0] == 0, + "keychain password should default empty"); + + char path[] = "/tmp/dssh-config-test-XXXXXX"; + int fd = mkstemp(path); + CHECK(fd >= 0, "mkstemp failed"); + if (fd < 0) return 1; + + FILE *fp = fdopen(fd, "w"); + CHECK(fp != NULL, "fdopen failed"); + if (!fp) { + close(fd); + unlink(path); + return 1; + } + fputs("host = mac.example # comment\n" + "user = alice\n" + "macos_keychain_password = \" p#ass\\\"word \" # comment\n", + fp); + fclose(fp); + + CHECK(config_load(&cfg, path) == 1, "temporary config should load"); + unlink(path); + CHECK(strcmp(cfg.host, "mac.example") == 0, "inline comment parsing"); + CHECK(strcmp(cfg.macos_keychain_password, " p#ass\"word ") == 0, + "quoted password should preserve spaces, #, and escaped quote"); + + fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0600); + CHECK(fd >= 0, "legacy-key config create failed"); + if (fd >= 0) { + fp = fdopen(fd, "w"); + CHECK(fp != NULL, "legacy-key fdopen failed"); + if (fp) { + fputs("keychain_password = should-not-load\n", fp); + fclose(fp); + CHECK(config_load(&cfg, path) == 1, + "legacy-key config should still be readable"); + CHECK(cfg.macos_keychain_password[0] == 0, + "unreleased legacy key should not be accepted"); + } else { + close(fd); + } + unlink(path); + } + + if (failures) { + fprintf(stderr, "%d config test(s) failed\n", failures); + return 1; + } + puts("config tests passed"); + return 0; +} diff --git a/tools/test_config.sh b/tools/test_config.sh new file mode 100755 index 0000000..5c59bc9 --- /dev/null +++ b/tools/test_config.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" +mkdir -p build +gcc -O2 -Wall -Wextra -std=c11 \ + -o build/test_config \ + tools/test_config.c source/config.c +./build/test_config diff --git a/tools/test_terminal.c b/tools/test_terminal.c new file mode 100644 index 0000000..f980533 --- /dev/null +++ b/tools/test_terminal.c @@ -0,0 +1,110 @@ +#include +#include +#include + +#include "../source/keychain_protocol.h" +#include "../source/terminal.h" + +/* terminal.c only needs this one font-atlas query. Protocol tests use ASCII. */ +int font_is_wide(uint32_t codepoint) { + (void)codepoint; + return 0; +} + +static int failures = 0; + +#define CHECK(condition, message) do { \ + if (!(condition)) { \ + fprintf(stderr, "FAIL: %s\n", message); \ + failures++; \ + } \ +} while (0) + +static char cell_char(terminal_t *term, int x, int y) { + return (char)terminal_get_cell(term, x, y).codepoint; +} + +int main(void) { + terminal_t *term = terminal_init(4, 3); + CHECK(term != NULL, "terminal_init failed"); + if (!term) return 1; + + /* A fourth line must scroll the active screen, leaving input on the + * physical bottom row instead of repainting at row 1. */ + terminal_write(term, "1\r\n2\r\n3\r\n4"); + CHECK(cell_char(term, 0, 0) == '2', "scroll row 1 should contain 2"); + CHECK(cell_char(term, 0, 1) == '3', "scroll row 2 should contain 3"); + CHECK(cell_char(term, 0, 2) == '4', "scroll row 3 should contain 4"); + CHECK(term->cur_y == 2 && term->cur_x == 1, + "cursor should remain after 4 on the bottom row"); + + /* fish uses CPR (CSI 6n) to decide where to redraw its input line. */ + terminal_write(term, "\x1b[6n"); + char reply[128] = {0}; + int n = terminal_take_response(term, reply, sizeof(reply)); + CHECK(n == 6 && memcmp(reply, "\x1b[3;2R", 6) == 0, + "CSI 6n should report row 3, column 2"); + CHECK(terminal_take_response(term, reply, sizeof(reply)) == 0, + "terminal response should be consumed"); + + /* Local startup banners are discarded before the remote PTY begins; CPR + * must then use the remote screen's clean origin, not the local banner's + * former cursor row. */ + terminal_reset(term); + terminal_write(term, "\x1b[6n"); + memset(reply, 0, sizeof(reply)); + n = terminal_take_response(term, reply, sizeof(reply)); + CHECK(n == 6 && memcmp(reply, "\x1b[1;1R", 6) == 0, + "terminal reset should restore remote CPR origin"); + + terminal_write(term, "\x1b[5n\x1b[c\x1b[>c"); + memset(reply, 0, sizeof(reply)); + n = terminal_take_response(term, reply, sizeof(reply)); + CHECK(n == 20 && + memcmp(reply, "\x1b[0n\x1b[?1;2c\x1b[>0;0;0c", 20) == 0, + "status and device-attribute replies should be queued in order"); + + /* Fish 4 enables xterm modifyOtherKeys and Kitty keyboard enhancement at + * every prompt. CSI = 5 u is not CSI u (restore cursor): confusing the + * two is what made fish repaint all typed input on terminal row 1. */ + terminal_write(term, "prompt\r\n> "); + int fish_x = term->cur_x; + int fish_y = term->cur_y; + uint8_t fish_flags = term->cur_flags; + terminal_write(term, "\x1b[>4;1m\x1b[=5u"); + CHECK(term->cur_x == fish_x && term->cur_y == fish_y, + "fish keyboard protocol must not restore cursor to row 1"); + CHECK(term->cur_flags == fish_flags, + "xterm modifyOtherKeys must not be parsed as SGR"); + + /* Startup synchronization markers travel inside OSC so the raw SSH + * bootstrap can detect them without leaking READY text onto the screen. */ + terminal_write(term, "\x1b]777;DSSH_READY_SHELL\x07"); + CHECK(term->cur_x == fish_x && term->cur_y == fish_y, + "OSC readiness marker must not move the cursor"); + CHECK(cell_char(term, fish_x, fish_y) == ' ', + "OSC readiness marker must not render visible text"); + + /* An interactive shell echoes the printable command before security asks + * for a password. Only the emitted ESC-prefixed OSC is a result marker. */ + const char echoed_result[] = + "printf \"\\033]777;DSSH_KEYCHAIN_RESULT unlock=%d verify=%d\""; + const char emitted_result[] = + DSSH_KEYCHAIN_RESULT_MARKER " unlock=0 verify=0\x07"; + CHECK(strstr(echoed_result, DSSH_KEYCHAIN_RESULT_MARKER) == NULL, + "echoed keychain command must not look like a result marker"); + CHECK(strstr(emitted_result, DSSH_KEYCHAIN_RESULT_MARKER) == emitted_result, + "emitted keychain OSC must match the result marker"); + + terminal_write(term, "\x1b[s\x1b[1;1H\x1b[u"); + CHECK(term->cur_x == fish_x && term->cur_y == fish_y, + "parameterless CSI s/u should still save and restore cursor"); + + terminal_free(term); + if (failures) { + fprintf(stderr, "%d terminal test(s) failed\n", failures); + return 1; + } + puts("terminal tests passed"); + return 0; +} diff --git a/tools/test_terminal.sh b/tools/test_terminal.sh new file mode 100755 index 0000000..b370da0 --- /dev/null +++ b/tools/test_terminal.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" +mkdir -p build +gcc -O2 -Wall -Wextra -std=c11 \ + -o build/test_terminal \ + tools/test_terminal.c source/terminal.c +./build/test_terminal