From b91a0da67d6f7ff36f5125456f3838119f46182e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:17:33 +0700 Subject: [PATCH 1/9] docs(spec): add connection proxy design (HTTP CONNECT / SOCKS5) --- .../2026-06-14-connection-proxy-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-connection-proxy-design.md diff --git a/docs/superpowers/specs/2026-06-14-connection-proxy-design.md b/docs/superpowers/specs/2026-06-14-connection-proxy-design.md new file mode 100644 index 00000000..3d288135 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-connection-proxy-design.md @@ -0,0 +1,272 @@ +# Connection Proxy Design + +**Date:** 2026-06-14 +**Feature:** Per-host HTTP CONNECT / SOCKS5 proxy for SSH connections on restricted networks +**Priority:** P1 (Security & identity) + +--- + +## Goal + +Let a host reach its SSH server through an outbound proxy, for networks where direct TCP to +the server is blocked but a corporate HTTP CONNECT or SOCKS5 proxy is available. This +complements — does not replace — the existing multi-hop jump chain: a proxy is how the local +machine makes its *first* outbound TCP connection; jump hops then tunnel over SSH as today. + +Decisions (from brainstorming): +- **Both proxy types**: HTTP CONNECT and SOCKS5. +- **Optional auth**: HTTP Basic (`Proxy-Authorization`) and SOCKS5 username/password (RFC 1929); + empty credentials → no-auth. Proxy password lives in secure storage, never in synced JSON. +- **Scope = local-originated dials**: the proxy on a host applies whenever that host's IP:port + is dialed *directly from the local machine* — i.e. a direct connect to the target, or hop0 + (the first bastion) of a jump chain. Hops reached via `forwardLocal` (over a previous SSH + hop) never use a proxy. `testConnection` uses the same path so it reflects reality. +- **SOCKS5 remote DNS**: the target hostname is sent as a domain address (ATYP `0x03`), so DNS + resolves at the proxy — important when local DNS can't see internal names. + +Out of scope: proxy for RDP (separate Rust engine), proxy chains (multiple proxies in series), +PAC / proxy auto-detect, SOCKS4. + +--- + +## Key facts about the current code + +`SshService` obtains the SSH transport as an `SSHSocket` (the public abstract class in the +dartssh2 fork, `packages/dartssh2/lib/src/socket/ssh_socket.dart`) in three local-originated +spots: +- `connect` direct: `socket = await SSHSocket.connect(host.host, host.port)` (`ssh_service.dart:248`). +- `dialHop` hop0: `over ?? await SSHSocket.connect(hop.host, hop.port)` — `over == null` means + the first bastion, dialed from local (`ssh_service.dart:330`). +- `testConnection` direct: `SSHSocket.connect(host.host, host.port).timeout(...)` (`ssh_service.dart:521`). + +`SSHClient` takes any `SSHSocket`. The fork's `_SSHNativeSocket` (private) simply wraps a +`dart:io Socket` (which is itself a `Stream` + sink). So a proxied transport is just +another `SSHSocket` whose stream/sink ride a socket we connected to the proxy and handshook. + +The app is desktop-only (`dart:io` always present); no web/`ssh_socket_js` concern. + +--- + +## Architecture (app-side, no dartssh2 fork change) + +### 1. `app/lib/models/proxy_settings.dart` (new) + +```dart +enum ProxyType { none, http, socks5 } + +/// Runtime proxy parameters resolved from a Host plus its stored password. +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + const ProxySettings({ + required this.type, + required this.host, + required this.port, + this.username, + this.password, + }); +} +``` + +### 2. `app/lib/services/proxy_handshake.dart` (new, pure) + +Byte-level handshake logic, testable against in-memory stream/sink. Operates over a small +buffered byte reader (`ByteReader`, public so tests can construct it over a fake stream) so it +can read exactly the handshake bytes and return any over-read tail. + +```dart +class ProxyException implements Exception { + final String message; + ProxyException(this.message); + @override String toString() => 'ProxyException: $message'; +} + +/// Result of a successful handshake: bytes already read from the socket that +/// belong to the tunneled stream (e.g. the start of the SSH banner) and must +/// be re-emitted ahead of further socket data. +typedef HandshakeLeftover = Uint8List; + +/// Performs the HTTP CONNECT handshake. Writes the CONNECT request (+ optional +/// Basic auth), reads response headers up to `\r\n\r\n`, requires a `200` +/// status. Throws [ProxyException] on any non-200 (e.g. 407/403) or malformed +/// response. Returns bytes read past the header terminator. +Future httpConnectHandshake( + _ByteReader reader, + StreamSink> sink, { + required String targetHost, + required int targetPort, + String? username, + String? password, +}); + +/// Performs the SOCKS5 handshake: greeting (no-auth + optional user/pass +/// methods), optional RFC 1929 auth, then a CONNECT request with the target as +/// a domain address (ATYP 0x03 → remote DNS). Throws [ProxyException] on a +/// non-zero reply (mapped to a human message) or auth failure. Returns any +/// over-read tail. +Future socks5Handshake( + _ByteReader reader, + StreamSink> sink, { + required String targetHost, + required int targetPort, + String? username, + String? password, +}); +``` + +`ByteReader` is a small helper over the socket stream: `readExactly(n)` and +`readUntil(delimiter)` accumulate from the live subscription into a buffer; whatever remains +buffered when the handshake finishes is the leftover. (The reader keeps the single socket +subscription so no bytes are lost between handshake and tunneled traffic.) The handshake +functions take a `ByteReader` (not a private type) so the public API has no private-type leak. + +HTTP request shape: +``` +CONNECT host:port HTTP/1.1\r\n +Host: host:port\r\n +[Proxy-Authorization: Basic base64(user:pass)\r\n] +\r\n +``` +SOCKS5 reply-code → message map: `0x01` general failure, `0x02` not allowed by ruleset, `0x03` +network unreachable, `0x04` host unreachable, `0x05` connection refused, `0x06` TTL expired, +`0x07` command not supported, `0x08` address type not supported. + +### 3. `app/lib/services/connection_proxy.dart` (new) + +```dart +class ConnectionProxy { + /// Connects to [settings.host:port], runs the type-specific handshake to + /// open a tunnel to [targetHost:targetPort], and returns an [SSHSocket] + /// whose stream re-emits any handshake leftover before further socket data. + static Future connect({ + required ProxySettings settings, + required String targetHost, + required int targetPort, + Duration? timeout, + }); +} +``` +Flow: `Socket.connect(settings.host, settings.port)` → build `ByteReader` over the socket → run +`httpConnectHandshake` or `socks5Handshake` → wrap as `_ProxiedSocket(socket, leftover)`. When +`timeout` is given it bounds the **whole** connect-plus-handshake (the returned future is +`.timeout(timeout)`-wrapped), not just the TCP connect. On any handshake error or timeout the +socket is destroyed before the exception propagates. + +`_ProxiedSocket implements SSHSocket`: `stream` yields `leftover` (if non-empty) then the +socket stream; `sink`/`done`/`close`/`destroy` delegate to the socket. + +### 4. `app/lib/models/host.dart` + +Add (next to the existing flat fields): +- `ProxyType proxyType` (default `ProxyType.none`) +- `String? proxyHost` +- `int? proxyPort` +- `String? proxyUsername` + +toJson/fromJson/copyWith for all four. `proxyType` serializes by `.name` and parses with a +safe fallback to `none`. The proxy **password is not** in JSON. + +### 5. `StorageService` + +Proxy password via the existing generic-secret helpers, key `proxy_pw_`: +`saveGenericSecret('proxy_pw_$id', pw)` / `loadGenericSecret('proxy_pw_$id')` / +`deleteGenericSecret('proxy_pw_$id')` — same secure-first strategy as `pw_`. + +### 6. `app/lib/services/ssh_service.dart` + +New private helper: +```dart +Future _localDial(Host host, {Duration? timeout}) async { + if (host.proxyType == ProxyType.none) { + return SSHSocket.connect(host.host, host.port, timeout: timeout); + } + final pw = await _storage.loadGenericSecret('proxy_pw_${host.id}'); + return ConnectionProxy.connect( + settings: ProxySettings( + type: host.proxyType, + host: host.proxyHost!, + port: host.proxyPort!, + username: (host.proxyUsername?.isEmpty ?? true) ? null : host.proxyUsername, + password: (pw?.isEmpty ?? true) ? null : pw, + ), + targetHost: host.host, + targetPort: host.port, + timeout: timeout, + ); +} +``` +Replace the three local-originated dials: +- `connect`: `socket = await _localDial(host);` (line 248) +- `dialHop`: `over ?? await _localDial(hop)` (line 330) +- `testConnection`: `socket = await _localDial(host, timeout: const Duration(seconds: 10));` + (line 521 — drop the now-redundant outer `.timeout`) + +`_localDial` is `@visibleForTesting` with an injectable dialer seam so the proxy-vs-direct +branch is unit-testable without real sockets (a `ConnectionProxy.connect` function pointer, +defaulting to the real one). + +### 7. `app/lib/widgets/host_detail_panel.dart` + +A PROXY section inside the existing SSH-only (`!_isRdp`) block: +- `ProxyType` dropdown (None / HTTP CONNECT / SOCKS5). +- When not None: proxy host, port (numeric), username (optional), password (optional, obscured). +- `_save` writes `proxyType`/`proxyHost`/`proxyPort`/`proxyUsername` onto the Host and persists + the password via `proxy_pw_` (cleared when type is None or password emptied). + +--- + +## Data flow + +``` +SshService._localDial(host) + ├─ proxyType == none ─▶ SSHSocket.connect(target) (unchanged) + └─ proxyType != none ─▶ Socket.connect(proxy) + └─▶ handshake (CONNECT / SOCKS5 + auth) + └─▶ _ProxiedSocket(socket, leftover) ─▶ SSHClient(socket, …) +``` + +--- + +## Error handling + +- Proxy host unreachable / slow → `Socket.connect` error or `timeout` surfaces (error tab / + testConnection result). +- HTTP non-200 (407 auth required, 403 forbidden, …) → `ProxyException('HTTP proxy refused: 407 …')`. +- SOCKS5 reply ≠ 0 or auth failure → `ProxyException` with the mapped reason. +- Any handshake failure destroys the proxy socket before rethrowing — no leaked sockets. +- Missing `proxyHost`/`proxyPort` while `proxyType != none` is prevented by the panel + (validated) and guarded in `_localDial` (treated as a config error → `ProxyException`). + +--- + +## Testing + +**`test/services/proxy_handshake_test.dart`** (pure, fake stream/sink): +- HTTP: `200 Connection established` → success, leftover preserved when the response chunk + carries trailing SSH bytes. +- HTTP: `407` → `ProxyException`; Basic header present and correctly base64-encoded when creds + given; absent when not. +- HTTP: malformed status line → `ProxyException`. +- SOCKS5: no-auth path (server selects `0x00`) → CONNECT reply `0x00` success. +- SOCKS5: user/pass path (server selects `0x02`) → RFC 1929 auth `0x00` then success; + auth status `0x01` → `ProxyException`. +- SOCKS5: CONNECT request encodes ATYP `0x03` domain + target host + port (assert bytes). +- SOCKS5: reply `0x05` → `ProxyException('connection refused')`. + +**`test/services/connection_proxy_test.dart`**: `_ProxiedSocket` emits leftover ahead of socket +stream; `close`/`destroy` delegate. (Uses an in-memory socket pair / fake.) + +**`test/models/host_proxy_test.dart`**: defaults (`ProxyType.none`, null fields); round-trip +through toJson/fromJson incl. unknown `proxyType` string → `none`; copyWith. + +**`test/services/ssh_service_proxy_test.dart`**: `_localDial` with `proxyType == none` calls the +direct dialer; with `http`/`socks5` calls the injected `ConnectionProxy.connect` with the +resolved `ProxySettings` (host/port/user, password loaded from `proxy_pw_`). Fake dialer + +mock StorageService secret. + +**`test/widgets/host_detail_panel_proxy_test.dart`**: dropdown defaults None; selecting HTTP +reveals host/port/user/pass; saving round-trips `proxyType`/`proxyHost`/`proxyPort` onto the +saved Host. From 71e1b26401bb5ddc8c3cf05ceee65d92d548300d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:26:37 +0700 Subject: [PATCH 2/9] docs(plan): connection proxy implementation plan --- .../plans/2026-06-14-connection-proxy.md | 1419 +++++++++++++++++ 1 file changed, 1419 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-connection-proxy.md diff --git a/docs/superpowers/plans/2026-06-14-connection-proxy.md b/docs/superpowers/plans/2026-06-14-connection-proxy.md new file mode 100644 index 00000000..66db3047 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-connection-proxy.md @@ -0,0 +1,1419 @@ +# Connection Proxy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Connect a host's SSH session through an outbound HTTP CONNECT or SOCKS5 proxy (optional auth), gated per host, applied to local-originated TCP dials (direct target + first bastion). + +**Architecture:** App-side, no dartssh2 fork change. A pure handshake module (`proxy_handshake.dart`) drives a buffered `ByteReader`; `ConnectionProxy.connect` produces an `SSHSocket` whose stream re-emits the handshake leftover ahead of live data. `SshService.localDial` chooses direct vs proxied dialing via two injectable seams and is wired into the three local-originated dial sites. + +**Tech Stack:** Dart `dart:io` (`Socket`), `dart:convert` (base64/utf8), dartssh2 fork `SSHSocket` (public interface, unchanged), `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-06-14-connection-proxy-design.md` + +**Refinement vs spec:** handshake functions return the leftover `Uint8List` (via `ByteReader.takeLeftover()`); `ByteReader.release()` then yields a stream of *subsequent* live bytes only, and `ConnectionProxy` prepends the leftover. This avoids re-listening to the single-subscription `Socket`. + +--- + +## File structure + +- Create: `app/lib/models/proxy_settings.dart` — `ProxyType` enum + `ProxySettings` value. +- Create: `app/lib/services/proxy_handshake.dart` — `ProxyException`, `ByteReader`, `httpConnectHandshake`, `socks5Handshake`. +- Create: `app/lib/services/connection_proxy.dart` — `ConnectionProxy.connect` + `_ProxiedSocket`. +- Modify: `app/lib/models/host.dart` — proxy fields. +- Modify: `app/lib/services/storage_service.dart` — (none needed; reuse generic-secret helpers). +- Modify: `app/lib/services/ssh_service.dart` — proxy password helpers, dial seams, `localDial`, wire 3 sites. +- Modify: `app/lib/widgets/host_detail_panel.dart` — PROXY section + persist proxy password. +- Tests: one per module under `app/test/...` (paths in each task). + +All commands run from `/Users/thangnguyen/Projects/Personal/yourssh/app` (Flutter root). Git commands `cd` to the repo root explicitly. + +--- + +### Task 1: `ProxyType` + `ProxySettings` + +**Files:** +- Create: `app/lib/models/proxy_settings.dart` +- Test: `app/test/models/proxy_settings_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/models/proxy_settings_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/proxy_settings.dart'; + +void main() { + test('ProxyType has none/http/socks5', () { + expect(ProxyType.values, [ProxyType.none, ProxyType.http, ProxyType.socks5]); + }); + + test('ProxySettings holds its fields', () { + const s = ProxySettings( + type: ProxyType.socks5, host: 'p', port: 1080, username: 'u', password: 'x'); + expect(s.type, ProxyType.socks5); + expect(s.host, 'p'); + expect(s.port, 1080); + expect(s.username, 'u'); + expect(s.password, 'x'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/models/proxy_settings_test.dart` +Expected: FAIL — `proxy_settings.dart` / `ProxyType` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `app/lib/models/proxy_settings.dart`: + +```dart +enum ProxyType { none, http, socks5 } + +/// Runtime proxy parameters resolved from a Host plus its stored password. +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + const ProxySettings({ + required this.type, + required this.host, + required this.port, + this.username, + this.password, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/models/proxy_settings_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/models/proxy_settings.dart app/test/models/proxy_settings_test.dart +git commit -m "feat(proxy): ProxyType enum + ProxySettings value" +``` + +--- + +### Task 2: `ByteReader` + `ProxyException` + HTTP CONNECT handshake + +**Files:** +- Create: `app/lib/services/proxy_handshake.dart` +- Test: `app/test/services/proxy_handshake_http_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/services/proxy_handshake_http_test.dart`: + +```dart +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/proxy_handshake.dart'; + +/// Minimal StreamSink that records everything written. +class _ListSink implements StreamSink> { + final List bytes = []; + @override void add(List data) => bytes.addAll(data); + @override void addError(Object error, [StackTrace? st]) {} + @override Future addStream(Stream> stream) => stream.forEach(add); + @override Future close() async {} + @override Future get done => Future.value(); +} + +void main() { + test('200 success returns leftover bytes', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = httpConnectHandshake(reader, sink, + targetHost: 'example.com', targetPort: 22); + + expect(utf8.decode(sink.bytes), contains('CONNECT example.com:22 HTTP/1.1')); + expect(utf8.decode(sink.bytes), isNot(contains('Proxy-Authorization'))); + + input.add(Uint8List.fromList( + utf8.encode('HTTP/1.1 200 Connection established\r\n\r\nSSHPREFIX'))); + final leftover = await future; + expect(utf8.decode(leftover), 'SSHPREFIX'); + }); + + test('Basic auth header is sent when credentials given', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = httpConnectHandshake(reader, sink, + targetHost: 'h', targetPort: 22, username: 'u', password: 'p'); + final expected = base64.encode(utf8.encode('u:p')); + expect(utf8.decode(sink.bytes), contains('Proxy-Authorization: Basic $expected')); + input.add(Uint8List.fromList(utf8.encode('HTTP/1.1 200 OK\r\n\r\n'))); + await future; + }); + + test('non-200 throws ProxyException', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = httpConnectHandshake(reader, sink, targetHost: 'h', targetPort: 22); + input.add(Uint8List.fromList( + utf8.encode('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'))); + await expectLater(future, throwsA(isA())); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/proxy_handshake_http_test.dart` +Expected: FAIL — `proxy_handshake.dart` / `ByteReader` / `httpConnectHandshake` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `app/lib/services/proxy_handshake.dart`: + +```dart +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +/// Raised for any proxy handshake failure (refusal, auth, malformed reply, +/// early close). +class ProxyException implements Exception { + final String message; + const ProxyException(this.message); + @override + String toString() => 'ProxyException: $message'; +} + +/// Buffered byte reader over a socket stream. Serves [readExactly]/[readUntil] +/// during the handshake; afterwards [takeLeftover] returns the buffered tail +/// and [release] hands the live remainder out as a stream. Single pending read +/// at a time (handshakes are sequential). +class ByteReader { + ByteReader(Stream stream) { + _sub = stream.listen(_onData, onError: _onError, onDone: _onDone); + } + + late final StreamSubscription _sub; + final List _buf = []; + bool _done = false; + Object? _error; + void Function()? _wake; + StreamController? _out; + + void _onData(Uint8List chunk) { + if (_out != null) { + _out!.add(chunk); + return; + } + _buf.addAll(chunk); + _wake?.call(); + } + + void _onError(Object e, StackTrace st) { + if (_out != null) { + _out!.addError(e, st); + return; + } + _error = e; + _wake?.call(); + } + + void _onDone() { + if (_out != null) { + _out!.close(); + return; + } + _done = true; + _wake?.call(); + } + + Future readExactly(int n) { + final completer = Completer(); + void attempt() { + if (completer.isCompleted) return; + if (_error != null) { + _wake = null; + completer.completeError(_error!); + } else if (_buf.length >= n) { + final out = Uint8List.fromList(_buf.sublist(0, n)); + _buf.removeRange(0, n); + _wake = null; + completer.complete(out); + } else if (_done) { + _wake = null; + completer.completeError( + const ProxyException('proxy closed connection during handshake')); + } + } + + _wake = attempt; + attempt(); + return completer.future; + } + + Future readUntil(List delimiter) { + final completer = Completer(); + void attempt() { + if (completer.isCompleted) return; + if (_error != null) { + _wake = null; + completer.completeError(_error!); + return; + } + final idx = _indexOf(_buf, delimiter); + if (idx >= 0) { + final end = idx + delimiter.length; + final out = Uint8List.fromList(_buf.sublist(0, end)); + _buf.removeRange(0, end); + _wake = null; + completer.complete(out); + } else if (_done) { + _wake = null; + completer.completeError( + const ProxyException('proxy closed connection during handshake')); + } + } + + _wake = attempt; + attempt(); + return completer.future; + } + + /// The bytes buffered but not consumed by the handshake. + Uint8List takeLeftover() { + final out = Uint8List.fromList(_buf); + _buf.clear(); + return out; + } + + /// Hands the live remainder of the stream out. Call once, after the + /// handshake. The buffer must already be drained via [takeLeftover]. + Stream release() { + final out = StreamController(); + _out = out; + out.onCancel = () => _sub.cancel(); + if (_done) { + out.close(); + } else if (_error != null) { + out.addError(_error!); + } + return out.stream; + } + + Future destroy() => _sub.cancel(); + + static int _indexOf(List hay, List needle) { + for (var i = 0; i + needle.length <= hay.length; i++) { + var match = true; + for (var j = 0; j < needle.length; j++) { + if (hay[i + j] != needle[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; + } +} + +/// HTTP CONNECT handshake. Writes the request (optional Basic auth), reads +/// response headers up to `\r\n\r\n`, requires a 200 status. Returns the +/// bytes already read past the header terminator. +Future httpConnectHandshake( + ByteReader reader, + StreamSink> sink, { + required String targetHost, + required int targetPort, + String? username, + String? password, +}) async { + final req = StringBuffer() + ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') + ..write('Host: $targetHost:$targetPort\r\n'); + if (username != null && username.isNotEmpty) { + final cred = base64.encode(utf8.encode('$username:${password ?? ''}')); + req.write('Proxy-Authorization: Basic $cred\r\n'); + } + req.write('\r\n'); + sink.add(utf8.encode(req.toString())); + + final header = await reader.readUntil(const [13, 10, 13, 10]); // \r\n\r\n + final statusLine = ascii.decode(header, allowInvalid: true).split('\r\n').first; + final parts = statusLine.split(' '); + if (parts.length < 2 || parts[1] != '200') { + throw ProxyException('HTTP proxy refused: $statusLine'); + } + return reader.takeLeftover(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/services/proxy_handshake_http_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/services/proxy_handshake.dart app/test/services/proxy_handshake_http_test.dart +git commit -m "feat(proxy): ByteReader + HTTP CONNECT handshake" +``` + +--- + +### Task 3: SOCKS5 handshake + +**Files:** +- Modify: `app/lib/services/proxy_handshake.dart` (append `socks5Handshake` + reply-code map) +- Test: `app/test/services/proxy_handshake_socks5_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/services/proxy_handshake_socks5_test.dart`: + +```dart +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/proxy_handshake.dart'; + +class _ListSink implements StreamSink> { + final List bytes = []; + @override void add(List data) => bytes.addAll(data); + @override void addError(Object error, [StackTrace? st]) {} + @override Future addStream(Stream> stream) => stream.forEach(add); + @override Future close() async {} + @override Future get done => Future.value(); +} + +void main() { + test('no-auth path: greeting, CONNECT with domain ATYP, success', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, + targetHost: 'host.internal', targetPort: 2222); + + // greeting: VER=5, NMETHODS=1, METHOD=0x00 + expect(sink.bytes.sublist(0, 3), [0x05, 0x01, 0x00]); + input.add(Uint8List.fromList([0x05, 0x00])); // select no-auth + + await Future.delayed(Duration.zero); // let CONNECT be written + // CONNECT: VER=5, CMD=1, RSV=0, ATYP=3, len, host..., port hi, port lo + final host = 'host.internal'.codeUnits; + final expected = [0x05, 0x01, 0x00, 0x03, host.length, ...host, 0x08, 0xAE]; + expect(sink.bytes.sublist(3), expected); + + // reply: VER, REP=0, RSV, ATYP=1 (ipv4), 4 addr, 2 port, then SSH leftover + input.add(Uint8List.fromList( + [0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0x53, 0x53])); // 'SS' leftover + final leftover = await future; + expect(leftover, [0x53, 0x53]); + }); + + test('user/pass path: RFC 1929 auth then success', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, + targetHost: 'h', targetPort: 22, username: 'u', password: 'p'); + + // greeting offers no-auth + user/pass + expect(sink.bytes.sublist(0, 4), [0x05, 0x02, 0x00, 0x02]); + input.add(Uint8List.fromList([0x05, 0x02])); // select user/pass + + await Future.delayed(Duration.zero); + // auth: VER=1, ulen, 'u', plen, 'p' + final authStart = 4; + expect(sink.bytes.sublist(authStart, authStart + 5), + [0x01, 0x01, 'u'.codeUnitAt(0), 0x01, 'p'.codeUnitAt(0)]); + input.add(Uint8List.fromList([0x01, 0x00])); // auth success + + await Future.delayed(Duration.zero); + input.add(Uint8List.fromList([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + await future; // completes without throwing + }); + + test('reply 0x05 maps to connection refused', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, targetHost: 'h', targetPort: 22); + input.add(Uint8List.fromList([0x05, 0x00])); + await Future.delayed(Duration.zero); + input.add(Uint8List.fromList([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + await expectLater( + future, + throwsA(isA().having( + (e) => e.message, 'message', contains('refused')))); + }); + + test('auth failure throws', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, + targetHost: 'h', targetPort: 22, username: 'u', password: 'bad'); + input.add(Uint8List.fromList([0x05, 0x02])); + await Future.delayed(Duration.zero); + input.add(Uint8List.fromList([0x01, 0x01])); // auth fail + await expectLater(future, throwsA(isA())); + }); +} +``` + +Note: port 2222 = `0x08AE` (hi `0x08`, lo `0xAE`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/proxy_handshake_socks5_test.dart` +Expected: FAIL — `socks5Handshake` not found. + +- [ ] **Step 3: Write minimal implementation** + +Append to `app/lib/services/proxy_handshake.dart`: + +```dart +/// SOCKS5 handshake: greeting (no-auth + optional user/pass), optional RFC 1929 +/// auth, then a CONNECT request with the target as a domain address (ATYP 0x03 → +/// remote DNS). Returns any over-read tail. +Future socks5Handshake( + ByteReader reader, + StreamSink> sink, { + required String targetHost, + required int targetPort, + String? username, + String? password, +}) async { + final hasAuth = username != null && username.isNotEmpty; + final methods = hasAuth ? [0x00, 0x02] : [0x00]; + sink.add(Uint8List.fromList([0x05, methods.length, ...methods])); + + final sel = await reader.readExactly(2); + if (sel[0] != 0x05) throw const ProxyException('not a SOCKS5 proxy'); + final method = sel[1]; + if (method == 0xFF) { + throw const ProxyException('SOCKS5 proxy rejected all auth methods'); + } + if (method == 0x02) { + if (!hasAuth) { + throw const ProxyException('SOCKS5 proxy requires username/password'); + } + final u = utf8.encode(username); + final p = utf8.encode(password ?? ''); + sink.add(Uint8List.fromList([0x01, u.length, ...u, p.length, ...p])); + final authResp = await reader.readExactly(2); + if (authResp[1] != 0x00) { + throw const ProxyException('SOCKS5 authentication failed'); + } + } else if (method != 0x00) { + throw ProxyException('SOCKS5 proxy selected unsupported method $method'); + } + + final hostBytes = utf8.encode(targetHost); + if (hostBytes.length > 255) { + throw const ProxyException('hostname too long for SOCKS5'); + } + sink.add(Uint8List.fromList([ + 0x05, 0x01, 0x00, 0x03, hostBytes.length, ...hostBytes, + (targetPort >> 8) & 0xff, targetPort & 0xff, + ])); + + final head = await reader.readExactly(4); // VER, REP, RSV, ATYP + if (head[1] != 0x00) throw ProxyException(_socksReplyMessage(head[1])); + final atyp = head[3]; + int addrLen; + if (atyp == 0x01) { + addrLen = 4; + } else if (atyp == 0x04) { + addrLen = 16; + } else if (atyp == 0x03) { + addrLen = (await reader.readExactly(1))[0]; + } else { + throw ProxyException('SOCKS5 unsupported address type $atyp'); + } + await reader.readExactly(addrLen + 2); // bound address + port + return reader.takeLeftover(); +} + +String _socksReplyMessage(int rep) { + switch (rep) { + case 0x01: + return 'SOCKS5 general failure'; + case 0x02: + return 'SOCKS5 connection not allowed by ruleset'; + case 0x03: + return 'SOCKS5 network unreachable'; + case 0x04: + return 'SOCKS5 host unreachable'; + case 0x05: + return 'SOCKS5 connection refused'; + case 0x06: + return 'SOCKS5 TTL expired'; + case 0x07: + return 'SOCKS5 command not supported'; + case 0x08: + return 'SOCKS5 address type not supported'; + default: + return 'SOCKS5 failed (code $rep)'; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/services/proxy_handshake_socks5_test.dart` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/services/proxy_handshake.dart app/test/services/proxy_handshake_socks5_test.dart +git commit -m "feat(proxy): SOCKS5 handshake with optional RFC 1929 auth" +``` + +--- + +### Task 4: `ConnectionProxy.connect` + `_ProxiedSocket` + +**Files:** +- Create: `app/lib/services/connection_proxy.dart` +- Test: `app/test/services/connection_proxy_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/services/connection_proxy_test.dart` (covers the leftover-prepend behavior of the released stream, which is the risky part; the real `Socket.connect` path is exercised by manual smoke): + +```dart +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/proxy_handshake.dart'; + +void main() { + test('release() emits live data; takeLeftover returns buffered tail', () async { + final input = StreamController(); + final reader = ByteReader(input.stream); + + // Simulate a handshake having read up to a point, leaving trailing bytes. + input.add(Uint8List.fromList([1, 2, 3, 4])); + await Future.delayed(Duration.zero); + final firstTwo = await reader.readExactly(2); + expect(firstTwo, [1, 2]); + + final leftover = reader.takeLeftover(); + expect(leftover, [3, 4]); + + final live = []; + final done = Completer(); + reader.release().listen(live.addAll, onDone: done.complete); + input.add(Uint8List.fromList([5, 6])); + await Future.delayed(Duration.zero); + await input.close(); + await done.future; + expect(live, [5, 6]); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/connection_proxy_test.dart` +Expected: this test exercises only `proxy_handshake.dart` and should PASS already once Task 2 is done. (It documents the handoff contract `ConnectionProxy` relies on.) If Task 2 is complete it passes immediately — proceed to write `connection_proxy.dart` anyway in Step 3 since later tasks import it. + +- [ ] **Step 3: Write the implementation** + +Create `app/lib/services/connection_proxy.dart`: + +```dart +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +import '../models/proxy_settings.dart'; +import 'proxy_handshake.dart'; + +class ConnectionProxy { + /// Connects to the proxy, runs the type-specific handshake to tunnel to + /// [targetHost]:[targetPort], and returns an [SSHSocket] whose stream + /// re-emits the handshake leftover before live data. When [timeout] is set + /// it bounds the whole connect-plus-handshake. Destroys the socket on any + /// failure. + static Future connect({ + required ProxySettings settings, + required String targetHost, + required int targetPort, + Duration? timeout, + }) { + Future run() async { + final socket = await Socket.connect(settings.host, settings.port); + try { + final reader = ByteReader(socket); + final Uint8List leftover; + switch (settings.type) { + case ProxyType.http: + leftover = await httpConnectHandshake(reader, socket, + targetHost: targetHost, + targetPort: targetPort, + username: settings.username, + password: settings.password); + case ProxyType.socks5: + leftover = await socks5Handshake(reader, socket, + targetHost: targetHost, + targetPort: targetPort, + username: settings.username, + password: settings.password); + case ProxyType.none: + throw const ProxyException( + 'ConnectionProxy.connect called with ProxyType.none'); + } + return _ProxiedSocket(socket, leftover, reader.release()); + } catch (_) { + socket.destroy(); + rethrow; + } + } + + final fut = run(); + return timeout == null ? fut : fut.timeout(timeout); + } +} + +class _ProxiedSocket implements SSHSocket { + _ProxiedSocket(this._socket, Uint8List leftover, Stream live) + : _stream = _prepend(leftover, live); + + final Socket _socket; + final Stream _stream; + + static Stream _prepend( + Uint8List head, Stream tail) async* { + if (head.isNotEmpty) yield head; + yield* tail; + } + + @override + Stream get stream => _stream; + + @override + StreamSink> get sink => _socket; + + @override + Future get done => _socket.done; + + @override + Future close() async { + await _socket.close(); + } + + @override + void destroy() => _socket.destroy(); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `flutter test test/services/connection_proxy_test.dart` +Expected: PASS (1 test). Also `flutter analyze lib/services/connection_proxy.dart` → no issues. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/services/connection_proxy.dart app/test/services/connection_proxy_test.dart +git commit -m "feat(proxy): ConnectionProxy.connect producing a proxied SSHSocket" +``` + +--- + +### Task 5: Host proxy fields + +**Files:** +- Modify: `app/lib/models/host.dart` (import; field block ~48; ctor ~85; toJson ~135; fromJson parse + ~225; copyWith sig ~255 + body ~286) +- Test: `app/test/models/host_proxy_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/models/host_proxy_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/proxy_settings.dart'; + +void main() { + group('Host proxy fields', () { + test('defaults: none type, null host/port/username', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + expect(h.proxyType, ProxyType.none); + expect(h.proxyHost, isNull); + expect(h.proxyPort, isNull); + expect(h.proxyUsername, isNull); + }); + + test('round-trips through toJson/fromJson', () { + final h = Host( + label: 'a', + host: 'h', + username: 'u', + proxyType: ProxyType.http, + proxyHost: 'proxy', + proxyPort: 8080, + proxyUsername: 'pu'); + final back = Host.fromJson(h.toJson()); + expect(back.proxyType, ProxyType.http); + expect(back.proxyHost, 'proxy'); + expect(back.proxyPort, 8080); + expect(back.proxyUsername, 'pu'); + }); + + test('unknown proxyType string falls back to none', () { + final json = Host(label: 'a', host: 'h', username: 'u').toJson() + ..['proxyType'] = 'bogus'; + expect(Host.fromJson(json).proxyType, ProxyType.none); + }); + + test('copyWith overrides proxy fields', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + final c = h.copyWith( + proxyType: ProxyType.socks5, proxyHost: 'p', proxyPort: 1080); + expect(c.proxyType, ProxyType.socks5); + expect(c.proxyHost, 'p'); + expect(c.proxyPort, 1080); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/models/host_proxy_test.dart` +Expected: FAIL — `proxyType` not a parameter / getter undefined. + +- [ ] **Step 3: Write minimal implementation** + +In `app/lib/models/host.dart`: + +(a) Add import near the other model imports at the top of the file: +```dart +import 'proxy_settings.dart'; +``` + +(b) Field block — after `bool agentForwarding;` (and `bool osc52Clipboard;` from the OSC 52 work; place after the bools, before `SftpMode sftpMode;`): +```dart + ProxyType proxyType; + String? proxyHost; + int? proxyPort; + String? proxyUsername; +``` + +(c) Constructor — after `this.agentForwarding = false,` (and `this.osc52Clipboard = false,`): +```dart + this.proxyType = ProxyType.none, + this.proxyHost, + this.proxyPort, + this.proxyUsername, +``` + +(d) `toJson` — after `'agentForwarding': agentForwarding,` (and the osc52 line): +```dart + 'proxyType': proxyType.name, + 'proxyHost': proxyHost, + 'proxyPort': proxyPort, + 'proxyUsername': proxyUsername, +``` + +(e) `fromJson` — add a local parser near the other `parseX` helpers in `fromJson`: +```dart + ProxyType parseProxyType() { + final name = json['proxyType'] as String?; + if (name == null) return ProxyType.none; + return ProxyType.values.asNameMap()[name] ?? ProxyType.none; + } +``` +and in the returned `Host(...)`, after the `agentForwarding:`/`osc52Clipboard:` lines: +```dart + proxyType: parseProxyType(), + proxyHost: json['proxyHost'] as String?, + proxyPort: (json['proxyPort'] as num?)?.toInt(), + proxyUsername: json['proxyUsername'] as String?, +``` + +(f) `copyWith` signature — after `bool? agentForwarding,` (and `bool? osc52Clipboard,`): +```dart + ProxyType? proxyType, + String? proxyHost, + int? proxyPort, + String? proxyUsername, +``` + +(g) `copyWith` body — after `agentForwarding: agentForwarding ?? this.agentForwarding,` (and osc52 line): +```dart + proxyType: proxyType ?? this.proxyType, + proxyHost: proxyHost ?? this.proxyHost, + proxyPort: proxyPort ?? this.proxyPort, + proxyUsername: proxyUsername ?? this.proxyUsername, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/models/host_proxy_test.dart` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/models/host.dart app/test/models/host_proxy_test.dart +git commit -m "feat(proxy): add Host proxy fields" +``` + +--- + +### Task 6: `SshService` proxy password + `localDial` + wire dials + +**Files:** +- Modify: `app/lib/services/ssh_service.dart` (imports; password helpers; dial seams; `localDial`; call sites 248/330/521) +- Test: `app/test/services/ssh_service_proxy_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/services/ssh_service_proxy_test.dart`: + +```dart +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/proxy_settings.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +class _FakeSocket implements SSHSocket { + @override Stream get stream => const Stream.empty(); + @override StreamSink> get sink => throw UnimplementedError(); + @override Future get done => Future.value(); + @override Future close() async {} + @override void destroy() {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('localDial uses the direct dialer when proxyType is none', () async { + final svc = SshService(StorageService()); + final fake = _FakeSocket(); + String? dialedHost; + int? dialedPort; + svc.directDialer = (h, p, {timeout}) async { + dialedHost = h; + dialedPort = p; + return fake; + }; + svc.proxyDialer = ({required settings, required targetHost, required targetPort, timeout}) async => + throw StateError('proxy dialer must not be called'); + + final host = Host(label: 'a', host: 'srv', port: 22, username: 'u'); + final s = await svc.localDial(host); + + expect(identical(s, fake), isTrue); + expect(dialedHost, 'srv'); + expect(dialedPort, 22); + }); + + test('localDial uses the proxy dialer with resolved settings', () async { + final svc = SshService(StorageService()); + final host = Host( + label: 'a', + host: 'target', + port: 2222, + username: 'u', + proxyType: ProxyType.socks5, + proxyHost: 'proxy', + proxyPort: 1080, + proxyUsername: 'pu'); + await svc.saveProxyPassword(host.id, 'secret'); + + ProxySettings? got; + String? gotTarget; + int? gotPort; + svc.proxyDialer = ({required settings, required targetHost, required targetPort, timeout}) async { + got = settings; + gotTarget = targetHost; + gotPort = targetPort; + return _FakeSocket(); + }; + + await svc.localDial(host); + + expect(got!.type, ProxyType.socks5); + expect(got!.host, 'proxy'); + expect(got!.port, 1080); + expect(got!.username, 'pu'); + expect(got!.password, 'secret'); + expect(gotTarget, 'target'); + expect(gotPort, 2222); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/ssh_service_proxy_test.dart` +Expected: FAIL — `directDialer`/`proxyDialer`/`localDial`/`saveProxyPassword` undefined. + +- [ ] **Step 3: Write minimal implementation** + +In `app/lib/services/ssh_service.dart`: + +(a) Add imports after the existing service imports (alongside the OSC 52 `osc52_clipboard.dart` import): +```dart +import 'connection_proxy.dart'; +import 'proxy_handshake.dart'; +import '../models/proxy_settings.dart'; +``` + +(b) Add dial seams just before the constructor (near `clipboardWriter`): +```dart + /// Direct TCP dialer; injectable for tests. Defaults to the native socket. + @visibleForTesting + Future Function(String host, int port, {Duration? timeout}) + directDialer = SSHSocket.connect; + + /// Proxied dialer; injectable for tests. Defaults to [ConnectionProxy.connect]. + @visibleForTesting + Future Function({ + required ProxySettings settings, + required String targetHost, + required int targetPort, + Duration? timeout, + }) proxyDialer = ConnectionProxy.connect; +``` + +(c) Add proxy-password helpers (near the top-level public methods, e.g. after where `loadPassword` is exposed or anywhere in the class body): +```dart + Future loadProxyPassword(String hostId) => + _storage.loadGenericSecret('proxy_pw_$hostId'); + + Future saveProxyPassword(String hostId, String password) => + password.isEmpty + ? _storage.deleteGenericSecret('proxy_pw_$hostId') + : _storage.saveGenericSecret('proxy_pw_$hostId', password); +``` +> Note: if `SshService` has no public `loadPassword`, add one too: `Future loadPassword(String hostId) => _storage.loadPassword(hostId);` — but it is already used by the host panel (`context.read().loadPassword`), so it exists. + +(d) Add `localDial`: +```dart + /// Opens the first local-originated TCP transport for [host], routing through + /// the host's configured proxy when set. Used for a direct connect, the first + /// bastion hop, and test-connection. + @visibleForTesting + Future localDial(Host host, {Duration? timeout}) async { + if (host.proxyType == ProxyType.none) { + return directDialer(host.host, host.port, timeout: timeout); + } + if (host.proxyHost == null || host.proxyHost!.isEmpty || host.proxyPort == null) { + throw const ProxyException('Proxy enabled but proxy host/port is missing'); + } + final pw = await loadProxyPassword(host.id); + return proxyDialer( + settings: ProxySettings( + type: host.proxyType, + host: host.proxyHost!, + port: host.proxyPort!, + username: (host.proxyUsername?.isEmpty ?? true) ? null : host.proxyUsername, + password: (pw?.isEmpty ?? true) ? null : pw, + ), + targetHost: host.host, + targetPort: host.port, + timeout: timeout, + ); + } +``` + +(e) Wire the three local-originated dial sites: + +`connect` (line ~248): replace +```dart + socket = await SSHSocket.connect(host.host, host.port); +``` +with +```dart + socket = await localDial(host); +``` + +`dialHop` (line ~330): replace +```dart + over ?? await SSHSocket.connect(hop.host, hop.port), +``` +with +```dart + over ?? await localDial(hop), +``` + +`testConnection` (line ~521): replace +```dart + socket = await SSHSocket.connect(host.host, host.port) + .timeout(const Duration(seconds: 10)); +``` +with +```dart + socket = await localDial(host, timeout: const Duration(seconds: 10)); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/services/ssh_service_proxy_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/services/ssh_service.dart app/test/services/ssh_service_proxy_test.dart +git commit -m "feat(proxy): route local-originated dials through the configured proxy" +``` + +--- + +### Task 7: Host panel PROXY section + +**Files:** +- Modify: `app/lib/widgets/host_detail_panel.dart` (controllers/state; init incl. proxy password load; `_buildHost`/save proxy fields; persist proxy password; PROXY section UI in the `!_isRdp` block; dispose) +- Test: `app/test/widgets/host_detail_panel_proxy_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `app/test/widgets/host_detail_panel_proxy_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/proxy_settings.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/host_detail_panel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + Host? saved; + + Future pumpPanel(WidgetTester tester, {Host? existing}) async { + saved = null; + await tester.binding.setSurfaceSize(const Size(500, 2800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider( + create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), + ], + child: MaterialApp( + home: Scaffold( + body: HostDetailPanel( + existing: existing, + agentProbe: () async => const AgentProbeSystem(1), + onClose: () {}, + onSave: (host, _) async => saved = host, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('proxy dropdown defaults to None and hides fields', (tester) async { + await pumpPanel(tester, existing: Host(label: 's', host: 'h', username: 'u')); + final dropdown = find.byKey(const ValueKey('proxy-type-dropdown')); + await tester.ensureVisible(dropdown); + expect(find.byKey(const ValueKey('proxy-host-field')), findsNothing); + }); + + testWidgets('selecting HTTP reveals host/port and saving round-trips', + (tester) async { + await pumpPanel(tester, existing: Host(label: 's', host: 'h', username: 'u')); + + final dropdown = find.byKey(const ValueKey('proxy-type-dropdown')); + await tester.ensureVisible(dropdown); + await tester.tap(dropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text('HTTP CONNECT').last); + await tester.pumpAndSettle(); + + final hostField = find.byKey(const ValueKey('proxy-host-field')); + await tester.ensureVisible(hostField); + await tester.enterText(hostField, 'proxy.local'); + await tester.enterText( + find.byKey(const ValueKey('proxy-port-field')), '8080'); + + final save = find.text('SAVE ONLY'); + await tester.ensureVisible(save); + await tester.tap(save); + await tester.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!.proxyType, ProxyType.http); + expect(saved!.proxyHost, 'proxy.local'); + expect(saved!.proxyPort, 8080); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/widgets/host_detail_panel_proxy_test.dart` +Expected: FAIL — no `proxy-type-dropdown` widget. + +- [ ] **Step 3: Write minimal implementation** + +In `app/lib/widgets/host_detail_panel.dart`: + +(a) State + controllers — after the existing proxy-adjacent state (near `_osc52Clipboard`): +```dart + ProxyType _proxyType = ProxyType.none; + bool _obscureProxyPassword = true; + late final TextEditingController _proxyHostCtrl; + late final TextEditingController _proxyPortCtrl; + late final TextEditingController _proxyUsernameCtrl; + late final TextEditingController _proxyPasswordCtrl; +``` +Add `import 'package:yourssh/models/proxy_settings.dart';` to the imports. + +(b) Init (in `initState`, alongside the other controller initializers): +```dart + _proxyType = h?.proxyType ?? ProxyType.none; + _proxyHostCtrl = TextEditingController(text: h?.proxyHost ?? ''); + _proxyPortCtrl = + TextEditingController(text: h?.proxyPort?.toString() ?? ''); + _proxyUsernameCtrl = TextEditingController(text: h?.proxyUsername ?? ''); + _proxyPasswordCtrl = TextEditingController(); +``` +In the async password-loading block that already loads the SSH password (around line 139), also load the proxy password: +```dart + final proxyPw = + await context.read().loadProxyPassword(hostId); + if (mounted && proxyPw != null && proxyPw.isNotEmpty && + _proxyPasswordCtrl.text.isEmpty) { + setState(() => _proxyPasswordCtrl.text = proxyPw); + } +``` +(`hostId` is the existing host's id, already computed in that block.) + +(c) dispose — add the four controllers to the existing dispose list: +```dart + _proxyHostCtrl, _proxyPortCtrl, _proxyUsernameCtrl, _proxyPasswordCtrl, +``` + +(d) `_buildHost` (the `Host(...)` constructed for save/test — around lines 220-235): add after `agentForwarding: !_isRdp && _agentForwarding,` (and the `osc52Clipboard:` line): +```dart + proxyType: _isRdp ? ProxyType.none : _proxyType, + proxyHost: _isRdp || _proxyType == ProxyType.none + ? null + : _proxyHostCtrl.text.trim(), + proxyPort: _isRdp || _proxyType == ProxyType.none + ? null + : int.tryParse(_proxyPortCtrl.text.trim()), + proxyUsername: _isRdp || _proxyType == ProxyType.none + ? null + : (_proxyUsernameCtrl.text.trim().isEmpty + ? null + : _proxyUsernameCtrl.text.trim()), +``` + +(e) Persist the proxy password in `_save`, right after the host is built and before/after `await widget.onSave(host, _passwordCtrl.text);` (around line 257): +```dart + await context.read().saveProxyPassword( + host.id, + (_isRdp || _proxyType == ProxyType.none) + ? '' + : _proxyPasswordCtrl.text); +``` +(empty string deletes the secret.) + +(f) Extend `_PanelField` (class near line ~1266) to allow a key and a numeric keyboard — backward-compatible (existing callers pass neither): +```dart +class _PanelField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final IconData icon; + final TextInputType? keyboardType; + const _PanelField({ + super.key, + required this.controller, + required this.hint, + required this.icon, + this.keyboardType, + }); +``` +and in its `TextFormField`, add `keyboardType: keyboardType,` next to `controller:`. + +(g) UI — add a PROXY section inside the SSH-only `if (!_isRdp) ...[` block (e.g. just after the CONNECTION CHAIN section, before SFTP MODE). Note `_PanelField` requires an `icon`; the obscured password uses the existing `_PasswordField` (`controller`/`obscure`/`onToggle`): +```dart + const SizedBox(height: 16), + _sectionLabel('PROXY'), + const SizedBox(height: 6), + _Card(children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + child: Row(children: [ + const Text('Type', + style: TextStyle( + color: AppColors.textSecondary, fontSize: 13)), + const SizedBox(width: 12), + DropdownButton( + key: const ValueKey('proxy-type-dropdown'), + value: _proxyType, + dropdownColor: AppColors.surface, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem( + value: ProxyType.none, child: Text('None')), + DropdownMenuItem( + value: ProxyType.http, + child: Text('HTTP CONNECT')), + DropdownMenuItem( + value: ProxyType.socks5, child: Text('SOCKS5')), + ], + onChanged: (v) => + setState(() => _proxyType = v ?? ProxyType.none), + ), + ]), + ), + if (_proxyType != ProxyType.none) ...[ + _PanelField( + key: const ValueKey('proxy-host-field'), + controller: _proxyHostCtrl, + hint: 'Proxy host', + icon: Icons.dns, + ), + _PanelField( + key: const ValueKey('proxy-port-field'), + controller: _proxyPortCtrl, + hint: 'Proxy port', + icon: Icons.numbers, + keyboardType: TextInputType.number, + ), + _PanelField( + controller: _proxyUsernameCtrl, + hint: 'Proxy username (optional)', + icon: Icons.person_outline, + ), + _PasswordField( + controller: _proxyPasswordCtrl, + obscure: _obscureProxyPassword, + onToggle: () => setState( + () => _obscureProxyPassword = !_obscureProxyPassword), + ), + ], + ]), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/widgets/host_detail_panel_proxy_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/thangnguyen/Projects/Personal/yourssh +git add app/lib/widgets/host_detail_panel.dart app/test/widgets/host_detail_panel_proxy_test.dart +git commit -m "feat(proxy): per-host PROXY section in the host panel" +``` + +--- + +### Task 8: Verify whole feature + +**Files:** none (verification + wrap-up) + +- [ ] **Step 1: Static analysis** + +Run: `flutter analyze lib/models/proxy_settings.dart lib/services/proxy_handshake.dart lib/services/connection_proxy.dart lib/models/host.dart lib/services/ssh_service.dart lib/widgets/host_detail_panel.dart` +Expected: No issues found. + +- [ ] **Step 2: Run the connection-proxy suite together** + +Run: +```bash +flutter test \ + test/models/proxy_settings_test.dart \ + test/services/proxy_handshake_http_test.dart \ + test/services/proxy_handshake_socks5_test.dart \ + test/services/connection_proxy_test.dart \ + test/models/host_proxy_test.dart \ + test/services/ssh_service_proxy_test.dart \ + test/widgets/host_detail_panel_proxy_test.dart +``` +Expected: all PASS. + +- [ ] **Step 3: Regression — model + host panel suites** + +Run: +```bash +flutter test test/models/ test/widgets/host_detail_panel_agent_forwarding_test.dart test/widgets/host_detail_panel_osc52_test.dart +``` +Expected: PASS (the new Host fields and panel section must not break existing round-trip / panel tests). + +- [ ] **Step 4: Manual smoke (optional, needs a proxy + SSH host)** + +Run a local SOCKS5 proxy (e.g. `ssh -D 1080 somehost` or `microsocks`), set a host's proxy to SOCKS5 `127.0.0.1:1080`, connect, and confirm the session opens through the proxy. Repeat with an HTTP CONNECT proxy (e.g. `tinyproxy`). + +- [ ] **Step 5: Final docs** + +At release time, move the "Connection proxy support" bullet from P1 (Security & identity) to "Already shipped" in `docs/roadmap.md` with the shipping version, and add user-facing notes to `docs/wiki/`. (Defer version bump / CHANGELOG to the release checklist.) + +--- + +## Self-review + +**Spec coverage:** +- Both proxy types → Tasks 2 (HTTP) + 3 (SOCKS5). ✓ +- Optional auth (HTTP Basic, SOCKS5 RFC 1929) → Task 2/3 tests assert headers/bytes. ✓ +- Proxy password in secure storage (`proxy_pw_`) → Task 6 helpers; Task 7 persists. ✓ +- Local-originated scope (direct + hop0 + testConnection) → Task 6 wires lines 248/330/521. ✓ +- SOCKS5 remote DNS (ATYP 0x03 domain) → Task 3 implementation + byte assertion. ✓ +- Leftover preservation (no lost SSH banner bytes) → ByteReader/`_ProxiedSocket` + Task 2/4 tests. ✓ +- Host fields + sync round-trip + unknown-type fallback → Task 5. ✓ +- Panel section, SSH-only, password persistence → Task 7. ✓ +- Error handling (non-200, SOCKS reply codes, auth fail, early close) → Tasks 2/3 tests + `ProxyException`. ✓ + +**Placeholder scan:** none — every code step has full code, including the `_PanelField` extension (verified against the real constructor: `controller`/`hint`/`icon` required) and the `_PasswordField` reuse for the obscured proxy password. + +**Type consistency:** `ProxyType`/`ProxySettings`, `ProxyException`, `ByteReader` (`readExactly`/`readUntil`/`takeLeftover`/`release`/`destroy`), `httpConnectHandshake`/`socks5Handshake` (return `Future`), `ConnectionProxy.connect` (named params `settings`/`targetHost`/`targetPort`/`timeout`), `SshService.directDialer`/`proxyDialer`/`localDial`/`loadProxyPassword`/`saveProxyPassword`, `Host.proxyType`/`proxyHost`/`proxyPort`/`proxyUsername` — consistent across Tasks 1-7. Panel save button text `SAVE ONLY` matches the existing harness. ✓ +``` From 4fd11736d79c5810c910e20bc8b140019cafc2a7 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:28:24 +0700 Subject: [PATCH 3/9] feat(proxy): ProxyType enum + ProxySettings value --- app/lib/models/proxy_settings.dart | 17 +++++++++++++++++ app/test/models/proxy_settings_test.dart | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 app/lib/models/proxy_settings.dart create mode 100644 app/test/models/proxy_settings_test.dart diff --git a/app/lib/models/proxy_settings.dart b/app/lib/models/proxy_settings.dart new file mode 100644 index 00000000..8ab9901c --- /dev/null +++ b/app/lib/models/proxy_settings.dart @@ -0,0 +1,17 @@ +enum ProxyType { none, http, socks5 } + +/// Runtime proxy parameters resolved from a Host plus its stored password. +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + const ProxySettings({ + required this.type, + required this.host, + required this.port, + this.username, + this.password, + }); +} diff --git a/app/test/models/proxy_settings_test.dart b/app/test/models/proxy_settings_test.dart new file mode 100644 index 00000000..2a6da7c8 --- /dev/null +++ b/app/test/models/proxy_settings_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/proxy_settings.dart'; + +void main() { + test('ProxyType has none/http/socks5', () { + expect(ProxyType.values, [ProxyType.none, ProxyType.http, ProxyType.socks5]); + }); + + test('ProxySettings holds its fields', () { + const s = ProxySettings( + type: ProxyType.socks5, host: 'p', port: 1080, username: 'u', password: 'x'); + expect(s.type, ProxyType.socks5); + expect(s.host, 'p'); + expect(s.port, 1080); + expect(s.username, 'u'); + expect(s.password, 'x'); + }); +} From f545a1dfb4e083cdcacf9df5ea0862571ae1221f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:29:18 +0700 Subject: [PATCH 4/9] feat(proxy): ByteReader + HTTP CONNECT handshake --- app/lib/services/proxy_handshake.dart | 175 ++++++++++++++++++ .../services/proxy_handshake_http_test.dart | 61 ++++++ 2 files changed, 236 insertions(+) create mode 100644 app/lib/services/proxy_handshake.dart create mode 100644 app/test/services/proxy_handshake_http_test.dart diff --git a/app/lib/services/proxy_handshake.dart b/app/lib/services/proxy_handshake.dart new file mode 100644 index 00000000..36934cf3 --- /dev/null +++ b/app/lib/services/proxy_handshake.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +/// Raised for any proxy handshake failure (refusal, auth, malformed reply, +/// early close). +class ProxyException implements Exception { + final String message; + const ProxyException(this.message); + @override + String toString() => 'ProxyException: $message'; +} + +/// Buffered byte reader over a socket stream. Serves [readExactly]/[readUntil] +/// during the handshake; afterwards [takeLeftover] returns the buffered tail +/// and [release] hands the live remainder out as a stream. Single pending read +/// at a time (handshakes are sequential). +class ByteReader { + ByteReader(Stream stream) { + _sub = stream.listen(_onData, onError: _onError, onDone: _onDone); + } + + late final StreamSubscription _sub; + final List _buf = []; + bool _done = false; + Object? _error; + void Function()? _wake; + StreamController? _out; + + void _onData(Uint8List chunk) { + if (_out != null) { + _out!.add(chunk); + return; + } + _buf.addAll(chunk); + _wake?.call(); + } + + void _onError(Object e, StackTrace st) { + if (_out != null) { + _out!.addError(e, st); + return; + } + _error = e; + _wake?.call(); + } + + void _onDone() { + if (_out != null) { + _out!.close(); + return; + } + _done = true; + _wake?.call(); + } + + Future readExactly(int n) { + final completer = Completer(); + void attempt() { + if (completer.isCompleted) return; + if (_error != null) { + _wake = null; + completer.completeError(_error!); + } else if (_buf.length >= n) { + final out = Uint8List.fromList(_buf.sublist(0, n)); + _buf.removeRange(0, n); + _wake = null; + completer.complete(out); + } else if (_done) { + _wake = null; + completer.completeError( + const ProxyException('proxy closed connection during handshake')); + } + } + + _wake = attempt; + attempt(); + return completer.future; + } + + Future readUntil(List delimiter) { + final completer = Completer(); + void attempt() { + if (completer.isCompleted) return; + if (_error != null) { + _wake = null; + completer.completeError(_error!); + return; + } + final idx = _indexOf(_buf, delimiter); + if (idx >= 0) { + final end = idx + delimiter.length; + final out = Uint8List.fromList(_buf.sublist(0, end)); + _buf.removeRange(0, end); + _wake = null; + completer.complete(out); + } else if (_done) { + _wake = null; + completer.completeError( + const ProxyException('proxy closed connection during handshake')); + } + } + + _wake = attempt; + attempt(); + return completer.future; + } + + /// The bytes buffered but not consumed by the handshake. + Uint8List takeLeftover() { + final out = Uint8List.fromList(_buf); + _buf.clear(); + return out; + } + + /// Hands the live remainder of the stream out. Call once, after the + /// handshake. The buffer must already be drained via [takeLeftover]. + Stream release() { + final out = StreamController(); + _out = out; + out.onCancel = () => _sub.cancel(); + if (_done) { + out.close(); + } else if (_error != null) { + out.addError(_error!); + } + return out.stream; + } + + Future destroy() => _sub.cancel(); + + static int _indexOf(List hay, List needle) { + for (var i = 0; i + needle.length <= hay.length; i++) { + var match = true; + for (var j = 0; j < needle.length; j++) { + if (hay[i + j] != needle[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; + } +} + +/// HTTP CONNECT handshake. Writes the request (optional Basic auth), reads +/// response headers up to `\r\n\r\n`, requires a 200 status. Returns the +/// bytes already read past the header terminator. +Future httpConnectHandshake( + ByteReader reader, + StreamSink> sink, { + required String targetHost, + required int targetPort, + String? username, + String? password, +}) async { + final req = StringBuffer() + ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') + ..write('Host: $targetHost:$targetPort\r\n'); + if (username != null && username.isNotEmpty) { + final cred = base64.encode(utf8.encode('$username:${password ?? ''}')); + req.write('Proxy-Authorization: Basic $cred\r\n'); + } + req.write('\r\n'); + sink.add(utf8.encode(req.toString())); + + final header = await reader.readUntil(const [13, 10, 13, 10]); // \r\n\r\n + final statusLine = ascii.decode(header, allowInvalid: true).split('\r\n').first; + final parts = statusLine.split(' '); + if (parts.length < 2 || parts[1] != '200') { + throw ProxyException('HTTP proxy refused: $statusLine'); + } + return reader.takeLeftover(); +} diff --git a/app/test/services/proxy_handshake_http_test.dart b/app/test/services/proxy_handshake_http_test.dart new file mode 100644 index 00000000..7ac1d68f --- /dev/null +++ b/app/test/services/proxy_handshake_http_test.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/proxy_handshake.dart'; + +/// Minimal StreamSink that records everything written. +class _ListSink implements StreamSink> { + final List bytes = []; + @override + void add(List data) => bytes.addAll(data); + @override + void addError(Object error, [StackTrace? st]) {} + @override + Future addStream(Stream> stream) => stream.forEach(add); + @override + Future close() async {} + @override + Future get done => Future.value(); +} + +void main() { + test('200 success returns leftover bytes', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = httpConnectHandshake(reader, sink, + targetHost: 'example.com', targetPort: 22); + + expect(utf8.decode(sink.bytes), contains('CONNECT example.com:22 HTTP/1.1')); + expect(utf8.decode(sink.bytes), isNot(contains('Proxy-Authorization'))); + + input.add(Uint8List.fromList( + utf8.encode('HTTP/1.1 200 Connection established\r\n\r\nSSHPREFIX'))); + final leftover = await future; + expect(utf8.decode(leftover), 'SSHPREFIX'); + }); + + test('Basic auth header is sent when credentials given', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = httpConnectHandshake(reader, sink, + targetHost: 'h', targetPort: 22, username: 'u', password: 'p'); + final expected = base64.encode(utf8.encode('u:p')); + expect(utf8.decode(sink.bytes), contains('Proxy-Authorization: Basic $expected')); + input.add(Uint8List.fromList(utf8.encode('HTTP/1.1 200 OK\r\n\r\n'))); + await future; + }); + + test('non-200 throws ProxyException', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = httpConnectHandshake(reader, sink, targetHost: 'h', targetPort: 22); + input.add(Uint8List.fromList( + utf8.encode('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'))); + await expectLater(future, throwsA(isA())); + }); +} From c5704efbe3e23fc412e086bf2996b511429cd440 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:30:06 +0700 Subject: [PATCH 5/9] feat(proxy): SOCKS5 handshake with optional RFC 1929 auth --- app/lib/services/proxy_handshake.dart | 85 +++++++++++++++++ .../services/proxy_handshake_socks5_test.dart | 91 +++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 app/test/services/proxy_handshake_socks5_test.dart diff --git a/app/lib/services/proxy_handshake.dart b/app/lib/services/proxy_handshake.dart index 36934cf3..18b5c133 100644 --- a/app/lib/services/proxy_handshake.dart +++ b/app/lib/services/proxy_handshake.dart @@ -173,3 +173,88 @@ Future httpConnectHandshake( } return reader.takeLeftover(); } + +/// SOCKS5 handshake: greeting (no-auth + optional user/pass), optional RFC 1929 +/// auth, then a CONNECT request with the target as a domain address (ATYP 0x03 → +/// remote DNS). Returns any over-read tail. +Future socks5Handshake( + ByteReader reader, + StreamSink> sink, { + required String targetHost, + required int targetPort, + String? username, + String? password, +}) async { + final hasAuth = username != null && username.isNotEmpty; + final methods = hasAuth ? [0x00, 0x02] : [0x00]; + sink.add(Uint8List.fromList([0x05, methods.length, ...methods])); + + final sel = await reader.readExactly(2); + if (sel[0] != 0x05) throw const ProxyException('not a SOCKS5 proxy'); + final method = sel[1]; + if (method == 0xFF) { + throw const ProxyException('SOCKS5 proxy rejected all auth methods'); + } + if (method == 0x02) { + if (!hasAuth) { + throw const ProxyException('SOCKS5 proxy requires username/password'); + } + final u = utf8.encode(username); + final p = utf8.encode(password ?? ''); + sink.add(Uint8List.fromList([0x01, u.length, ...u, p.length, ...p])); + final authResp = await reader.readExactly(2); + if (authResp[1] != 0x00) { + throw const ProxyException('SOCKS5 authentication failed'); + } + } else if (method != 0x00) { + throw ProxyException('SOCKS5 proxy selected unsupported method $method'); + } + + final hostBytes = utf8.encode(targetHost); + if (hostBytes.length > 255) { + throw const ProxyException('hostname too long for SOCKS5'); + } + sink.add(Uint8List.fromList([ + 0x05, 0x01, 0x00, 0x03, hostBytes.length, ...hostBytes, + (targetPort >> 8) & 0xff, targetPort & 0xff, + ])); + + final head = await reader.readExactly(4); // VER, REP, RSV, ATYP + if (head[1] != 0x00) throw ProxyException(_socksReplyMessage(head[1])); + final atyp = head[3]; + int addrLen; + if (atyp == 0x01) { + addrLen = 4; + } else if (atyp == 0x04) { + addrLen = 16; + } else if (atyp == 0x03) { + addrLen = (await reader.readExactly(1))[0]; + } else { + throw ProxyException('SOCKS5 unsupported address type $atyp'); + } + await reader.readExactly(addrLen + 2); // bound address + port + return reader.takeLeftover(); +} + +String _socksReplyMessage(int rep) { + switch (rep) { + case 0x01: + return 'SOCKS5 general failure'; + case 0x02: + return 'SOCKS5 connection not allowed by ruleset'; + case 0x03: + return 'SOCKS5 network unreachable'; + case 0x04: + return 'SOCKS5 host unreachable'; + case 0x05: + return 'SOCKS5 connection refused'; + case 0x06: + return 'SOCKS5 TTL expired'; + case 0x07: + return 'SOCKS5 command not supported'; + case 0x08: + return 'SOCKS5 address type not supported'; + default: + return 'SOCKS5 failed (code $rep)'; + } +} diff --git a/app/test/services/proxy_handshake_socks5_test.dart b/app/test/services/proxy_handshake_socks5_test.dart new file mode 100644 index 00000000..97ea5d07 --- /dev/null +++ b/app/test/services/proxy_handshake_socks5_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/proxy_handshake.dart'; + +class _ListSink implements StreamSink> { + final List bytes = []; + @override + void add(List data) => bytes.addAll(data); + @override + void addError(Object error, [StackTrace? st]) {} + @override + Future addStream(Stream> stream) => stream.forEach(add); + @override + Future close() async {} + @override + Future get done => Future.value(); +} + +void main() { + test('no-auth path: greeting, CONNECT with domain ATYP, success', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, + targetHost: 'host.internal', targetPort: 2222); + + // greeting: VER=5, NMETHODS=1, METHOD=0x00 + expect(sink.bytes.sublist(0, 3), [0x05, 0x01, 0x00]); + input.add(Uint8List.fromList([0x05, 0x00])); // select no-auth + + await Future.delayed(Duration.zero); // let CONNECT be written + final host = 'host.internal'.codeUnits; + final expected = [0x05, 0x01, 0x00, 0x03, host.length, ...host, 0x08, 0xAE]; + expect(sink.bytes.sublist(3), expected); + + // reply: VER, REP=0, RSV, ATYP=1 (ipv4), 4 addr, 2 port, then 'SS' leftover + input.add(Uint8List.fromList( + [0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0x53, 0x53])); + final leftover = await future; + expect(leftover, [0x53, 0x53]); + }); + + test('user/pass path: RFC 1929 auth then success', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, + targetHost: 'h', targetPort: 22, username: 'u', password: 'p'); + + expect(sink.bytes.sublist(0, 4), [0x05, 0x02, 0x00, 0x02]); + input.add(Uint8List.fromList([0x05, 0x02])); // select user/pass + + await Future.delayed(Duration.zero); + const authStart = 4; + expect(sink.bytes.sublist(authStart, authStart + 5), + [0x01, 0x01, 0x75 /* u */, 0x01, 0x70 /* p */]); + input.add(Uint8List.fromList([0x01, 0x00])); // auth success + + await Future.delayed(Duration.zero); + input.add(Uint8List.fromList([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + await future; // completes without throwing + }); + + test('reply 0x05 maps to connection refused', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, targetHost: 'h', targetPort: 22); + input.add(Uint8List.fromList([0x05, 0x00])); + await Future.delayed(Duration.zero); + input.add(Uint8List.fromList([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + await expectLater( + future, + throwsA(isA().having( + (e) => e.message, 'message', contains('refused')))); + }); + + test('auth failure throws', () async { + final input = StreamController(); + final sink = _ListSink(); + final reader = ByteReader(input.stream); + final future = socks5Handshake(reader, sink, + targetHost: 'h', targetPort: 22, username: 'u', password: 'bad'); + input.add(Uint8List.fromList([0x05, 0x02])); + await Future.delayed(Duration.zero); + input.add(Uint8List.fromList([0x01, 0x01])); // auth fail + await expectLater(future, throwsA(isA())); + }); +} From 01e6121652470eec964e49d4afa8d47393b8791f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:30:39 +0700 Subject: [PATCH 6/9] feat(proxy): ConnectionProxy.connect producing a proxied SSHSocket --- app/lib/services/connection_proxy.dart | 85 ++++++++++++++++++++ app/test/services/connection_proxy_test.dart | 30 +++++++ 2 files changed, 115 insertions(+) create mode 100644 app/lib/services/connection_proxy.dart create mode 100644 app/test/services/connection_proxy_test.dart diff --git a/app/lib/services/connection_proxy.dart b/app/lib/services/connection_proxy.dart new file mode 100644 index 00000000..b5057826 --- /dev/null +++ b/app/lib/services/connection_proxy.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +import '../models/proxy_settings.dart'; +import 'proxy_handshake.dart'; + +class ConnectionProxy { + /// Connects to the proxy, runs the type-specific handshake to tunnel to + /// [targetHost]:[targetPort], and returns an [SSHSocket] whose stream + /// re-emits the handshake leftover before live data. When [timeout] is set + /// it bounds the whole connect-plus-handshake. Destroys the socket on any + /// failure. + static Future connect({ + required ProxySettings settings, + required String targetHost, + required int targetPort, + Duration? timeout, + }) { + Future run() async { + final socket = await Socket.connect(settings.host, settings.port); + try { + final reader = ByteReader(socket); + final Uint8List leftover; + switch (settings.type) { + case ProxyType.http: + leftover = await httpConnectHandshake(reader, socket, + targetHost: targetHost, + targetPort: targetPort, + username: settings.username, + password: settings.password); + case ProxyType.socks5: + leftover = await socks5Handshake(reader, socket, + targetHost: targetHost, + targetPort: targetPort, + username: settings.username, + password: settings.password); + case ProxyType.none: + throw const ProxyException( + 'ConnectionProxy.connect called with ProxyType.none'); + } + return _ProxiedSocket(socket, leftover, reader.release()); + } catch (_) { + socket.destroy(); + rethrow; + } + } + + final fut = run(); + return timeout == null ? fut : fut.timeout(timeout); + } +} + +class _ProxiedSocket implements SSHSocket { + _ProxiedSocket(this._socket, Uint8List leftover, Stream live) + : _stream = _prepend(leftover, live); + + final Socket _socket; + final Stream _stream; + + static Stream _prepend( + Uint8List head, Stream tail) async* { + if (head.isNotEmpty) yield head; + yield* tail; + } + + @override + Stream get stream => _stream; + + @override + StreamSink> get sink => _socket; + + @override + Future get done => _socket.done; + + @override + Future close() async { + await _socket.close(); + } + + @override + void destroy() => _socket.destroy(); +} diff --git a/app/test/services/connection_proxy_test.dart b/app/test/services/connection_proxy_test.dart new file mode 100644 index 00000000..0fcf6366 --- /dev/null +++ b/app/test/services/connection_proxy_test.dart @@ -0,0 +1,30 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/services/proxy_handshake.dart'; + +void main() { + test('release() emits live data; takeLeftover returns buffered tail', () async { + final input = StreamController(); + final reader = ByteReader(input.stream); + + // Simulate a handshake having read up to a point, leaving trailing bytes. + input.add(Uint8List.fromList([1, 2, 3, 4])); + await Future.delayed(Duration.zero); + final firstTwo = await reader.readExactly(2); + expect(firstTwo, [1, 2]); + + final leftover = reader.takeLeftover(); + expect(leftover, [3, 4]); + + final live = []; + final done = Completer(); + reader.release().listen(live.addAll, onDone: done.complete); + input.add(Uint8List.fromList([5, 6])); + await Future.delayed(Duration.zero); + await input.close(); + await done.future; + expect(live, [5, 6]); + }); +} From f58d7542f174c7507345959f6637ae74c0e971de Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:32:17 +0700 Subject: [PATCH 7/9] feat(proxy): add Host proxy fields --- app/lib/models/host.dart | 34 ++++++++++++++++++++ app/test/models/host_proxy_test.dart | 46 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 app/test/models/host_proxy_test.dart diff --git a/app/lib/models/host.dart b/app/lib/models/host.dart index 634406b7..2e743ce3 100644 --- a/app/lib/models/host.dart +++ b/app/lib/models/host.dart @@ -1,5 +1,7 @@ import 'package:uuid/uuid.dart'; +import 'proxy_settings.dart'; + enum AuthType { password, privateKey, certificate, agent } /// How SFTP sessions are started on this host. [normal] requests the @@ -47,6 +49,13 @@ class Host { bool shellIntegration; bool agentForwarding; bool osc52Clipboard; + + // ── Connection proxy (per-host outbound proxy for the first TCP dial) ── + ProxyType proxyType; + String? proxyHost; + int? proxyPort; + String? proxyUsername; + SftpMode sftpMode; String? sftpServerCommand; @@ -85,6 +94,10 @@ class Host { this.shellIntegration = true, this.agentForwarding = false, this.osc52Clipboard = false, + this.proxyType = ProxyType.none, + this.proxyHost, + this.proxyPort, + this.proxyUsername, this.sftpMode = SftpMode.normal, this.sftpServerCommand, this.protocol = HostProtocol.ssh, @@ -136,6 +149,10 @@ class Host { 'shellIntegration': shellIntegration, 'agentForwarding': agentForwarding, 'osc52Clipboard': osc52Clipboard, + 'proxyType': proxyType.name, + 'proxyHost': proxyHost, + 'proxyPort': proxyPort, + 'proxyUsername': proxyUsername, 'sftpMode': sftpMode.name, 'sftpServerCommand': sftpServerCommand, 'protocol': protocol.name, @@ -190,6 +207,11 @@ class Host { final name = json['rdpSecurity'] as String?; return RdpSecurityMode.values.asNameMap()[name] ?? RdpSecurityMode.auto; } + ProxyType parseProxyType() { + final name = json['proxyType'] as String?; + if (name == null) return ProxyType.none; + return ProxyType.values.asNameMap()[name] ?? ProxyType.none; + } Map parseEnvVars() { final raw = json['envVars']; // Malformed/forward-compat values degrade to empty rather than @@ -227,6 +249,10 @@ class Host { shellIntegration: (json['shellIntegration'] as bool?) ?? true, agentForwarding: (json['agentForwarding'] as bool?) ?? false, osc52Clipboard: (json['osc52Clipboard'] as bool?) ?? false, + proxyType: parseProxyType(), + proxyHost: json['proxyHost'] as String?, + proxyPort: (json['proxyPort'] as num?)?.toInt(), + proxyUsername: json['proxyUsername'] as String?, sftpMode: parseSftpMode(), sftpServerCommand: json['sftpServerCommand'] as String?, protocol: parseProtocol(), @@ -258,6 +284,10 @@ class Host { bool? shellIntegration, bool? agentForwarding, bool? osc52Clipboard, + ProxyType? proxyType, + String? proxyHost, + int? proxyPort, + String? proxyUsername, SftpMode? sftpMode, Object? sftpServerCommand = const _Unset(), HostProtocol? protocol, @@ -290,6 +320,10 @@ class Host { shellIntegration: shellIntegration ?? this.shellIntegration, agentForwarding: agentForwarding ?? this.agentForwarding, osc52Clipboard: osc52Clipboard ?? this.osc52Clipboard, + proxyType: proxyType ?? this.proxyType, + proxyHost: proxyHost ?? this.proxyHost, + proxyPort: proxyPort ?? this.proxyPort, + proxyUsername: proxyUsername ?? this.proxyUsername, sftpMode: sftpMode ?? this.sftpMode, sftpServerCommand: sftpServerCommand is _Unset ? this.sftpServerCommand diff --git a/app/test/models/host_proxy_test.dart b/app/test/models/host_proxy_test.dart new file mode 100644 index 00000000..2446aef2 --- /dev/null +++ b/app/test/models/host_proxy_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/proxy_settings.dart'; + +void main() { + group('Host proxy fields', () { + test('defaults: none type, null host/port/username', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + expect(h.proxyType, ProxyType.none); + expect(h.proxyHost, isNull); + expect(h.proxyPort, isNull); + expect(h.proxyUsername, isNull); + }); + + test('round-trips through toJson/fromJson', () { + final h = Host( + label: 'a', + host: 'h', + username: 'u', + proxyType: ProxyType.http, + proxyHost: 'proxy', + proxyPort: 8080, + proxyUsername: 'pu'); + final back = Host.fromJson(h.toJson()); + expect(back.proxyType, ProxyType.http); + expect(back.proxyHost, 'proxy'); + expect(back.proxyPort, 8080); + expect(back.proxyUsername, 'pu'); + }); + + test('unknown proxyType string falls back to none', () { + final json = Host(label: 'a', host: 'h', username: 'u').toJson() + ..['proxyType'] = 'bogus'; + expect(Host.fromJson(json).proxyType, ProxyType.none); + }); + + test('copyWith overrides proxy fields', () { + final h = Host(label: 'a', host: 'h', username: 'u'); + final c = h.copyWith( + proxyType: ProxyType.socks5, proxyHost: 'p', proxyPort: 1080); + expect(c.proxyType, ProxyType.socks5); + expect(c.proxyHost, 'p'); + expect(c.proxyPort, 1080); + }); + }); +} From 5de272f7dd09980dda179533cb2ffb737a0a20bc Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:33:40 +0700 Subject: [PATCH 8/9] feat(proxy): route local-originated dials through the configured proxy --- app/lib/services/ssh_service.dart | 61 ++++++++++++- app/test/services/ssh_service_proxy_test.dart | 85 +++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 app/test/services/ssh_service_proxy_test.dart diff --git a/app/lib/services/ssh_service.dart b/app/lib/services/ssh_service.dart index 5a923316..0648b35d 100644 --- a/app/lib/services/ssh_service.dart +++ b/app/lib/services/ssh_service.dart @@ -9,9 +9,12 @@ import 'osc52_clipboard.dart'; import '../providers/shell_integration_provider.dart'; import '../models/agent_forwarding_state.dart'; import '../models/host.dart'; +import '../models/proxy_settings.dart'; import '../models/ssh_key.dart'; import '../models/ssh_session.dart'; import 'certificate_key_pair.dart'; +import 'connection_proxy.dart'; +import 'proxy_handshake.dart'; import 'injection_gate.dart'; import '../models/audit_event.dart'; import 'audit_service.dart'; @@ -134,6 +137,57 @@ class SshService { } } + /// Direct TCP dialer; injectable for tests. Defaults to the native socket. + @visibleForTesting + Future Function(String host, int port, {Duration? timeout}) + directDialer = SSHSocket.connect; + + /// Proxied dialer; injectable for tests. Defaults to [ConnectionProxy.connect]. + @visibleForTesting + Future Function({ + required ProxySettings settings, + required String targetHost, + required int targetPort, + Duration? timeout, + }) proxyDialer = ConnectionProxy.connect; + + Future loadProxyPassword(String hostId) => + _storage.loadGenericSecret('proxy_pw_$hostId'); + + Future saveProxyPassword(String hostId, String password) => + password.isEmpty + ? _storage.deleteGenericSecret('proxy_pw_$hostId') + : _storage.saveGenericSecret('proxy_pw_$hostId', password); + + /// Opens the first local-originated TCP transport for [host], routing through + /// the host's configured proxy when set. Used for a direct connect, the first + /// bastion hop, and test-connection. + @visibleForTesting + Future localDial(Host host, {Duration? timeout}) async { + if (host.proxyType == ProxyType.none) { + return directDialer(host.host, host.port, timeout: timeout); + } + if (host.proxyHost == null || + host.proxyHost!.isEmpty || + host.proxyPort == null) { + throw const ProxyException('Proxy enabled but proxy host/port is missing'); + } + final pw = await loadProxyPassword(host.id); + return proxyDialer( + settings: ProxySettings( + type: host.proxyType, + host: host.proxyHost!, + port: host.proxyPort!, + username: + (host.proxyUsername?.isEmpty ?? true) ? null : host.proxyUsername, + password: (pw?.isEmpty ?? true) ? null : pw, + ), + targetHost: host.host, + targetPort: host.port, + timeout: timeout, + ); + } + /// Test-only: register a (fake) client so shell/exec paths can run without /// a real network connection. @visibleForTesting @@ -245,7 +299,7 @@ class SshService { verifyHopHostKey: verifyHopHostKey); socket = await lastHop.forwardLocal(host.host, host.port); } else { - socket = await SSHSocket.connect(host.host, host.port); + socket = await localDial(host); } client = SSHClient( socket, @@ -327,7 +381,7 @@ class SshService { final resolution = await _resolveIdentities(hop, keyEntry, jumpHostLabel: hop.label); final client = SSHClient( - over ?? await SSHSocket.connect(hop.host, hop.port), + over ?? await localDial(hop), username: hop.username, onPasswordRequest: () => password ?? '', identities: @@ -518,8 +572,7 @@ class SshService { .forwardLocal(host.host, host.port) .timeout(const Duration(seconds: 10)); } else { - socket = await SSHSocket.connect(host.host, host.port) - .timeout(const Duration(seconds: 10)); + socket = await localDial(host, timeout: const Duration(seconds: 10)); } final resolution = await _resolveIdentities(host, keyEntry); diff --git a/app/test/services/ssh_service_proxy_test.dart b/app/test/services/ssh_service_proxy_test.dart new file mode 100644 index 00000000..df6e0e69 --- /dev/null +++ b/app/test/services/ssh_service_proxy_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/proxy_settings.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +class _FakeSocket implements SSHSocket { + @override + Stream get stream => const Stream.empty(); + @override + StreamSink> get sink => throw UnimplementedError(); + @override + Future get done => Future.value(); + @override + Future close() async {} + @override + void destroy() {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('localDial uses the direct dialer when proxyType is none', () async { + final svc = SshService(StorageService()); + final fake = _FakeSocket(); + String? dialedHost; + int? dialedPort; + svc.directDialer = (h, p, {timeout}) async { + dialedHost = h; + dialedPort = p; + return fake; + }; + svc.proxyDialer = + ({required settings, required targetHost, required targetPort, timeout}) async => + throw StateError('proxy dialer must not be called'); + + final host = Host(label: 'a', host: 'srv', port: 22, username: 'u'); + final s = await svc.localDial(host); + + expect(identical(s, fake), isTrue); + expect(dialedHost, 'srv'); + expect(dialedPort, 22); + }); + + test('localDial uses the proxy dialer with resolved settings', () async { + final svc = SshService(StorageService()); + final host = Host( + label: 'a', + host: 'target', + port: 2222, + username: 'u', + proxyType: ProxyType.socks5, + proxyHost: 'proxy', + proxyPort: 1080, + proxyUsername: 'pu'); + await svc.saveProxyPassword(host.id, 'secret'); + + ProxySettings? got; + String? gotTarget; + int? gotPort; + svc.proxyDialer = + ({required settings, required targetHost, required targetPort, timeout}) async { + got = settings; + gotTarget = targetHost; + gotPort = targetPort; + return _FakeSocket(); + }; + + await svc.localDial(host); + + expect(got!.type, ProxyType.socks5); + expect(got!.host, 'proxy'); + expect(got!.port, 1080); + expect(got!.username, 'pu'); + expect(got!.password, 'secret'); + expect(gotTarget, 'target'); + expect(gotPort, 2222); + }); +} From 3db4d4353884830ab2142196ec0a52b2fbc95221 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 14 Jun 2026 21:43:11 +0700 Subject: [PATCH 9/9] feat(proxy): per-host PROXY section in the host panel --- app/lib/widgets/host_detail_panel.dart | 122 +++++++++++++++++- .../widgets/host_detail_panel_proxy_test.dart | 92 +++++++++++++ 2 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 app/test/widgets/host_detail_panel_proxy_test.dart diff --git a/app/lib/widgets/host_detail_panel.dart b/app/lib/widgets/host_detail_panel.dart index 3b093634..80a0454e 100644 --- a/app/lib/widgets/host_detail_panel.dart +++ b/app/lib/widgets/host_detail_panel.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/host.dart'; +import '../models/proxy_settings.dart'; import '../providers/host_provider.dart'; import '../providers/key_provider.dart'; import '../providers/settings_provider.dart' show kTermTypes; @@ -69,6 +70,12 @@ class _HostDetailPanelState extends State { bool _shellIntegration = true; bool _agentForwarding = false; bool _osc52Clipboard = false; + ProxyType _proxyType = ProxyType.none; + bool _obscureProxyPassword = true; + late final TextEditingController _proxyHostCtrl; + late final TextEditingController _proxyPortCtrl; + late final TextEditingController _proxyUsernameCtrl; + late final TextEditingController _proxyPasswordCtrl; late final TextEditingController _workingDirCtrl; late final TextEditingController _startupSnippetCtrl; late final TextEditingController _fontSizeCtrl; @@ -110,6 +117,12 @@ class _HostDetailPanelState extends State { _shellIntegration = h?.shellIntegration ?? true; _agentForwarding = h?.agentForwarding ?? false; _osc52Clipboard = h?.osc52Clipboard ?? false; + _proxyType = h?.proxyType ?? ProxyType.none; + _proxyHostCtrl = TextEditingController(text: h?.proxyHost ?? ''); + _proxyPortCtrl = + TextEditingController(text: h?.proxyPort?.toString() ?? ''); + _proxyUsernameCtrl = TextEditingController(text: h?.proxyUsername ?? ''); + _proxyPasswordCtrl = TextEditingController(); _workingDirCtrl = TextEditingController(text: h?.workingDir ?? ''); _startupSnippetCtrl = TextEditingController(text: h?.startupSnippet ?? ''); _fontSizeCtrl = TextEditingController(text: _fmtFontSize(h?.fontSize)); @@ -129,16 +142,29 @@ class _HostDetailPanelState extends State { for (final c in [_hostCtrl, _portCtrl, _usernameCtrl, _passwordCtrl]) { c.addListener(_clearTestResult); } - if (h != null && _authType == AuthType.password) { + if (h != null && + (_authType == AuthType.password || _proxyType != ProxyType.none)) { WidgetsBinding.instance.addPostFrameCallback((_) => _loadExistingPassword(h.id)); } } Future _loadExistingPassword(String hostId) async { if (!mounted) return; - final pw = await context.read().loadPassword(hostId); - if (mounted && pw != null && pw.isNotEmpty && _passwordCtrl.text.isEmpty) { - setState(() => _passwordCtrl.text = pw); + final ssh = context.read(); + if (_authType == AuthType.password) { + final pw = await ssh.loadPassword(hostId); + if (mounted && pw != null && pw.isNotEmpty && _passwordCtrl.text.isEmpty) { + setState(() => _passwordCtrl.text = pw); + } + } + if (_proxyType != ProxyType.none) { + final proxyPw = await ssh.loadProxyPassword(hostId); + if (mounted && + proxyPw != null && + proxyPw.isNotEmpty && + _proxyPasswordCtrl.text.isEmpty) { + setState(() => _proxyPasswordCtrl.text = proxyPw); + } } } @@ -196,7 +222,8 @@ class _HostDetailPanelState extends State { for (final c in [ _hostCtrl, _labelCtrl, _groupCtrl, _tagsCtrl, _portCtrl, _usernameCtrl, _passwordCtrl, _sftpCommand, _workingDirCtrl, _startupSnippetCtrl, - _fontSizeCtrl, _domainCtrl, + _fontSizeCtrl, _domainCtrl, _proxyHostCtrl, _proxyPortCtrl, + _proxyUsernameCtrl, _proxyPasswordCtrl, ]) { c.dispose(); } @@ -232,6 +259,18 @@ class _HostDetailPanelState extends State { shellIntegration: _shellIntegration, agentForwarding: !_isRdp && _agentForwarding, osc52Clipboard: !_isRdp && _osc52Clipboard, + proxyType: _isRdp ? ProxyType.none : _proxyType, + proxyHost: _isRdp || _proxyType == ProxyType.none + ? null + : _proxyHostCtrl.text.trim(), + proxyPort: _isRdp || _proxyType == ProxyType.none + ? null + : int.tryParse(_proxyPortCtrl.text.trim()), + proxyUsername: _isRdp || + _proxyType == ProxyType.none || + _proxyUsernameCtrl.text.trim().isEmpty + ? null + : _proxyUsernameCtrl.text.trim(), jumpHostIds: _jumpHostIds, sftpMode: _isRdp ? SftpMode.normal : _sftpMode, sftpServerCommand: !_isRdp && _sftpMode == SftpMode.custom @@ -253,7 +292,11 @@ class _HostDetailPanelState extends State { termType: _templateTermType, tmuxOverride: _tmuxOverride, ); + final ssh = context.read(); try { + if (!_isRdp && _proxyType != ProxyType.none) { + await ssh.saveProxyPassword(host.id, _proxyPasswordCtrl.text); + } await widget.onSave(host, _passwordCtrl.text); if (mounted) widget.onClose(); } finally { @@ -661,6 +704,65 @@ class _HostDetailPanelState extends State { ); }), + const SizedBox(height: 16), + _sectionLabel('PROXY'), + const SizedBox(height: 6), + _Card(children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + child: Row(children: [ + const Text('Type', + style: TextStyle( + color: AppColors.textSecondary, fontSize: 13)), + const SizedBox(width: 12), + DropdownButton( + key: const ValueKey('proxy-type-dropdown'), + value: _proxyType, + dropdownColor: AppColors.card, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem( + value: ProxyType.none, child: Text('None')), + DropdownMenuItem( + value: ProxyType.http, + child: Text('HTTP CONNECT')), + DropdownMenuItem( + value: ProxyType.socks5, child: Text('SOCKS5')), + ], + onChanged: (v) => + setState(() => _proxyType = v ?? ProxyType.none), + ), + ]), + ), + if (_proxyType != ProxyType.none) ...[ + _PanelField( + key: const ValueKey('proxy-host-field'), + controller: _proxyHostCtrl, + hint: 'Proxy host', + icon: Icons.dns, + ), + _PanelField( + key: const ValueKey('proxy-port-field'), + controller: _proxyPortCtrl, + hint: 'Proxy port', + icon: Icons.numbers, + keyboardType: TextInputType.number, + ), + _PanelField( + controller: _proxyUsernameCtrl, + hint: 'Proxy username (optional)', + icon: Icons.person_outline, + ), + _PasswordField( + controller: _proxyPasswordCtrl, + obscure: _obscureProxyPassword, + onToggle: () => setState(() => + _obscureProxyPassword = !_obscureProxyPassword), + ), + ], + ]), + const SizedBox(height: 16), _sectionLabel('SFTP MODE'), const SizedBox(height: 6), @@ -1267,7 +1369,14 @@ class _PanelField extends StatelessWidget { final TextEditingController controller; final String hint; final IconData icon; - const _PanelField({required this.controller, required this.hint, required this.icon}); + final TextInputType? keyboardType; + const _PanelField({ + super.key, + required this.controller, + required this.hint, + required this.icon, + this.keyboardType, + }); @override Widget build(BuildContext context) { @@ -1280,6 +1389,7 @@ class _PanelField extends StatelessWidget { Expanded( child: TextFormField( controller: controller, + keyboardType: keyboardType, style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), decoration: InputDecoration( hintText: hint, diff --git a/app/test/widgets/host_detail_panel_proxy_test.dart b/app/test/widgets/host_detail_panel_proxy_test.dart new file mode 100644 index 00000000..8ed2f14d --- /dev/null +++ b/app/test/widgets/host_detail_panel_proxy_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/proxy_settings.dart'; +import 'package:yourssh/providers/host_provider.dart'; +import 'package:yourssh/providers/key_provider.dart'; +import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/host_detail_panel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + SharedPreferences.setMockInitialValues({}); + // Mock flutter_secure_storage so proxy-password persistence resolves + // immediately instead of awaiting a platform reply that never arrives + // under pumpAndSettle. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => call.method == 'readAll' ? {} : null, + ); + }); + + Host? saved; + + Future pumpPanel(WidgetTester tester, {Host? existing}) async { + saved = null; + await tester.binding.setSurfaceSize(const Size(500, 2800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => KeyProvider()), + ChangeNotifierProvider( + create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), + ], + child: MaterialApp( + home: Scaffold( + body: HostDetailPanel( + existing: existing, + agentProbe: () async => const AgentProbeSystem(1), + onClose: () {}, + onSave: (host, _) async => saved = host, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('proxy dropdown defaults to None and hides fields', (tester) async { + await pumpPanel(tester, existing: Host(label: 's', host: 'h', username: 'u')); + final dropdown = find.byKey(const ValueKey('proxy-type-dropdown')); + await tester.ensureVisible(dropdown); + expect(find.byKey(const ValueKey('proxy-host-field')), findsNothing); + }); + + testWidgets('selecting HTTP reveals host/port and saving round-trips', + (tester) async { + await pumpPanel(tester, existing: Host(label: 's', host: 'h', username: 'u')); + + final dropdown = find.byKey(const ValueKey('proxy-type-dropdown')); + await tester.ensureVisible(dropdown); + await tester.tap(dropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text('HTTP CONNECT').last); + await tester.pumpAndSettle(); + + final hostField = find.byKey(const ValueKey('proxy-host-field')); + await tester.ensureVisible(hostField); + await tester.enterText(hostField, 'proxy.local'); + await tester.enterText( + find.byKey(const ValueKey('proxy-port-field')), '8080'); + + final save = find.text('SAVE ONLY'); + await tester.ensureVisible(save); + await tester.tap(save); + await tester.pumpAndSettle(); + + expect(saved, isNotNull); + expect(saved!.proxyType, ProxyType.http); + expect(saved!.proxyHost, 'proxy.local'); + expect(saved!.proxyPort, 8080); + }); +}