diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fae960b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libts3ds"] + path = libts3ds + url = https://github.com/cadl/libts3ds.git diff --git a/Makefile b/Makefile index 2271adc..de1bc36 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ $(error "Please set DEVKITARM. Run via: tools/dkp.sh make") endif TOPDIR ?= $(CURDIR) +PROJECT_ROOT := $(patsubst %/,%,$(dir $(abspath $(firstword $(MAKEFILE_LIST))))) include $(DEVKITARM)/3ds_rules #--------------------------------------------------------------------------------- @@ -20,6 +21,8 @@ SOURCES := source DATA := data INCLUDES := include ROMFS := romfs +LIBTS3DS_DIR := $(PROJECT_ROOT)/libts3ds +LIBTS3DS_A := $(LIBTS3DS_DIR)/build/3ds/libts3ds.a APP_TITLE := 3DS SSH Client APP_DESCRIPTION := SSH terminal with Chinese IME @@ -34,13 +37,24 @@ CFLAGS := -g -Wall -O2 -mword-relocations \ CFLAGS += $(INCLUDE) -D__3DS__ +# Diagnostic transport builds set this to auto, direct, peer-relay, or derp. +# It is compiled into the .3dsx and intentionally cannot be overridden by the +# SD-card configuration file. +DSSH_TAILSCALE_PATH ?= auto +CFLAGS += -DDSSH_TAILSCALE_PATH=\"$(DSSH_TAILSCALE_PATH)\" + +# Keep libts3ds/microlink diagnostics out of the user's terminal by default. +# Set to 1 for a troubleshooting build that shows startup transport logs. +DSSH_TAILSCALE_VERBOSE ?= 0 +CFLAGS += -DDSSH_TAILSCALE_VERBOSE=$(DSSH_TAILSCALE_VERBOSE) + CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions -std=gnu++11 ASFLAGS := -g $(ARCH) LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) # M3: citro2d/citro3d for top-screen ANSI terminal rendering. -LIBS := -lssh2 \ +LIBS := -lts3ds -lssh2 \ -lmbedtls -lmbedx509 -lmbedcrypto \ -lcitro2d -lcitro3d \ -lctru -lm @@ -77,9 +91,11 @@ export HFILES := $(addsuffix .h,$(BINFILES)) export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(LIBTS3DS_DIR)/include \ -I$(CURDIR)/$(BUILD) -export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) +export LIBPATHS := -L$(LIBTS3DS_DIR)/build/3ds \ + $(foreach dir,$(LIBDIRS),-L$(dir)/lib) export _3DSXDEPS := $(if $(NO_SMDH),,$(OUTPUT).smdh) @@ -185,6 +201,12 @@ cia-clean: @rm -rf $(CIA_ASSETS) icon.png $(BUILD)/dssh.smdh $(BUILD)/dssh.bnr $(CIA_TARGET) $(BUILD): + @test -f "$(LIBTS3DS_DIR)/Makefile.3ds" || { \ + echo "error: libts3ds submodule is missing" >&2; \ + echo "run: git submodule update --init --recursive" >&2; \ + exit 1; \ + } + @$(MAKE) --no-print-directory -C $(LIBTS3DS_DIR) -f Makefile.3ds all @mkdir -p $@ @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile @@ -201,6 +223,16 @@ $(OUTPUT).3dsx : $(OUTPUT).elf $(_3DSXDEPS) $(OFILES_SOURCES) : $(HFILES) $(OUTPUT).elf : $(OFILES) +$(OUTPUT).elf: $(LIBTS3DS_A) + +$(LIBTS3DS_A): + @test -f "$(LIBTS3DS_DIR)/Makefile.3ds" || { \ + echo "error: libts3ds submodule is missing" >&2; \ + echo "run: git submodule update --init --recursive" >&2; \ + exit 1; \ + } + @$(MAKE) --no-print-directory -C $(LIBTS3DS_DIR) -f Makefile.3ds all + %.bin.o %_bin.h : %.bin @echo $(notdir $<) @$(bin2o) diff --git a/README.md b/README.md index d8212c2..4c73eac 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ up questions, **B** to clear and start a new conversation. - **RSA-4096 public-key auth** — libssh2 + mbedTLS, private key read from the SD card. +- **Native Tailscale transport** — optionally join the 3DS to a tailnet and + carry SSH over direct UDP, Tailscale Peer Relay, or DERP without running Go + or `tailscaled`. - **Full physical-key mapping** — D-pad arrow keys, hold-style modifiers (L = Shift, Y = Ctrl, X = Alt), Circle Pad scrollback / mouse-wheel. - **Anthropic-red crab mascot** — scampers along the bottom row, dodges @@ -186,6 +189,12 @@ passphrase = # Optional: unlock the current SSH user's macOS login keychain macos_keychain_password = + +# Optional: connect SSH through the tailnet +tailscale_auth_key = +tailscale_hostname = dssh-3ds +tailscale_state = sdmc:/3ds/3dssh/tailscale.state +tailscale_control_url = https://controlplane.tailscale.com ``` | Field | Meaning | @@ -196,6 +205,10 @@ macos_keychain_password = | `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 | +| `tailscale_auth_key` | Auth key used for registration; setting it enables Tailscale automatically | +| `tailscale_hostname` | Device name shown in the tailnet | +| `tailscale_state` | Persistent machine/WireGuard/DISCO identity file | +| `tailscale_control_url` | Coordination server; defaults to Tailscale SaaS | When `macos_keychain_password` is set, DSSH waits for the current interactive PTY shell to finish initialization, then runs @@ -222,12 +235,39 @@ macos_keychain_password = "my # password" > dedicated macOS account where practical, keep the SD card physically secure, > and leave this field empty unless you accept this risk. +### Native Tailscale transport + +Tailscale support is provided by +[`cadl/libts3ds`](https://github.com/cadl/libts3ds), an experimental native C +client for Nintendo 3DS derived from MicroLink. It is not the official Go +`tailscale/libtailscale` library and does not run `tailscaled`. DSSH pins the +audited source as the `libts3ds` git submodule at the +[`libts3ds-v0.1.0`](https://github.com/cadl/libts3ds/releases/tag/libts3ds-v0.1.0) +release. The top-level `make` builds the static library automatically. + +When enabled, SSH uses the private libts3ds/lwIP TCP stack and can select +direct UDP, Tailscale Peer Relay, or DERP. The default build uses automatic +path selection. Diagnostic builds can fix the data path at compile time with +`DSSH_TAILSCALE_PATH=direct`, `peer-relay`, or `derp`. + +Tailscale also starts automatically without an auth key when the state file +exists. `tailscale_hostname` defaults to `dssh-3ds`, and `tailscale_state` +defaults to `sdmc:/3ds/3dssh/tailscale.state`. The auth key may remain in the +configuration after registration, although removing it reduces exposure if the +SD card is lost. + +The state file contains private machine, WireGuard, and DISCO identity keys. +Treat both it and the auth key as secrets. This is an unofficial community +integration and is not affiliated with or endorsed by Tailscale Inc. + + Final SD layout: ``` sdmc:/3ds/3dssh/ ├── config.ini -└── id_rsa +├── id_rsa +└── tailscale.state # created after Tailscale registration ``` --- @@ -635,10 +675,13 @@ wget https://apt.devkitpro.org/install-devkitpro-pacman bash install-devkitpro-pacman sudo dkp-pacman -S 3ds-dev 3ds-mbedtls 3ds-libpng 3ds-zlib -# 2. Clone + cd -git clone https://github.com/Fishason/DSSH.git +# 2. Clone recursively so libts3ds and its private lwIP are checked out +git clone --recurse-submodules https://github.com/Fishason/DSSH.git cd DSSH +# Existing non-recursive clone only: +git submodule update --init --recursive + # 3. Cross-compile libssh2 (one-time, drops into $DEVKITPRO/portlibs/3ds/lib/) bash build-libssh2.sh @@ -663,6 +706,11 @@ bash tools/install_cia_tools.sh # installs bannertool + makerom into ~/bin make cia # → DSSH.cia ``` +The build command itself is unchanged: `make` first builds the pinned +`libts3ds/build/3ds/libts3ds.a`, then links DSSH. If the submodule is missing, +the Makefile prints the initialization command instead of failing later with a +missing directory or header. + ### Test the IME engine on the host (no 3DS needed) ```bash @@ -684,6 +732,7 @@ DSSH/ ├── app.rsf # makerom CIA spec ├── Makefile # Top-level build (make / make cia / make test-ime) ├── build-libssh2.sh # libssh2 + mbedTLS ARM cross-compile +├── libts3ds/ # Pinned native Tailscale client submodule ├── source/ │ ├── main.c # Main loop, SSH receive, UTF-8 reassembly │ ├── ssh_client.{c,h} # libssh2 wrapper @@ -755,6 +804,9 @@ the full progression. and box-drawing pixel font. - **[libssh2](https://www.libssh2.org/)** + **[mbedTLS](https://www.trustedfirmware.org/projects/mbed-tls/)** — SSH / TLS protocol stack. +- **[cadl/libts3ds](https://github.com/cadl/libts3ds)** — native Nintendo 3DS + Tailscale-compatible transport, derived from + **[CamM2325/microlink](https://github.com/CamM2325/microlink)**. - **[devkitPro](https://devkitpro.org/) libctru / citro2d / citro3d** — 3DS user-mode runtime and rendering. - **[carstene1ns/3ds-bannertool](https://github.com/carstene1ns/3ds-bannertool)** diff --git a/README.zh.md b/README.zh.md index 265eeeb..ac56288 100644 --- a/README.zh.md +++ b/README.zh.md @@ -58,6 +58,8 @@ SSH 会话完全不受打扰。modal 里按 **A** 关窗保留 history(可追问), 按 **B** 关窗清空 history(新对话)。 - **RSA-4096 公钥认证**:libssh2 + mbedTLS,私钥放 SD 卡读 +- **原生 Tailscale 传输**:可选地让 3DS 加入 tailnet,通过直连 UDP、 + Tailscale Peer Relay 或 DERP 承载 SSH,不运行 Go 或 `tailscaled` - **物理键全映射**:D-pad 方向键、修饰键 hold-style(L=Shift / Y=Ctrl / X=Alt)、Circle Pad scrollback / mouse-wheel - **Anthropic 红螃蟹吉祥物**:底行左右奔跑,点击会躲开 🦀 @@ -169,6 +171,12 @@ passphrase = # 可选:自动解锁当前 SSH 用户的 macOS 登录 keychain macos_keychain_password = + +# 可选:通过 tailnet 连接 SSH +tailscale_auth_key = +tailscale_hostname = dssh-3ds +tailscale_state = sdmc:/3ds/3dssh/tailscale.state +tailscale_control_url = https://controlplane.tailscale.com ``` | 字段 | 说明 | @@ -179,6 +187,10 @@ macos_keychain_password = | `key_path` | 私钥路径,`sdmc:/...` 是 3DS 标准 SD 路径前缀 | | `passphrase` | 私钥口令;建议留空(SD 卡上输 passphrase 体验差) | | `macos_keychain_password` | 可选的 macOS 登录密码;填写后为当前 SSH 用户启用自动解锁 | +| `tailscale_auth_key` | 注册使用的 auth key;填写后自动开启 Tailscale | +| `tailscale_hostname` | tailnet 中显示的 3DS 设备名 | +| `tailscale_state` | 持久化机器、WireGuard 与 DISCO 身份的文件 | +| `tailscale_control_url` | 控制服务器,默认使用 Tailscale SaaS | 填写 `macos_keychain_password` 后,DSSH 会等待当前交互式 PTY shell 初始化完成, 然后执行 @@ -203,12 +215,35 @@ macos_keychain_password = "my # password" > 敏感程度高于一把专用 SSH 密钥。建议尽量使用专门的 macOS 账户,妥善保管 > SD 卡;只有接受这一风险时才填写该字段。 +### 原生 Tailscale 传输 + +Tailscale 支持由 [`cadl/libts3ds`](https://github.com/cadl/libts3ds) +提供。它是从 MicroLink 派生、面向 Nintendo 3DS 的实验性原生 C 客户端, +不是官方 Go `tailscale/libtailscale`,也不会运行 `tailscaled`。DSSH 通过 +`libts3ds` git submodule 固定到经过真机验证的 +[`libts3ds-v0.1.0`](https://github.com/cadl/libts3ds/releases/tag/libts3ds-v0.1.0) +源码;顶层 `make` 会自动先构建静态库。 + +开启后,SSH 使用 libts3ds 内部的私有 lwIP TCP 栈,可走直连 UDP、 +Tailscale Peer Relay 或 DERP。默认构建自动选路;诊断构建可在编译时通过 +`DSSH_TAILSCALE_PATH=direct`、`peer-relay` 或 `derp` 固定数据路径。 + +没有 auth key 时,只要 state 文件存在,Tailscale 也会自动启动。 +`tailscale_hostname` 默认是 `dssh-3ds`,`tailscale_state` 默认是 +`sdmc:/3ds/3dssh/tailscale.state`。注册成功后 auth key 可以继续保留在配置中; +如果担心 SD 卡遗失导致 key 暴露,也可以手动删除。 + +state 文件包含机器、WireGuard 和 DISCO 私钥身份,必须和 auth key 一样按 +敏感信息保护。本集成为非官方社区项目,与 Tailscale Inc. 无隶属或背书关系。 + + 最终 SD 卡布局: ``` sdmc:/3ds/3dssh/ ├── config.ini -└── id_rsa +├── id_rsa +└── tailscale.state # Tailscale 注册后生成 ``` --- @@ -579,10 +614,13 @@ wget https://apt.devkitpro.org/install-devkitpro-pacman bash install-devkitpro-pacman sudo dkp-pacman -S 3ds-dev 3ds-mbedtls 3ds-libpng 3ds-zlib -# 2. clone + 进项目 -git clone https://github.com/Fishason/DSSH.git +# 2. 递归 clone,同时拉取 libts3ds 及其私有 lwIP +git clone --recurse-submodules https://github.com/Fishason/DSSH.git cd DSSH +# 仅已有的非递归 clone 需要执行: +git submodule update --init --recursive + # 3. 交叉编译 libssh2(一次性,输出到 $DEVKITPRO/portlibs/3ds/lib/) bash build-libssh2.sh @@ -607,6 +645,10 @@ bash tools/install_cia_tools.sh # 装 bannertool + makerom 到 ~/bin make cia # 输出 DSSH.cia ``` +构建命令本身没有变化:`make` 会先生成固定版本的 +`libts3ds/build/3ds/libts3ds.a`,再链接 DSSH。若 submodule 未初始化, +Makefile 会直接提示正确命令,而不是稍后报目录或头文件缺失。 + ### 测试 IME 引擎(host 端,不需 3DS) ```bash @@ -627,6 +669,7 @@ DSSH/ ├── app.rsf # makerom CIA spec ├── Makefile # 主构建(make / make cia / make test-ime) ├── build-libssh2.sh # libssh2 + mbedTLS ARM 交叉编译 +├── libts3ds/ # 固定版本的原生 Tailscale 客户端 submodule ├── source/ │ ├── main.c # 主循环、SSH receive、UTF-8 边界 │ ├── ssh_client.{c,h} # libssh2 封装 @@ -696,6 +739,9 @@ SSH server (somewhere on the internet) box-drawing 像素字体 - **[libssh2](https://www.libssh2.org/)** + **[mbedTLS](https://www.trustedfirmware.org/projects/mbed-tls/)** — SSH/TLS 协议栈 +- **[cadl/libts3ds](https://github.com/cadl/libts3ds)** — Nintendo 3DS 原生 + Tailscale 兼容传输,派生自 + **[CamM2325/microlink](https://github.com/CamM2325/microlink)** - **[devkitPro](https://devkitpro.org/) libctru / citro2d / citro3d** — 3DS 用户态运行时 + 渲染 - **[carstene1ns/3ds-bannertool](https://github.com/carstene1ns/3ds-bannertool)** diff --git a/libts3ds b/libts3ds new file mode 160000 index 0000000..dd700ed --- /dev/null +++ b/libts3ds @@ -0,0 +1 @@ +Subproject commit dd700edda8298c13e0bb01f4a112f08fdc268e31 diff --git a/sd_template/3ds/3dssh/config.ini.example b/sd_template/3ds/3dssh/config.ini.example index 8fb74c4..770e6f2 100644 --- a/sd_template/3ds/3dssh/config.ini.example +++ b/sd_template/3ds/3dssh/config.ini.example @@ -36,3 +36,13 @@ passphrase = # 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 = + +# Optional native Tailscale transport. Setting an auth key enables Tailscale. +# Later launches also start automatically when tailscale.state exists, so the +# key may remain configured but removing it reduces exposure on the SD card. +tailscale_auth_key = + +# Optional values below use these defaults when omitted or left empty. +tailscale_hostname = dssh-3ds +tailscale_state = sdmc:/3ds/3dssh/tailscale.state +tailscale_control_url = https://controlplane.tailscale.com diff --git a/source/config.c b/source/config.c index 8bef7bc..1e32e90 100644 --- a/source/config.c +++ b/source/config.c @@ -68,6 +68,11 @@ int config_load(ssh_config_t *cfg, const char *path) { set_str(cfg->key_path, "sdmc:/3ds/3dssh/id_rsa"); cfg->passphrase[0] = 0; cfg->macos_keychain_password[0] = 0; + cfg->tailscale_auth_key[0] = 0; + set_str(cfg->tailscale_hostname, "dssh-3ds"); + set_str(cfg->tailscale_state, "sdmc:/3ds/3dssh/tailscale.state"); + set_str(cfg->tailscale_control_url, + "https://controlplane.tailscale.com"); FILE *fp = fopen(path, "r"); if (!fp) return 0; @@ -92,7 +97,36 @@ int config_load(ssh_config_t *cfg, const char *path) { else if (!strcmp(key, "passphrase")) set_str(cfg->passphrase, val); else if (!strcmp(key, "macos_keychain_password")) set_str(cfg->macos_keychain_password, val); + else if (!strcmp(key, "tailscale_auth_key")) + set_str(cfg->tailscale_auth_key, val); + else if (!strcmp(key, "tailscale_hostname")) + set_str(cfg->tailscale_hostname, val); + else if (!strcmp(key, "tailscale_state")) + set_str(cfg->tailscale_state, val); + else if (!strcmp(key, "tailscale_control_url")) + set_str(cfg->tailscale_control_url, val); } fclose(fp); + + /* An explicitly empty optional value has the same meaning as omitting it. */ + if (!cfg->tailscale_hostname[0]) + set_str(cfg->tailscale_hostname, "dssh-3ds"); + if (!cfg->tailscale_state[0]) + set_str(cfg->tailscale_state, + "sdmc:/3ds/3dssh/tailscale.state"); + if (!cfg->tailscale_control_url[0]) + set_str(cfg->tailscale_control_url, + "https://controlplane.tailscale.com"); + return 1; +} + +int config_tailscale_should_start(const ssh_config_t *cfg) { + if (!cfg) return 0; + if (cfg->tailscale_auth_key[0]) return 1; + if (!cfg->tailscale_state[0]) return 0; + + FILE *state = fopen(cfg->tailscale_state, "rb"); + if (!state) return 0; + fclose(state); return 1; } diff --git a/source/config.h b/source/config.h index c19c444..035fe2a 100644 --- a/source/config.h +++ b/source/config.h @@ -12,6 +12,9 @@ * key_path = sdmc:/3ds/3dssh/id_rsa * passphrase = # leave empty for unencrypted keys * macos_keychain_password = # quote values containing # or edge spaces + * tailscale_auth_key = # setting a key enables Tailscale + * tailscale_hostname = dssh-3ds + * tailscale_state = sdmc:/3ds/3dssh/tailscale.state */ #define CONFIG_STR_MAX 256 @@ -23,10 +26,18 @@ typedef struct { char key_path[CONFIG_STR_MAX]; char passphrase[CONFIG_STR_MAX]; char macos_keychain_password[CONFIG_STR_MAX]; + char tailscale_auth_key[CONFIG_STR_MAX]; + char tailscale_hostname[CONFIG_STR_MAX]; + char tailscale_state[CONFIG_STR_MAX]; + char tailscale_control_url[CONFIG_STR_MAX]; } ssh_config_t; /* Fills cfg with defaults then overlays values from config file (if present). * Returns 1 if the file was loaded successfully, 0 if defaults only. */ int config_load(ssh_config_t *cfg, const char *path); +/* Tailscale starts automatically when an auth key is configured or when a + * persisted node state exists at tailscale_state. */ +int config_tailscale_should_start(const ssh_config_t *cfg); + #endif /* CONFIG_H */ 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 e008203..180bf8b 100644 --- a/source/main.c +++ b/source/main.c @@ -24,14 +24,23 @@ #include #include <3ds.h> #include +#include #include "ssh_client.h" #include "config.h" +#include "keychain_protocol.h" #include "terminal.h" #include "renderer.h" #include "keyboard.h" #include "softkb.h" #include "mascot.h" + +#ifndef DSSH_TAILSCALE_PATH +#define DSSH_TAILSCALE_PATH "auto" +#endif +#ifndef DSSH_TAILSCALE_VERBOSE +#define DSSH_TAILSCALE_VERBOSE 0 +#endif #include "ime_pinyin.h" #include "voice.h" #include "ai_modal.h" @@ -57,6 +66,111 @@ static u32 *soc_buf = NULL; +/* APT hooks may run while the main loop is suspended. Keep the callback + * side minimal and let the next active frame own all SSH/Tailscale work. */ +static volatile int g_sleep_seen = 0; +static volatile int g_wakeup_pending = 0; + +static void apt_event_hook(APT_HookType hook, void *param) { + (void)param; + if (hook == APTHOOK_ONSLEEP) { + g_sleep_seen = 1; + } else if (hook == APTHOOK_ONWAKEUP && g_sleep_seen) { + g_sleep_seen = 0; + g_wakeup_pending = 1; + } +} + +#define TS_DEBUG_LINE_COUNT 24 +#define TS_DEBUG_LINE_SIZE 224 + +typedef struct tailscale_debug_log { + LightLock lock; + char lines[TS_DEBUG_LINE_COUNT][TS_DEBUG_LINE_SIZE]; + unsigned char levels[TS_DEBUG_LINE_COUNT]; + unsigned head; + unsigned count; + unsigned dropped; + int startup_verbose; +} tailscale_debug_log; + +static void tailscale_debug_init(tailscale_debug_log *debug) { + memset(debug, 0, sizeof(*debug)); + LightLock_Init(&debug->lock); + debug->startup_verbose = DSSH_TAILSCALE_VERBOSE ? 1 : 0; +} + +/* libts3ds may log from its DERP worker. Keep that callback independent of + * terminal rendering, then drain it from DSSH's main thread. */ +static void tailscale_debug_capture(void *userdata, int level, + const char *message) { + tailscale_debug_log *debug = (tailscale_debug_log *)userdata; + if (!debug || !message) return; + LightLock_Lock(&debug->lock); + /* Keep rich diagnostics while Tailscale and SSH are connecting. Once an + * interactive shell exists, control/DERP maintenance is independent of + * the established WireGuard data path and must never corrupt the user's + * command line. SSH failure still has its own visible connection state. */ + if (level >= 3 || !debug->startup_verbose) { + LightLock_Unlock(&debug->lock); + return; + } + if (debug->count == TS_DEBUG_LINE_COUNT) { + debug->head = (debug->head + 1) % TS_DEBUG_LINE_COUNT; + debug->count--; + debug->dropped++; + } + unsigned slot = (debug->head + debug->count) % TS_DEBUG_LINE_COUNT; + snprintf(debug->lines[slot], TS_DEBUG_LINE_SIZE, "%s", message); + debug->levels[slot] = (unsigned char)level; + debug->count++; + LightLock_Unlock(&debug->lock); +} + +static void tailscale_debug_set_runtime(tailscale_debug_log *debug) { + if (!debug) return; + LightLock_Lock(&debug->lock); + debug->startup_verbose = 0; + LightLock_Unlock(&debug->lock); +} + +static void tailscale_debug_flush(tailscale_debug_log *debug, + terminal_t *term) { + if (!debug || !term) return; + for (;;) { + char message[TS_DEBUG_LINE_SIZE]; + unsigned level; + unsigned dropped = 0; + LightLock_Lock(&debug->lock); + if (debug->count == 0) { + dropped = debug->dropped; + debug->dropped = 0; + LightLock_Unlock(&debug->lock); + if (dropped) { + char line[96]; + snprintf(line, sizeof(line), + "\x1b[33m[ts3ds] %u earlier log lines dropped" + "\x1b[0m\r\n", dropped); + terminal_write(term, line); + } + return; + } + unsigned slot = debug->head; + snprintf(message, sizeof(message), "%s", debug->lines[slot]); + level = debug->levels[slot]; + debug->head = (debug->head + 1) % TS_DEBUG_LINE_COUNT; + debug->count--; + LightLock_Unlock(&debug->lock); + + char line[TS_DEBUG_LINE_SIZE + 40]; + const char *color = level == 0 ? "\x1b[31m" + : level == 1 ? "\x1b[33m" : "\x1b[90m"; + snprintf(line, sizeof(line), "%s[ts3ds] %s\x1b[0m\r\n", + color, message); + terminal_write(term, line); + } +} + static int net_init(char *err, int err_sz) { soc_buf = (u32 *)memalign(SOC_ALIGN, SOC_BUFFERSIZE); if (!soc_buf) { snprintf(err, err_sz, "memalign failed"); return -1; } @@ -128,6 +242,7 @@ static int startup_write_all(ssh_client_t *ssh, const char *data, int len, int sent = 0; u64 deadline = osGetTime() + (u64)timeout_ms; while (sent < len && osGetTime() < deadline) { + ssh_poll_transport(ssh); int n = ssh_write(ssh, data + sent, len - sent); if (n < 0) return -1; if (n == 0) { @@ -172,6 +287,10 @@ static int wait_for_remote_text_any(ssh_client_t *ssh, terminal_t *term, u64 deadline = osGetTime() + (u64)timeout_ms; while (osGetTime() < deadline) { + /* A Tailscale-backed SSH socket lives in libts3ds's private lwIP + * stack. It cannot progress while this synchronous bootstrap loop + * sleeps unless its transport is explicitly pumped. */ + ssh_poll_transport(ssh); int n = ssh_read(ssh, raw, sizeof(raw)); if (n < 0) return -1; if (n == 0) { @@ -252,7 +371,7 @@ static int unlock_macos_keychain(ssh_client_t *ssh, terminal_t *term, "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"; + static const char result_marker[] = DSSH_KEYCHAIN_RESULT_MARKER; char capture[768]; report->unlock_status = -1; @@ -347,6 +466,21 @@ static void clear_secret(char *s, size_t len) { while (len-- > 0) *p++ = 0; } +static void render_connecting_frame(C3D_RenderTarget *top, + C3D_RenderTarget *bot, + renderer_t *renderer, terminal_t *term, + softkb_t *keyboard, + keyboard_t *physical_keyboard) { + C3D_FrameBegin(C3D_FRAME_SYNCDRAW); + C2D_TargetClear(top, C2D_Color32(0x1a, 0x1b, 0x26, 0xff)); + C2D_SceneBegin(top); + renderer_draw_terminal(renderer, term); + C2D_TargetClear(bot, C2D_Color32(0x18, 0x18, 0x25, 0xff)); + C2D_SceneBegin(bot); + softkb_draw(keyboard, renderer, physical_keyboard); + C3D_FrameEnd(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. */ @@ -365,10 +499,16 @@ static void send_to_ssh(ssh_client_t *ssh, terminal_t *term, * Used both for the initial connect at startup and for the SELECT-key * reconnect after a hard disconnect (lid-close sleep kills the TCP). * On success: returns a new ssh_client_t, resets/sizes the PTY, performs the - * one-time keychain bootstrap when a password is still present, and writes a + * keychain bootstrap when a password is configured, and writes a * status string. On failure: returns NULL and writes the SSH diagnostic. */ static ssh_client_t *reconnect_ssh(const ssh_config_t *cfg, + ts3ds *tailscale, terminal_t *term, + C3D_RenderTarget *top, + C3D_RenderTarget *bot, + renderer_t *renderer, + softkb_t *soft_keyboard, + keyboard_t *physical_keyboard, char *status_buf, int status_sz, char *err, int err_sz) { ssh_client_t *ssh = ssh_connect_pubkey( @@ -376,6 +516,7 @@ static ssh_client_t *reconnect_ssh(const ssh_config_t *cfg, cfg->key_path, NULL, cfg->passphrase[0] ? cfg->passphrase : NULL, R_TOP_COLS, R_TOP_ROWS, + tailscale, err, err_sz); if (!ssh) { @@ -394,6 +535,15 @@ static ssh_client_t *reconnect_ssh(const ssh_config_t *cfg, cfg->host, cfg->port); if (cfg->macos_keychain_password[0]) { + /* Render the status before the synchronous bootstrap blocks. Reset + * again afterwards so this local-only line cannot affect fish's CPR + * replies or the remote PTY coordinate system. */ + terminal_write(term, + "\x1b[36mUnlocking macOS keychain...\x1b[0m\r\n"); + render_connecting_frame(top, bot, renderer, term, + soft_keyboard, physical_keyboard); + terminal_reset(term); + keychain_report_t report = { -1, -1 }; int unlock_rc = unlock_macos_keychain( ssh, term, cfg->macos_keychain_password, &report, err, err_sz); @@ -434,12 +584,17 @@ int main(int argc, char *argv[]) { char status_buf[80] = "starting..."; uint32_t status_color = COLOR_WARN; ssh_client_t *ssh = NULL; + ts3ds *tailscale = NULL; + static tailscale_debug_log tailscale_debug; + tailscale_debug_init(&tailscale_debug); /* ── Graphics init (audio disabled — see audio.{c,h} kept for future) ── */ gfxInitDefault(); C3D_Init(C3D_DEFAULT_CMDBUF_SIZE); C2D_Init(32768); C2D_Prepare(); + aptHookCookie apt_hook_cookie; + aptHook(&apt_hook_cookie, apt_event_hook, NULL); C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT); C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT); @@ -498,19 +653,85 @@ int main(int argc, char *argv[]) { terminal_write(term, "\x1b[33mromfs init failed — IME unavailable\x1b[0m\r\n"); } + if (config_tailscale_should_start(&cfg)) { + ts3ds_config tailscale_config; + ts3ds_config_init(&tailscale_config); + tailscale_config.auth_key = cfg.tailscale_auth_key[0] + ? cfg.tailscale_auth_key : NULL; + tailscale_config.hostname = cfg.tailscale_hostname; + tailscale_config.state_path = cfg.tailscale_state; + tailscale_config.control_url = cfg.tailscale_control_url; + if (ts3ds_path_policy_parse(DSSH_TAILSCALE_PATH, + &tailscale_config.path_policy) != + TS3DS_OK) { + char line[192]; + snprintf(line, sizeof(line), + "\x1b[31mTailscale build error:\x1b[0m invalid " + "path policy='%.64s' (use auto, direct, " + "peer-relay, or derp)\r\n", + DSSH_TAILSCALE_PATH); + terminal_write(term, line); + snprintf(status_buf, sizeof(status_buf), "tailscale build err"); + status_color = COLOR_ERR; + goto idle_loop; + } + if (DSSH_TAILSCALE_VERBOSE) { + tailscale_config.log = tailscale_debug_capture; + tailscale_config.log_userdata = &tailscale_debug; + } + terminal_write(term, + "\x1b[36mConnecting to Tailscale...\x1b[0m\r\n"); + if (DSSH_TAILSCALE_VERBOSE) { + char line[256]; + snprintf(line, sizeof(line), + " node=%.63s auth=%s path=%.16s state=%.96s" + "\r\n", + cfg.tailscale_hostname, + cfg.tailscale_auth_key[0] ? "yes" : "no", + ts3ds_path_policy_name(tailscale_config.path_policy), + cfg.tailscale_state); + terminal_write(term, line); + } + render_connecting_frame(top, bot, r, term, kb, kbd); + tailscale = ts3ds_new(&tailscale_config); + int tailscale_result = tailscale ? ts3ds_up(tailscale) + : TS3DS_ERR_ARGUMENT; + memset(cfg.tailscale_auth_key, 0, + sizeof(cfg.tailscale_auth_key)); + tailscale_debug_flush(&tailscale_debug, term); + if (!tailscale || tailscale_result != TS3DS_OK) { + const char *reason = tailscale + ? ts3ds_last_error(tailscale) + : "invalid Tailscale configuration"; + char line[256]; + snprintf(line, sizeof(line), + "\x1b[31mTailscale error rc=%d status=%d:\x1b[0m " + "%.170s\r\n", tailscale_result, + tailscale ? (int)ts3ds_get_status(tailscale) : -1, + reason); + terminal_write(term, line); + snprintf(status_buf, sizeof(status_buf), "tailscale err"); + status_color = COLOR_ERR; + tailscale_debug_set_runtime(&tailscale_debug); + goto idle_loop; + } + { + uint32_t ip = ts3ds_get_ipv4(tailscale); + char line[96]; + snprintf(line, sizeof(line), + "\x1b[32mTailscale online:\x1b[0m %u.%u.%u.%u\r\n", + (unsigned)((ip >> 24) & 0xff), + (unsigned)((ip >> 16) & 0xff), + (unsigned)((ip >> 8) & 0xff), + (unsigned)(ip & 0xff)); + terminal_write(term, line); + } + } + /* Pump one frame so the user sees the loading banner during the * (synchronous, ~5s) dict read. The bottom screen still has the * keyboard rendered — the badge and mascot work normally. */ - { - 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); - } + render_connecting_frame(top, bot, r, term, kb, kbd); /* Load the pinyin dict (~9 MB). Failure here is non-fatal — we * just leave ime NULL and softkb degrades CN mode to passthrough. */ @@ -535,25 +756,19 @@ int main(int argc, char *argv[]) { /* Pump again so the user sees the loaded/connecting banners before * the SSH handshake blocks the main loop. */ - { - 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); - } + render_connecting_frame(top, bot, r, term, kb, kbd); - ssh = reconnect_ssh(&cfg, term, status_buf, sizeof(status_buf), + ssh = reconnect_ssh(&cfg, tailscale, term, top, bot, r, kb, kbd, + status_buf, sizeof(status_buf), err, sizeof(err)); status_color = ssh ? COLOR_OK : COLOR_ERR; - /* Keychain bootstrap is only needed for the initial session. The SSH key - * passphrase remains until cleanup because SELECT reconnect may need it. */ - clear_secret(cfg.macos_keychain_password, - sizeof(cfg.macos_keychain_password)); + tailscale_debug_set_runtime(&tailscale_debug); + tailscale_debug_flush(&tailscale_debug, term); + + /* A fresh SSH login may see the macOS login keychain as locked even when + * the previous session unlocked it. Keep both credentials until cleanup + * so wake/SELECT reconnects can authenticate and bootstrap keychain again. */ idle_loop: { @@ -576,8 +791,73 @@ int main(int argc, char *argv[]) { g_last_tx_at = last_rx_at; int stall_alert = 0; int ssh_dead = ssh ? 0 : 1; + int wake_reconnect_pending = 0; + int wake_reconnect_attempts = 0; + u64 wake_reconnect_at = 0; while (aptMainLoop()) { + if (g_wakeup_pending) { + g_wakeup_pending = 0; + + /* Wi-Fi sleep often leaves libssh2/lwIP returning EAGAIN + * forever instead of a hard error. The old TCP flow cannot + * be trusted after wake, even if its connected flag is set. */ + if (ssh) { + voice_abort(voice); + ssh_disconnect(ssh); + ssh = NULL; + } + ssh_dead = 1; + stall_alert = 1; + mascot_set_alert(mc, 1); + mascot_set_reconnecting(mc, 1); + terminal_write(term, + "\r\n\x1b[33mWoke from sleep; reconnecting..." + "\x1b[0m\r\n"); + render_connecting_frame(top, bot, r, term, kb, kbd); + + /* Give the 3DS Wi-Fi stack and libts3ds a short window to + * process wakeup before opening a fresh TCP/SSH flow. */ + wake_reconnect_pending = 1; + wake_reconnect_attempts = 0; + wake_reconnect_at = osGetTime() + 1500; + g_last_tx_at = last_rx_at = time(NULL); + } + + if (tailscale && ts3ds_get_status(tailscale) == + TS3DS_STATUS_ONLINE) + ts3ds_poll(tailscale); + tailscale_debug_flush(&tailscale_debug, term); + + if (wake_reconnect_pending && + osGetTime() >= wake_reconnect_at) { + wake_reconnect_attempts++; + ssh = reconnect_ssh(&cfg, tailscale, term, + top, bot, r, kb, kbd, + status_buf, sizeof(status_buf), + err, sizeof(err)); + if (ssh) { + wake_reconnect_pending = 0; + ssh_dead = 0; + stall_alert = 0; + mascot_set_alert(mc, 0); + mascot_set_reconnecting(mc, 0); + mascot_celebrate(mc); + g_last_tx_at = last_rx_at = time(NULL); + } else if (wake_reconnect_attempts < 3) { + terminal_write(term, + "\x1b[33mWake reconnect retrying...\x1b[0m\r\n"); + wake_reconnect_at = osGetTime() + 3000; + } else { + wake_reconnect_pending = 0; + mascot_set_reconnecting(mc, 0); + mascot_sadden(mc); + terminal_write(term, + "\x1b[31mWake reconnect failed; " + "press SELECT to retry.\x1b[0m\r\n"); + } + } + hidScanInput(); u32 down = hidKeysDown(); u32 held = hidKeysHeld(); @@ -670,6 +950,7 @@ int main(int argc, char *argv[]) { * and how to recover (SELECT reconnect). This only * fires on the 0→1 transition of ssh_dead, so it * won't spam the terminal every frame. */ + voice_abort(voice); ssh_disconnect(ssh); ssh = NULL; ssh_dead = 1; @@ -719,6 +1000,7 @@ int main(int argc, char *argv[]) { int select_consumed = 0; if (ssh_dead && !modal_open && (down & KEY_SELECT)) { select_consumed = 1; + wake_reconnect_pending = 0; terminal_write(term, "\x1b[33mReconnecting...\x1b[0m\r\n"); /* Put the crab into its "looking up / waiting" pose before @@ -753,7 +1035,9 @@ int main(int argc, char *argv[]) { if (softkb_mascot_enabled(kb)) mascot_draw(mc); } C3D_FrameEnd(0); - ssh = reconnect_ssh(&cfg, term, status_buf, sizeof(status_buf), + ssh = reconnect_ssh(&cfg, tailscale, term, + top, bot, r, kb, kbd, + status_buf, sizeof(status_buf), err, sizeof(err)); mascot_set_reconnecting(mc, 0); if (ssh) { @@ -867,12 +1151,24 @@ int main(int argc, char *argv[]) { if (ssh) ssh_disconnect(ssh); } + if (tailscale) { + ts3ds_close(tailscale); + tailscale = NULL; + tailscale_debug_flush(&tailscale_debug, term); + } net_fini(); cleanup: + if (tailscale) { + ts3ds_close(tailscale); + tailscale = NULL; + tailscale_debug_flush(&tailscale_debug, term); + } clear_secret(cfg.passphrase, sizeof(cfg.passphrase)); clear_secret(cfg.macos_keychain_password, sizeof(cfg.macos_keychain_password)); + clear_secret(cfg.tailscale_auth_key, + sizeof(cfg.tailscale_auth_key)); if (aim) ai_modal_free(aim); if (voice) voice_free(voice); if (ime) ime_free(ime); @@ -882,6 +1178,7 @@ int main(int argc, char *argv[]) { if (r) renderer_free(r); if (term) terminal_free(term); if (romfs_ok) romfsExit(); + aptUnhook(&apt_hook_cookie); C2D_Fini(); C3D_Fini(); gfxExit(); diff --git a/source/ssh_client.c b/source/ssh_client.c index ac40024..007acb8 100644 --- a/source/ssh_client.c +++ b/source/ssh_client.c @@ -1,4 +1,6 @@ #include "ssh_client.h" +#include <3ds.h> +#include #include #include #include @@ -15,11 +17,60 @@ struct ssh_client_t { int sock; + ts3ds_conn *tailscale_conn; + void *transport; LIBSSH2_SESSION *session; LIBSSH2_CHANNEL *channel; int connected; }; +typedef struct ssh_transport { + ts3ds *tailscale; + ts3ds_conn *connection; + int blocking; +} ssh_transport; + +/* libssh2's blocking flag alone is insufficient for callback transports: + * its socket wait path cannot advance libts3ds. Keep both layers in the same + * mode so the callbacks poll Tailscale while synchronous SSH operations wait. */ +static void ssh_set_io_blocking(ssh_client_t *ssh, int blocking) { + if (!ssh || !ssh->session) return; + ssh_transport *transport = (ssh_transport *)ssh->transport; + if (transport) transport->blocking = blocking ? 1 : 0; + libssh2_session_set_blocking(ssh->session, blocking ? 1 : 0); +} + +static ssize_t tailscale_send_cb(libssh2_socket_t socket, + const void *buffer, size_t length, int flags, + void **abstract) { + ssh_transport *transport = abstract ? (ssh_transport *)*abstract : NULL; + (void)socket; (void)flags; + if (!transport || !transport->connection) return -1; + for (;;) { + ssize_t result = ts3ds_conn_write(transport->connection, buffer, length); + if (result != TS3DS_ERR_AGAIN || !transport->blocking) { + return result == TS3DS_ERR_AGAIN ? -EAGAIN : result; + } + ts3ds_poll(transport->tailscale); + svcSleepThread(1000000); + } +} + +static ssize_t tailscale_recv_cb(libssh2_socket_t socket, void *buffer, + size_t length, int flags, void **abstract) { + ssh_transport *transport = abstract ? (ssh_transport *)*abstract : NULL; + (void)socket; (void)flags; + if (!transport || !transport->connection) return -1; + for (;;) { + ssize_t result = ts3ds_conn_read(transport->connection, buffer, length); + if (result != TS3DS_ERR_AGAIN || !transport->blocking) { + return result == TS3DS_ERR_AGAIN ? -EAGAIN : result; + } + ts3ds_poll(transport->tailscale); + svcSleepThread(1000000); + } +} + static void copy_err(char *dst, int dst_sz, const char *src) { if (dst && dst_sz > 0) { snprintf(dst, dst_sz, "%s", src ? src : "(unknown)"); @@ -68,38 +119,77 @@ static int tcp_connect(const char *host, int port, char *err, int err_sz) { return sock; } +static void close_pending_transport(int sock, ts3ds_conn *connection, + ssh_transport *transport) { + if (sock >= 0) closesocket(sock); + if (connection) ts3ds_conn_close(connection); + free(transport); +} + ssh_client_t *ssh_connect_pubkey(const char *host, int port, - const char *user, - const char *key_path, - const char *pubkey_path, - const char *passphrase, - int pty_cols, int pty_rows, - char *err_buf, int err_sz) { + const char *user, + const char *key_path, + const char *pubkey_path, + const char *passphrase, + int pty_cols, int pty_rows, + ts3ds *tailscale, + char *err_buf, int err_sz) { if (libssh2_init(0) != 0) { copy_err(err_buf, err_sz, "libssh2_init failed"); return NULL; } - int sock = tcp_connect(host, port, err_buf, err_sz); - if (sock < 0) { - libssh2_exit(); - return NULL; + int sock = -1; + ts3ds_conn *tailscale_conn = NULL; + ssh_transport *transport = NULL; + if (tailscale) { + int dial_result = ts3ds_dial_tcp(tailscale, host, + (uint16_t)port, &tailscale_conn); + if (dial_result != TS3DS_OK) { + snprintf(err_buf, err_sz, + "Tailscale TCP dial failed (%d): %.180s", + dial_result, ts3ds_last_error(tailscale)); + libssh2_exit(); + return NULL; + } + transport = calloc(1, sizeof(*transport)); + if (!transport) { + ts3ds_conn_close(tailscale_conn); + libssh2_exit(); + return NULL; + } + transport->tailscale = tailscale; + transport->connection = tailscale_conn; + transport->blocking = 1; + } else { + sock = tcp_connect(host, port, err_buf, err_sz); + if (sock < 0) { + libssh2_exit(); + return NULL; + } } - LIBSSH2_SESSION *session = libssh2_session_init(); + LIBSSH2_SESSION *session = libssh2_session_init_ex(NULL, NULL, NULL, + transport); if (!session) { copy_err(err_buf, err_sz, "session_init failed"); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } + if (transport) { + libssh2_session_callback_set(session, LIBSSH2_CALLBACK_SEND, + (void *)tailscale_send_cb); + libssh2_session_callback_set(session, LIBSSH2_CALLBACK_RECV, + (void *)tailscale_recv_cb); + } libssh2_session_set_blocking(session, 1); - int hs_rc = libssh2_session_handshake(session, sock); + int hs_rc = libssh2_session_handshake(session, transport ? 0 : sock); if (hs_rc != 0) { copy_libssh2_err(err_buf, err_sz, session, "handshake", hs_rc); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -113,7 +203,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, "open key file failed: %s (errno=%d)", key_path, errno); libssh2_session_disconnect(session, "no key"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -131,7 +221,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, "key not PEM (size=%ld). Head: %.30s", key_size, first); libssh2_session_disconnect(session, "bad key"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -146,7 +236,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, user, methods); libssh2_session_disconnect(session, "no pubkey method"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -170,7 +260,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, mbedtls_pk_free(&tctx); libssh2_session_disconnect(session, "mbedtls parse"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -200,7 +290,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, prefix, inner, key_size, user, methods ? methods : "?"); libssh2_session_disconnect(session, "auth failed"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -212,7 +302,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, copy_libssh2_err(err_buf, err_sz, session, "channel_open", -1); libssh2_session_disconnect(session, "channel failed"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -229,7 +319,7 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, libssh2_channel_free(channel); libssh2_session_disconnect(session, "pty failed"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } @@ -241,12 +331,13 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, libssh2_channel_free(channel); libssh2_session_disconnect(session, "shell failed"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } libssh2_session_set_blocking(session, 0); + if (transport) transport->blocking = 0; /* Enable keepalive so we can tell idle-connection from broken-network. * want_reply=1 makes the server SSH_MSG_GLOBAL_REQUEST/keepalive @@ -261,12 +352,14 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, libssh2_channel_free(channel); libssh2_session_disconnect(session, "oom"); libssh2_session_free(session); - closesocket(sock); + close_pending_transport(sock, tailscale_conn, transport); libssh2_exit(); return NULL; } ssh->sock = sock; + ssh->tailscale_conn = tailscale_conn; + ssh->transport = transport; ssh->session = session; ssh->channel = channel; ssh->connected = 1; @@ -284,12 +377,23 @@ void ssh_disconnect(ssh_client_t *ssh) { libssh2_session_free(ssh->session); } if (ssh->sock >= 0) closesocket(ssh->sock); + if (ssh->tailscale_conn) ts3ds_conn_close(ssh->tailscale_conn); + free(ssh->transport); libssh2_exit(); free(ssh); } int ssh_is_connected(ssh_client_t *ssh) { return ssh && ssh->connected; } +void ssh_poll_transport(ssh_client_t *ssh) { + ssh_transport *transport; + if (!ssh || !ssh->transport) return; + transport = (ssh_transport *)ssh->transport; + if (transport->tailscale && + ts3ds_get_status(transport->tailscale) == TS3DS_STATUS_ONLINE) + (void)ts3ds_poll(transport->tailscale); +} + int ssh_read(ssh_client_t *ssh, char *buf, int len) { if (!ssh || !ssh->connected) return -1; ssize_t n = libssh2_channel_read(ssh->channel, buf, (size_t)len); @@ -339,28 +443,26 @@ ssh_aux_channel_t *ssh_aux_exec(ssh_client_t *ssh, const char *cmd, char *err_buf, int err_sz) { if (!ssh || !ssh->connected || !ssh->session || !cmd) return NULL; - /* Briefly flip session to blocking mode so open + exec round-trip - * inline. This stalls the main loop for ~one TCP RTT (~50-200 ms), - * which is invisible alongside the multi-second voice transcription - * we're about to wait for. An async open would be cleaner but adds - * substantial state-machine code for no perceptible UX gain. */ - libssh2_session_set_blocking(ssh->session, 1); + /* Briefly flip both libssh2 and its optional callback transport to + * blocking mode so open + exec can complete inline. This stalls the main + * loop for roughly one RTT, which is invisible beside transcription. */ + ssh_set_io_blocking(ssh, 1); LIBSSH2_CHANNEL *ch = libssh2_channel_open_session(ssh->session); if (!ch) { copy_libssh2_err(err_buf, err_sz, ssh->session, "aux open_session", 0); - libssh2_session_set_blocking(ssh->session, 0); + ssh_set_io_blocking(ssh, 0); return NULL; } int rc = libssh2_channel_exec(ch, cmd); if (rc != 0) { copy_libssh2_err(err_buf, err_sz, ssh->session, "aux exec", rc); + ssh_set_io_blocking(ssh, 0); libssh2_channel_free(ch); - libssh2_session_set_blocking(ssh->session, 0); return NULL; } - libssh2_session_set_blocking(ssh->session, 0); + ssh_set_io_blocking(ssh, 0); ssh_aux_channel_t *a = calloc(1, sizeof(*a)); if (!a) { diff --git a/source/ssh_client.h b/source/ssh_client.h index 56cf3a7..72c391c 100644 --- a/source/ssh_client.h +++ b/source/ssh_client.h @@ -8,12 +8,14 @@ */ typedef struct ssh_client_t ssh_client_t; +typedef struct ts3ds ts3ds; /* Connect, authenticate, open shell with PTY. Returns NULL on any failure. * 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. + * tailscale — optional libts3ds transport; NULL uses a BSD socket. * err_buf, err_sz — optional human-readable error captured on failure. */ ssh_client_t *ssh_connect_pubkey(const char *host, int port, @@ -22,11 +24,16 @@ ssh_client_t *ssh_connect_pubkey(const char *host, int port, const char *pubkey_path, const char *passphrase, int pty_cols, int pty_rows, + ts3ds *tailscale, char *err_buf, int err_sz); void ssh_disconnect(ssh_client_t *ssh); int ssh_is_connected(ssh_client_t *ssh); +/* Advance an application-owned transport such as libts3ds. Native sockets + * need no work. Call this while retrying nonblocking libssh2 operations. */ +void ssh_poll_transport(ssh_client_t *ssh); + /* Returns: bytes read (>=0), or -1 on disconnect. 0 means EAGAIN (try later). */ int ssh_read(ssh_client_t *ssh, char *buf, int len); diff --git a/source/voice.c b/source/voice.c index cfed514..e426876 100644 --- a/source/voice.c +++ b/source/voice.c @@ -511,6 +511,16 @@ void voice_free(voice_t *v) { free(v); } +void voice_abort(voice_t *v) { + if (!v) return; + if (v->mic_active) stop_mic_capture(v); + release_aux(v); + v->reply_len = 0; + v->type_pos = 0; + v->typed_latch = 0; + enter_idle(v); +} + void voice_toggle(voice_t *v, ssh_client_t *ssh) { if (!v || !ssh) return; switch (v->state) { diff --git a/source/voice.h b/source/voice.h index cc78075..d3cfc26 100644 --- a/source/voice.h +++ b/source/voice.h @@ -42,6 +42,10 @@ typedef enum { voice_t *voice_init(void); void voice_free(voice_t *v); +/* Cancel recording/transcription tied to an SSH transport that is about to + * be destroyed (for example after lid-close sleep or a hard disconnect). */ +void voice_abort(voice_t *v); + /* Triggered by physical KEY_START. * IDLE → RECORDING * RECORDING → TRANSCRIBING (or IDLE if too short) diff --git a/tools/test_config.c b/tools/test_config.c index 9ea00e7..d7b4156 100644 --- a/tools/test_config.c +++ b/tools/test_config.c @@ -23,6 +23,15 @@ int main(void) { "missing config should use defaults"); CHECK(cfg.macos_keychain_password[0] == 0, "keychain password should default empty"); + CHECK(cfg.tailscale_auth_key[0] == 0, + "Tailscale auth key should default empty"); + CHECK(strcmp(cfg.tailscale_hostname, "dssh-3ds") == 0, + "Tailscale hostname default"); + CHECK(strcmp(cfg.tailscale_state, + "sdmc:/3ds/3dssh/tailscale.state") == 0, + "Tailscale state path default"); + CHECK(config_tailscale_should_start(&cfg) == 0, + "Tailscale should stay off without key or state"); char path[] = "/tmp/dssh-config-test-XXXXXX"; int fd = mkstemp(path); @@ -38,7 +47,11 @@ int main(void) { } fputs("host = mac.example # comment\n" "user = alice\n" - "macos_keychain_password = \" p#ass\\\"word \" # comment\n", + "macos_keychain_password = \" p#ass\\\"word \" # comment\n" + "tailscale_auth_key = test-key\n" + "tailscale_hostname =\n" + "tailscale_state =\n" + "tailscale_control_url =\n", fp); fclose(fp); @@ -47,6 +60,27 @@ int main(void) { 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"); + CHECK(config_tailscale_should_start(&cfg) == 1, + "auth key should enable Tailscale automatically"); + CHECK(strcmp(cfg.tailscale_hostname, "dssh-3ds") == 0, + "empty Tailscale hostname should restore default"); + CHECK(strcmp(cfg.tailscale_state, + "sdmc:/3ds/3dssh/tailscale.state") == 0, + "empty Tailscale state path should restore default"); + CHECK(strcmp(cfg.tailscale_control_url, + "https://controlplane.tailscale.com") == 0, + "empty control URL should restore default"); + + cfg.tailscale_auth_key[0] = 0; + snprintf(cfg.tailscale_state, sizeof(cfg.tailscale_state), "%s", path); + fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0600); + CHECK(fd >= 0, "Tailscale state fixture create failed"); + if (fd >= 0) close(fd); + CHECK(config_tailscale_should_start(&cfg) == 1, + "persisted state should enable Tailscale without an auth key"); + unlink(path); + CHECK(config_tailscale_should_start(&cfg) == 0, + "missing state and empty auth key should disable Tailscale"); fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0600); CHECK(fd >= 0, "legacy-key config create failed"); diff --git a/tools/test_terminal.c b/tools/test_terminal.c index 2067813..f980533 100644 --- a/tools/test_terminal.c +++ b/tools/test_terminal.c @@ -2,6 +2,7 @@ #include #include +#include "../source/keychain_protocol.h" #include "../source/terminal.h" /* terminal.c only needs this one font-atlas query. Protocol tests use ASCII. */ @@ -84,6 +85,17 @@ int main(void) { 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");