mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Connect to hosts reachable only through a subprocess transport — e.g. a
Cloudflare tunnel:
Host homelab
HostName lab.example.com
ProxyCommand cloudflared access ssh --hostname %h
IdentityAgent ~/.1password/agent.sock
- SshConfigReader parses `ProxyCommand` (raw, %-tokens preserved) and
`IdentityAgent` (tilde-expanded), incl. Host * defaults + host
override.
- New ProxyCommandTunnel: `expandProxyCommandTokens` (%h/%p/%r/%%) +
`createProxyCommandTunnel`, which spawns the command through the shell
and bridges its stdio as a Duplex, with backpressure and child
teardown on close. ssh2 takes it as `sock` (same seam as the jump
tunnel).
- SshProfile gains `proxyCommand`; SftpClient.connect opens the tunnel
when set (jumpHost takes precedence — the two are mutually exclusive
in OpenSSH).
- ProfileForm: "Import from SSH config" maps ProxyCommand → proxyCommand
and IdentityAgent → agentSocket(+agent auth); plus a manual
"Proxy command" text field.
Desktop only (child_process). The SshConfigReader IdentityAgent test
compares via path.join so it's OS-agnostic.
Refs #430
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { readSshConfig } from '../src/ssh/SshConfigReader';
|
|
|
|
/**
|
|
* Each test writes a small ssh_config-like file to a temp dir and
|
|
* points readSshConfig at it; that's faster and more robust than
|
|
* mocking `fs`. Files are cleaned up in afterEach.
|
|
*/
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-cfg-test-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function write(content: string): string {
|
|
const p = path.join(tmpDir, 'config');
|
|
fs.writeFileSync(p, content, 'utf8');
|
|
return p;
|
|
}
|
|
|
|
describe('readSshConfig: missing / empty file', () => {
|
|
it('returns [] when the file does not exist', () => {
|
|
expect(readSshConfig(path.join(tmpDir, 'nope'))).toEqual([]);
|
|
});
|
|
|
|
it('returns [] for an empty file', () => {
|
|
const p = write('');
|
|
expect(readSshConfig(p)).toEqual([]);
|
|
});
|
|
|
|
it('skips comments and blank lines', () => {
|
|
const p = write([
|
|
'# this is a comment',
|
|
'',
|
|
'Host foo',
|
|
' HostName foo.example.com',
|
|
'',
|
|
'# another comment',
|
|
].join('\n'));
|
|
const entries = readSshConfig(p);
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].hostname).toBe('foo.example.com');
|
|
});
|
|
});
|
|
|
|
describe('readSshConfig: Port edge cases', () => {
|
|
it('falls back port to 22 when Port value is non-numeric', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' Port notanumber',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)[0].port).toBe(22);
|
|
});
|
|
|
|
it('applies a Port set in Host * defaults to hosts that do not override it', () => {
|
|
const p = write([
|
|
'Host *',
|
|
' Port 2222',
|
|
'',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)[0].port).toBe(2222);
|
|
});
|
|
});
|
|
|
|
describe('readSshConfig: single host', () => {
|
|
it('reads HostName / User / Port / IdentityFile', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' User alice',
|
|
' Port 2222',
|
|
' IdentityFile /home/alice/.ssh/id_ed25519',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)).toEqual([
|
|
{
|
|
alias: 'srv',
|
|
hostname: 'srv.example.com',
|
|
user: 'alice',
|
|
port: 2222,
|
|
identityFile: '/home/alice/.ssh/id_ed25519',
|
|
proxyJump: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('expands ~ in IdentityFile', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' IdentityFile ~/.ssh/id_ed25519',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.identityFile).toBe(path.join(os.homedir(), '.ssh', 'id_ed25519'));
|
|
});
|
|
|
|
it('falls back hostname → alias when HostName is missing', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' User alice',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)[0].hostname).toBe('srv');
|
|
});
|
|
|
|
it('falls back port → 22 and user → OS username', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.port).toBe(22);
|
|
expect(entry.user).toBeTruthy(); // os.userInfo().username; non-empty everywhere we run
|
|
});
|
|
});
|
|
|
|
describe('readSshConfig: multi-pattern Host line', () => {
|
|
it('expands `Host foo bar` to two entries with shared body', () => {
|
|
const p = write([
|
|
'Host foo bar',
|
|
' HostName 10.0.0.1',
|
|
' User shared',
|
|
].join('\n'));
|
|
const entries = readSshConfig(p);
|
|
expect(entries.map(e => e.alias).sort()).toEqual(['bar', 'foo']);
|
|
for (const e of entries) {
|
|
expect(e.hostname).toBe('10.0.0.1');
|
|
expect(e.user).toBe('shared');
|
|
}
|
|
});
|
|
|
|
it('skips wildcard patterns inside a multi-pattern line', () => {
|
|
const p = write([
|
|
'Host explicit *.wild',
|
|
' User mixed',
|
|
].join('\n'));
|
|
const entries = readSshConfig(p);
|
|
expect(entries.map(e => e.alias)).toEqual(['explicit']);
|
|
});
|
|
});
|
|
|
|
describe('readSshConfig: Host * defaults', () => {
|
|
it('applies defaults to non-wildcard entries that don\'t override them', () => {
|
|
const p = write([
|
|
'Host *',
|
|
' User defaultuser',
|
|
' IdentityFile /key',
|
|
'',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.user).toBe('defaultuser');
|
|
expect(entry.identityFile).toBe('/key');
|
|
});
|
|
|
|
it('lets specific Host blocks override defaults', () => {
|
|
const p = write([
|
|
'Host *',
|
|
' User defaultuser',
|
|
'',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' User specific',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)[0].user).toBe('specific');
|
|
});
|
|
|
|
it('does not include the * block itself in the output', () => {
|
|
const p = write([
|
|
'Host *',
|
|
' User defaultuser',
|
|
'',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
].join('\n'));
|
|
expect(readSshConfig(p).map(e => e.alias)).toEqual(['srv']);
|
|
});
|
|
});
|
|
|
|
describe('readSshConfig: ProxyJump', () => {
|
|
it('parses a literal hostname', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' User alice',
|
|
' ProxyJump bastion.example.com',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.proxyJump).toEqual({
|
|
host: 'bastion.example.com',
|
|
port: 22,
|
|
user: 'alice', // inherits from parent host
|
|
identityFile: undefined,
|
|
});
|
|
});
|
|
|
|
it('parses [user@]host[:port]', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' User alice',
|
|
' ProxyJump bob@bastion.example.com:2222',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.proxyJump).toEqual({
|
|
host: 'bastion.example.com',
|
|
port: 2222,
|
|
user: 'bob',
|
|
identityFile: undefined,
|
|
});
|
|
});
|
|
|
|
it('takes only the first hop of a chain', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' ProxyJump first.example.com,second.example.com',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)[0].proxyJump?.host).toBe('first.example.com');
|
|
});
|
|
|
|
it('resolves a referenced Host alias to its HostName/Port/User/IdentityFile', () => {
|
|
const p = write([
|
|
'Host bastion',
|
|
' HostName 10.0.0.99',
|
|
' User bastionuser',
|
|
' Port 2200',
|
|
' IdentityFile /bkey',
|
|
'',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' ProxyJump bastion',
|
|
].join('\n'));
|
|
const srv = readSshConfig(p).find(e => e.alias === 'srv')!;
|
|
expect(srv.proxyJump).toEqual({
|
|
host: '10.0.0.99',
|
|
port: 2200,
|
|
user: 'bastionuser',
|
|
identityFile: '/bkey',
|
|
});
|
|
});
|
|
|
|
it('lets an explicit ProxyJump port override the referenced alias\'s port', () => {
|
|
const p = write([
|
|
'Host bastion',
|
|
' HostName 10.0.0.99',
|
|
' Port 2200',
|
|
'',
|
|
'Host srv',
|
|
' ProxyJump bastion:9999',
|
|
].join('\n'));
|
|
const srv = readSshConfig(p).find(e => e.alias === 'srv')!;
|
|
expect(srv.proxyJump?.port).toBe(9999);
|
|
});
|
|
|
|
it('still works when the referenced alias is declared after the host that uses it', () => {
|
|
const p = write([
|
|
'Host srv',
|
|
' ProxyJump bastion',
|
|
'',
|
|
'Host bastion',
|
|
' HostName 10.0.0.99',
|
|
].join('\n'));
|
|
const srv = readSshConfig(p).find(e => e.alias === 'srv')!;
|
|
expect(srv.proxyJump?.host).toBe('10.0.0.99');
|
|
});
|
|
});
|
|
|
|
// ─── ProxyCommand (#430) ────────────────────────────────────────────────
|
|
//
|
|
// A user behind a Cloudflare tunnel connects via
|
|
// ProxyCommand cloudflared access ssh --hostname %h
|
|
// The reader must surface the raw command (OpenSSH %-tokens left
|
|
// intact for the transport layer to expand at connect time). None of
|
|
// these pass today — the parser has no `proxycommand` case and
|
|
// SshConfigEntry has no `proxyCommand` field.
|
|
describe('readSshConfig: ProxyCommand (#430)', () => {
|
|
it('parses a ProxyCommand and preserves the raw command incl. %-tokens', () => {
|
|
const p = write([
|
|
'Host homelab',
|
|
' HostName lab.example.com',
|
|
' User alice',
|
|
' ProxyCommand cloudflared access ssh --hostname %h',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.alias).toBe('homelab');
|
|
expect(entry.hostname).toBe('lab.example.com');
|
|
expect(entry.proxyCommand).toBe('cloudflared access ssh --hostname %h');
|
|
});
|
|
|
|
it('leaves proxyCommand undefined when the host declares none', () => {
|
|
const p = write(['Host plain', ' HostName plain.example.com'].join('\n'));
|
|
expect(readSshConfig(p)[0].proxyCommand).toBeUndefined();
|
|
});
|
|
|
|
it('applies a ProxyCommand from the Host * defaults block', () => {
|
|
const p = write([
|
|
'Host *',
|
|
' ProxyCommand nc -X connect -x proxy:1080 %h %p',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
].join('\n'));
|
|
expect(readSshConfig(p).find(e => e.alias === 'srv')?.proxyCommand)
|
|
.toBe('nc -X connect -x proxy:1080 %h %p');
|
|
});
|
|
|
|
it('a host-specific ProxyCommand overrides the Host * default', () => {
|
|
const p = write([
|
|
'Host *',
|
|
' ProxyCommand default-proxy %h',
|
|
'Host srv',
|
|
' HostName srv.example.com',
|
|
' ProxyCommand specific-proxy %h %p',
|
|
].join('\n'));
|
|
expect(readSshConfig(p).find(e => e.alias === 'srv')?.proxyCommand)
|
|
.toBe('specific-proxy %h %p');
|
|
});
|
|
|
|
it('preserves spaces in the ProxyCommand value verbatim', () => {
|
|
const p = write([
|
|
'Host q',
|
|
' HostName q.example.com',
|
|
' ProxyCommand ssh -W %h:%p jump.example.com',
|
|
].join('\n'));
|
|
expect(readSshConfig(p)[0].proxyCommand).toBe('ssh -W %h:%p jump.example.com');
|
|
});
|
|
});
|
|
|
|
// ─── IdentityAgent (#430) ───────────────────────────────────────────────
|
|
//
|
|
// The same user authenticates through 1Password's SSH agent socket
|
|
// (`IdentityAgent ~/.1password/agent.sock`). The reader must surface
|
|
// the tilde-expanded socket path so AuthResolver can use it. Fails
|
|
// today — no `identityagent` case, no `identityAgent` field.
|
|
describe('readSshConfig: IdentityAgent (#430)', () => {
|
|
it('parses IdentityAgent and tilde-expands the socket path', () => {
|
|
const p = write([
|
|
'Host onepw',
|
|
' HostName host.example.com',
|
|
' IdentityAgent ~/.1password/agent.sock',
|
|
].join('\n'));
|
|
const entry = readSshConfig(p)[0];
|
|
expect(entry.identityAgent).toBeDefined();
|
|
expect(entry.identityAgent?.startsWith('~')).toBe(false); // tilde expanded
|
|
// Compare via path.join so the separator is OS-agnostic (Windows
|
|
// expands to backslashes).
|
|
expect(entry.identityAgent).toBe(path.join(os.homedir(), '.1password', 'agent.sock'));
|
|
});
|
|
});
|