ledge-server 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +87 -35
  2. package/lib/serve.js +315 -78
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,89 +1,141 @@
1
1
  # ledge-server
2
2
 
3
- The server half of [Ledge](https://github.com/ledgesh/ledge), the macOS notebook for developers and DevOps.
3
+ The server half of [Ledge](https://ledge.sh), the Markdown notebook that runs
4
+ code. Install it on a machine whose notes and shells you want to reach from
5
+ the Ledge app on a Mac or an iPhone. The machine keeps the notes, runs the
6
+ commands, and holds the vault. The app is the window onto it.
4
7
 
5
- Install this on a machine whose notes and shells you want to reach from the Ledge app on your Mac or iPhone. The machine keeps the notes, runs the commands, and holds the vault. Your client keeps nothing but the window.
6
-
7
- The package installs one command, `ledge`. Its server verbs are what the apps run over ssh, and its other verbs are your notes from that machine's own shell.
8
+ The package installs one command, `ledge`. Its server verbs are what the apps
9
+ run over ssh, and its other verbs read the notes from that machine's own
10
+ shell.
8
11
 
9
12
  ## Install
10
13
 
11
- The server runs on [Bun](https://bun.sh), and where Bun goes decides where the server goes: Bun puts global commands beside itself. Installing Bun into `/usr/local` puts both names in `/usr/local/bin`, which is where the short PATH of an ssh command looks.
14
+ Signed in as the account Ledge should use:
12
15
 
13
16
  ```sh
14
- curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
17
+ curl -fsSL https://ledge.sh/server.sh | sh
15
18
  ```
16
19
 
17
- Then the server itself, into the same place:
20
+ This puts the package and a private copy of Bun under `~/.ledge/.server`. It
21
+ needs no `sudo`, opens no port, and starts no service: Ledge starts the server
22
+ over ssh when a device connects, and it exits on its own a minute after the
23
+ last device leaves.
24
+
25
+ Then print a pairing code:
18
26
 
19
27
  ```sh
20
- sudo BUN_INSTALL=/usr/local bun add -g ledge-server
28
+ ledge pair
21
29
  ```
22
30
 
23
- The variable on the second command is not optional. Without it the package installs into the home directory of whoever ran it, where an incoming ssh will not find it.
24
-
25
- macOS and Linux, on arm64 or x64. On Linux the floor is glibc 2.29, which means Debian 11, Ubuntu 20.04, RHEL 9, or anything newer. Alpine and other musl systems are not supported.
31
+ It lists the machine's addresses, with what each one reaches, and asks which
32
+ one the phone should dial. Scan the code with Ledge on a phone, or paste the
33
+ link under it into the Mac app's Add Server form. The code carries the
34
+ address, the account, and the host key.
26
35
 
27
- ## Check that an incoming ssh can find it
36
+ If the machine already has Bun, the same package installs with it. On Linux,
37
+ Bun has to live in `/usr/local` so that an incoming ssh finds both commands:
28
38
 
29
- This is the one step worth not skipping, because Ledge reports what it catches as a server that is not installed: a remote shell that cannot find a command says only that. Ledge starts the server by running `ledge serve` over ssh, and a command run that way gets a short PATH and no shell profile. Both `ledge` and the `bun` its shebang names have to be on that PATH.
39
+ ```sh
40
+ curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash
41
+ sudo BUN_INSTALL=/usr/local bun add -g ledge-server
42
+ ```
30
43
 
31
- From your Mac:
44
+ On a Mac, install Bun for the account and add its directory to `~/.zshenv`,
45
+ which zsh reads for commands run over ssh:
32
46
 
33
47
  ```sh
34
- ssh you@machine 'command -v ledge; command -v bun'
48
+ curl -fsSL https://bun.sh/install | bash
49
+ echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshenv
50
+ source ~/.zshenv
51
+ bun add -g ledge-server
35
52
  ```
36
53
 
37
- Two paths printed means you are done. Nothing printed means the install landed somewhere an incoming ssh does not look, which is what a machine with a per-user Bun on it already gives you. Bun puts global commands beside itself, so `bun pm bin -g` on that machine says where they went, and linking both into a system directory fixes it without reinstalling:
54
+ The server runs on macOS and Linux, arm64 or x64. Linux needs glibc 2.29 or
55
+ newer: Debian 11, Ubuntu 20.04, RHEL 9, or anything later. Alpine and other
56
+ musl systems are not supported.
57
+
58
+ ## Connect
59
+
60
+ In the Ledge app, run "Notes On…" from the command palette, choose Add, and
61
+ give the machine's ssh destination, or paste a pairing code. The app then
62
+ opens the connection with your own ssh credentials by running:
38
63
 
39
64
  ```sh
40
- sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
41
- sudo ln -s "$(command -v bun)" /usr/local/bin/bun
65
+ ssh you@machine 'PATH=$HOME/.ledge/.server/bin:$PATH ledge serve'
42
66
  ```
43
67
 
44
- ## Use
68
+ The machine's own sshd is the only thing listening, and the key or password
69
+ you already use is the credential. Ledge speaks its protocol over ssh's stdin
70
+ and stdout.
45
71
 
46
- You do not normally run this yourself. Add the machine in the Ledge app under Servers, and the app opens a connection with your own ssh credentials:
72
+ To check that an incoming ssh can find the command, run the same thing from
73
+ your Mac with `command -v ledge` in place of `ledge serve`. One path printed
74
+ means the machine is ready. Nothing printed means the install landed
75
+ somewhere ssh does not look, which happens with a `bun add -g` into a
76
+ per-user Bun. Link both commands into a system directory to fix it:
47
77
 
48
78
  ```sh
49
- ssh you@machine ledge serve
79
+ sudo ln -s "$(bun pm bin -g)/ledge" /usr/local/bin/ledge
80
+ sudo ln -s "$(command -v bun)" /usr/local/bin/bun
50
81
  ```
51
82
 
52
- No port is opened and no daemon is installed. Ledge speaks its protocol over ssh's stdin and stdout, so the machine's own sshd is the only thing listening, and the key you already use is the credential.
53
-
54
- The verbs, if you want them:
83
+ ## The verbs
55
84
 
56
85
  | Verb | What it does |
57
86
  | --- | --- |
58
- | `ledge serve` | Move the protocol between stdin, stdout, and this machine's daemon. Starts the daemon if nothing answers. |
87
+ | `ledge serve` | The protocol on stdin and stdout, attached to this machine's daemon. Starts the daemon if nothing answers. What a client runs. |
59
88
  | `ledge daemon` | Be this machine's server. Holds the notes, the shells, and the watchers, and runs until stopped. |
89
+ | `ledge pair` | Print a pairing code for this machine, after asking which of its addresses a phone should dial. `--user`, `--host`, and `--port` override what it describes. |
60
90
  | `ledge backup` | Back this machine up to an S3-compatible bucket: `setup`, `now`, `status`, `snapshots`, `restore`, `paths`, `restic`. |
61
- | `ledge pair` | Print a pairing code a phone scans to add this server. |
62
91
  | `ledge mcp` | The Ledge MCP server on stdin and stdout, for an agent running on this machine. |
63
- | `ledge ls`, `ledge cat`, ... | Notes from this machine's own shell. `ledge help` lists them. |
92
+ | `ledge ls`, `ledge cat`, `ledge search`, ... | Notes from this machine's own shell. `ledge help` lists them all. |
64
93
 
65
- The daemon outlives the connections to it, which is what lets a build keep running after your laptop closes and lets a reconnecting client pick the output back up.
94
+ The daemon outlives the connections to it. A build keeps running after your
95
+ laptop closes, and a reconnecting client picks the output back up.
66
96
 
67
97
  ## Restrict the key
68
98
 
69
- Optional, and worth doing on a server you care about. In that machine's `~/.ssh/authorized_keys`:
99
+ A key in that machine's `~/.ssh/authorized_keys` can be limited to Ledge's
100
+ protocol and nothing else:
70
101
 
71
102
  ```
72
- restrict,command="/usr/local/bin/ledge serve" ssh-ed25519 AAAA... ledge@laptop
103
+ restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
73
104
  ```
74
105
 
75
- That key can then speak Ledge's protocol and nothing else. No shell, no port forwarding, no file transfer.
106
+ That key cannot forward a port, run `scp`, or open a shell. It can still run
107
+ every block in every note, because running them is what the protocol does.
108
+ A phone's key arrives with this prefix already on its line.
76
109
 
77
- An absolute path here settles half of the PATH question above, since sshd runs this string instead of whatever the client asked for. The other half stays: the file it names begins `#!/usr/bin/env bun`, so `bun` still has to be findable.
110
+ Keep your usual key on the machine as well if you also ssh to it from a
111
+ terminal. The restricted line is for Ledge alone.
78
112
 
79
113
  ## Where the data lives
80
114
 
81
- Most of what the server owns sits under one directory: the notes, the workspace registry, the vault, the layout, and the logs. It is `~/.ledge` by default, and `LEDGE_NOTES_ROOT` moves it. Two things live outside it: the profiles, at `~/.config/ledge/profiles`, so credentials stay out of the folder people sync; and any workspace folder attached from elsewhere on the machine.
115
+ The notes, the workspace registry, the vault, the layout, and the logs sit
116
+ under `~/.ledge`. `LEDGE_NOTES_ROOT` moves that directory. Two things live
117
+ outside it: profiles, at `~/.config/ledge/profiles`, so secrets stay out of
118
+ the folder people sync; and any workspace folder attached from elsewhere on
119
+ the machine.
82
120
 
83
121
  ## Back it up
84
122
 
85
- `ledge backup setup` asks for an S3-compatible bucket and its key, then keeps an encrypted copy of all of the above there: every hour while the server is up, and once more before it exits. `ledge backup status` says how it is going, and `ledge backup restore` brings files back. See [Back Up Your Notes to S3](https://github.com/ledgesh/ledge/blob/main/docs/user/21-tutorial-back-up-your-notes-to-s3.md) in the manual.
123
+ `ledge backup setup` asks for an S3-compatible bucket and its key, then keeps
124
+ an encrypted copy of all of the above there: every hour while the server is
125
+ up, and once more before it exits. `ledge backup status` reports on it, and
126
+ `ledge backup restore` brings files back. See
127
+ [Back Up Your Notes to S3](https://ledge.sh/docs/tutorial-back-up-your-notes-to-s3).
128
+
129
+ ## Documentation
130
+
131
+ - [Keep Notes on a Remote Server](https://ledge.sh/docs/keep-notes-on-a-remote-server):
132
+ the reference for connections, sharing a server, and what a dropped
133
+ connection does.
134
+ - [Set Up a Ledge Server](https://ledge.sh/docs/tutorial-set-up-a-ledge-server):
135
+ a fresh Linux VPS, from a new account to a hardened sshd.
136
+ - [Ledge on Your Phone](https://ledge.sh/docs/ledge-on-your-phone): pairing
137
+ and what a phone does with a server.
86
138
 
87
139
  ## License
88
140
 
89
- Apache-2.0
141
+ [Apache-2.0](https://github.com/ledgesh/ledge/blob/main/LICENSE)
package/lib/serve.js CHANGED
@@ -6047,6 +6047,8 @@ A server with no pinned key has no code, since the code names the key. Edit the
6047
6047
 
6048
6048
  Every row in the picker carries three controls: a QR code icon to show its pairing code, a pencil to change it and a bin to remove it. Press \u232B on a focused row to remove it without reaching for either.
6049
6049
 
6050
+ Removing asks first, and the dialog says what goes with the record: the address, the host key you pinned, and the password if that connection uses one. The notes on that machine stay where they are. Adding the server back means comparing its fingerprint again.
6051
+
6050
6052
  Editing opens the same form. A rename, or a change of account on the same machine, saves in one step, because neither changes which machine the pinned key belongs to.
6051
6053
 
6052
6054
  Changing the address to a different machine does not. The button reads "Continue" instead of "Save", Ledge asks that machine for its host key, and you compare the fingerprint again before anything is stored. A key pinned for one machine says nothing about another, and carrying it across would refuse every later connection with a warning about a changed host key.
@@ -6413,13 +6415,13 @@ A pairing code names a server and its host keys, so the phone can add it without
6413
6415
  ledge pair
6414
6416
  \`\`\`
6415
6417
 
6416
- It prints the code as a QR code, then the account, host, port, and host keys it holds, then the same code as a link. It reads the address from your ssh session, or uses the machine's name when there is no session. When the phone cannot reach the server by that name, give the address yourself with \`--host\`. \`ledge pair --help\` lists the other flags.
6418
+ On a terminal, it first lists every address the machine has, with a note on which phones reach each one: its tailnet name and address, the address your ssh session reached, its public address when it runs in a cloud, its other network addresses, and its name. Type a number to pick one, or an address of your own as \`host\` or \`host:port\`, or press Return for the first. It then prints the code as a QR code, then the account, host, port, and host keys it holds, then the same code as a link. Without a terminal, it takes the first address and lists the rest under the code, and \`--host\` names one on the next run. \`ledge pair --help\` lists the other flags.
6417
6419
 
6418
6420
  A Mac that already has the server in its list can show the same code without a terminal: the QR code icon on the server's row in Notes On\u2026 ("Show a pairing code for a server" on [[Keep Notes on a Remote Server]]). The same link pastes into the Mac app's Add Server form ("Add a server from a pairing code" on that page).
6419
6421
 
6420
- The code names one address, and the phone dials exactly that. \`ledge pair\` over ssh names the address your session reached, so a machine you reached over your home network or tailnet gets that address, which works from a phone on the same network or tailnet. A machine behind a router's port forward needs \`--host\` and \`--port\` for the outside address, since the session only knows the inside one. A Mac's code names the address the Mac dials, with the same reach.
6422
+ The code names one address, and the phone dials exactly that, so pick the one the phone reaches from where it will be. A tailnet name works from anywhere the phone is on the tailnet. A home network address works from a phone on that network. A cloud machine's public address works from anywhere, when its sshd is reachable from outside. A machine behind a router's port forward has an outside address no source knows: type it at the menu with its port, or give them with \`--host\` and \`--port\`. A Mac's code names the address the Mac dials, with the same reach.
6421
6423
 
6422
- On the phone, tap Scan a pairing code on the first screen and point the camera at the QR code. Scan it from Ledge rather than the Camera app, which opens the code in Safari. Ledge shows what the code names and connects only when you tap Connect. Choose how to sign in first, the same way as in "Pair by address": with a key, whose line still has to be in the server's \`authorized_keys\`, or with a password. Ledge signs in only if the server offers one of the host keys in the code, so there is no fingerprint to check by eye.
6424
+ On the phone, tap Scan a pairing code on the first screen, or in Add Server\u2026 inside the app ("More than one server" below), and point the camera at the QR code. Scan it from Ledge rather than the Camera app, which opens the code in Safari. Ledge shows what the code names and connects only when you tap Connect. Choose how to sign in first, the same way as in "Pair by address": with a key, whose line still has to be in the server's \`authorized_keys\`, or with a password. Ledge signs in only if the server offers one of the host keys in the code, so there is no fingerprint to check by eye.
6423
6425
 
6424
6426
  The code holds no password and no key. Someone who photographs it learns where the server is and which account to try, and nothing that signs them in.
6425
6427
 
@@ -6497,6 +6499,8 @@ Removing the last server returns the phone to the first screen. Deleting the app
6497
6499
 
6498
6500
  Inside the app the connection bar works as on a Mac: tap it to add, edit, remove, or switch servers, with the same fingerprint step ([[Keep Notes on a Remote Server]]). The form shows the phone's key line where a Mac's shows a key path, with Share Line beside Copy Line.
6499
6501
 
6502
+ Add Server\u2026 starts with Scan a pairing code, where a Mac's form has a field for the pasted link. It opens the camera, then the same "Pair with a server" screen as the first launch, and the app reopens on the new server once you tap Connect there. Cancel returns you to the form, where you can type the address instead. Editing a server has no scan: a code never replaces a host key the phone already has.
6503
+
6500
6504
  A phone and a Mac can be on one server at once. Each keeps its own tabs, and a note's terminal has one owner between them.
6501
6505
 
6502
6506
  ## What a phone does
@@ -11447,7 +11451,7 @@ function createOpLog(opts) {
11447
11451
  }
11448
11452
 
11449
11453
  // src/shared/version.ts
11450
- var BUILD_VERSION = "0.0.1";
11454
+ var BUILD_VERSION = "0.0.2";
11451
11455
 
11452
11456
  // src/bun/daemon.ts
11453
11457
  var SOCKET_PATH = join16(APP_HOME, ".server.sock");
@@ -11713,6 +11717,65 @@ import { randomBytes as randomBytes2 } from "crypto";
11713
11717
  import { homedir as homedir7 } from "os";
11714
11718
  import { join as join19 } from "path";
11715
11719
 
11720
+ // src/bun/ask.ts
11721
+ var reader = null;
11722
+ var pending = "";
11723
+ async function readLine() {
11724
+ reader ??= Bun.stdin.stream().getReader();
11725
+ const decoder2 = new TextDecoder;
11726
+ for (;; ) {
11727
+ const nl = pending.indexOf(`
11728
+ `);
11729
+ if (nl >= 0) {
11730
+ const line = pending.slice(0, nl);
11731
+ pending = pending.slice(nl + 1);
11732
+ return line.replace(/\r$/, "");
11733
+ }
11734
+ const { value, done } = await reader.read();
11735
+ if (done) {
11736
+ const line = pending;
11737
+ pending = "";
11738
+ return line;
11739
+ }
11740
+ pending += decoder2.decode(value, { stream: true });
11741
+ }
11742
+ }
11743
+ async function readHidden() {
11744
+ const stdin = process.stdin;
11745
+ stdin.setRawMode?.(true);
11746
+ reader ??= Bun.stdin.stream().getReader();
11747
+ let line = "";
11748
+ try {
11749
+ for (;; ) {
11750
+ const { value, done } = await reader.read();
11751
+ if (done)
11752
+ return line;
11753
+ for (const byte of value) {
11754
+ if (byte === 3)
11755
+ process.exit(130);
11756
+ if (byte === 13 || byte === 10)
11757
+ return line;
11758
+ if (byte === 127 || byte === 8)
11759
+ line = line.slice(0, -1);
11760
+ else if (byte >= 32)
11761
+ line += String.fromCharCode(byte);
11762
+ }
11763
+ }
11764
+ } finally {
11765
+ stdin.setRawMode?.(false);
11766
+ }
11767
+ }
11768
+ async function ask(question, o = {}) {
11769
+ process.stderr.write(`${question}: `);
11770
+ if (o.hidden && process.stdin.isTTY) {
11771
+ const line = await readHidden();
11772
+ process.stderr.write(`
11773
+ `);
11774
+ return line.trim();
11775
+ }
11776
+ return (await readLine()).trim();
11777
+ }
11778
+
11716
11779
  // src/bun/backup.ts
11717
11780
  import { join as join17 } from "path";
11718
11781
  function backupSet(input) {
@@ -12578,63 +12641,6 @@ function valueOf(args, flag) {
12578
12641
  const at = args.indexOf(flag);
12579
12642
  return at >= 0 ? args[at + 1] ?? null : null;
12580
12643
  }
12581
- var reader = null;
12582
- var pending = "";
12583
- async function readLine() {
12584
- reader ??= Bun.stdin.stream().getReader();
12585
- const decoder2 = new TextDecoder;
12586
- for (;; ) {
12587
- const nl = pending.indexOf(`
12588
- `);
12589
- if (nl >= 0) {
12590
- const line = pending.slice(0, nl);
12591
- pending = pending.slice(nl + 1);
12592
- return line.replace(/\r$/, "");
12593
- }
12594
- const { value, done } = await reader.read();
12595
- if (done) {
12596
- const line = pending;
12597
- pending = "";
12598
- return line;
12599
- }
12600
- pending += decoder2.decode(value, { stream: true });
12601
- }
12602
- }
12603
- async function readHidden() {
12604
- const stdin = process.stdin;
12605
- stdin.setRawMode?.(true);
12606
- reader ??= Bun.stdin.stream().getReader();
12607
- let line = "";
12608
- try {
12609
- for (;; ) {
12610
- const { value, done } = await reader.read();
12611
- if (done)
12612
- return line;
12613
- for (const byte of value) {
12614
- if (byte === 3)
12615
- process.exit(130);
12616
- if (byte === 13 || byte === 10)
12617
- return line;
12618
- if (byte === 127 || byte === 8)
12619
- line = line.slice(0, -1);
12620
- else if (byte >= 32)
12621
- line += String.fromCharCode(byte);
12622
- }
12623
- }
12624
- } finally {
12625
- stdin.setRawMode?.(false);
12626
- }
12627
- }
12628
- async function ask(question, o = {}) {
12629
- process.stderr.write(`${question}: `);
12630
- if (o.hidden && process.stdin.isTTY) {
12631
- const line = await readHidden();
12632
- process.stderr.write(`
12633
- `);
12634
- return line.trim();
12635
- }
12636
- return (await readLine()).trim();
12637
- }
12638
12644
 
12639
12645
  // node_modules/uqr/dist/index.mjs
12640
12646
  var QrCodeDataType = /* @__PURE__ */ ((QrCodeDataType2) => {
@@ -13315,20 +13321,179 @@ function sshServerAddress(sshConnection) {
13315
13321
  return null;
13316
13322
  return { host: ip, port: port === DEFAULT_PORT ? PORT_UNSET : port };
13317
13323
  }
13318
- function pairAddress(args, sshConnection, hostname) {
13324
+ function addressKind(ip) {
13325
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
13326
+ if (!match)
13327
+ return null;
13328
+ const [a, b, c, d] = match.slice(1).map(Number);
13329
+ if (a > 255 || b > 255 || c > 255 || d > 255)
13330
+ return null;
13331
+ if (a === 127)
13332
+ return "loopback";
13333
+ if (a === 169 && b === 254)
13334
+ return "linkLocal";
13335
+ if (a === 100 && b >= 64 && b <= 127)
13336
+ return "tailnet";
13337
+ if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168)
13338
+ return "private";
13339
+ return "public";
13340
+ }
13341
+ function sshClientAddress(sshConnection) {
13342
+ const parts = (sshConnection ?? "").trim().split(/\s+/);
13343
+ if (parts.length !== 4)
13344
+ return null;
13345
+ const ip = parts[0].replace(/^::ffff:/i, "");
13346
+ return addressKind(ip) === null ? null : ip;
13347
+ }
13348
+ var VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|vmnet|vboxnet)/;
13349
+ var NOTES = {
13350
+ tailnetName: "this machine's tailnet name; a phone on the tailnet reaches it from anywhere",
13351
+ tailnetAddress: "this machine's tailnet address; a phone on the tailnet reaches it from anywhere",
13352
+ ssh: "the address this ssh session reached",
13353
+ sshInside: "the address this ssh session reached, inside a NAT the session came through; a phone outside needs the outside address",
13354
+ cloud: "this machine's public address, from the cloud's metadata service",
13355
+ public: (name) => `the public address on ${name}`,
13356
+ private: (name) => `the local network address on ${name}; a phone on that network reaches it`,
13357
+ name: "this machine's name; a phone on the same network may resolve it"
13358
+ };
13359
+ function addressCandidates(inputs) {
13360
+ const out2 = [];
13361
+ const seen = new Set;
13362
+ const add = (host, source, note) => {
13363
+ const key2 = host.toLowerCase();
13364
+ if (host === "" || seen.has(key2))
13365
+ return;
13366
+ seen.add(key2);
13367
+ out2.push({ host, source, note });
13368
+ };
13369
+ if (inputs.tailnet?.name)
13370
+ add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
13371
+ for (const ip of inputs.tailnet?.addresses ?? [])
13372
+ if (addressKind(ip) === "tailnet")
13373
+ add(ip, "tailnet", NOTES.tailnetAddress);
13374
+ for (const i of inputs.interfaces)
13375
+ if (addressKind(i.address) === "tailnet")
13376
+ add(i.address, "tailnet", NOTES.tailnetAddress);
13377
+ const session = sshServerAddress(inputs.sshConnection);
13378
+ const client = sshClientAddress(inputs.sshConnection);
13379
+ const crossedNat = session !== null && addressKind(session.host) === "private" && client !== null && addressKind(client) === "public";
13380
+ const cloud = inputs.cloudAddress && addressKind(inputs.cloudAddress) === "public" ? inputs.cloudAddress : null;
13381
+ if (session && !crossedNat)
13382
+ add(session.host, "ssh", NOTES.ssh);
13383
+ if (cloud)
13384
+ add(cloud, "cloud", NOTES.cloud);
13385
+ if (session && crossedNat)
13386
+ add(session.host, "ssh", NOTES.sshInside);
13387
+ const usable = inputs.interfaces.filter((i) => !VIRTUAL_INTERFACE.test(i.name));
13388
+ for (const i of usable)
13389
+ if (addressKind(i.address) === "public")
13390
+ add(i.address, "interface", NOTES.public(i.name));
13391
+ for (const i of usable)
13392
+ if (addressKind(i.address) === "private")
13393
+ add(i.address, "interface", NOTES.private(i.name));
13394
+ add(inputs.hostname, "name", NOTES.name);
13395
+ return out2;
13396
+ }
13397
+ function tailscaleSelf(json) {
13398
+ let status2;
13399
+ try {
13400
+ status2 = JSON.parse(json);
13401
+ } catch {
13402
+ return null;
13403
+ }
13404
+ if (typeof status2 !== "object" || status2 === null)
13405
+ return null;
13406
+ const { BackendState, Self } = status2;
13407
+ if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
13408
+ return null;
13409
+ const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
13410
+ const ips = Array.isArray(Self.TailscaleIPs) ? Self.TailscaleIPs : [];
13411
+ const addresses = ips.filter((ip) => typeof ip === "string" && addressKind(ip) === "tailnet");
13412
+ return { name: name === "" ? null : name, addresses };
13413
+ }
13414
+ var METADATA_ORIGIN = "http://169.254.169.254";
13415
+ var METADATA_PATHS = [
13416
+ { cloud: "Google Cloud", path: "/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip", headers: { "Metadata-Flavor": "Google" } },
13417
+ {
13418
+ cloud: "Azure",
13419
+ path: "/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01&format=text",
13420
+ headers: { Metadata: "true" }
13421
+ },
13422
+ { cloud: "DigitalOcean", path: "/metadata/v1/interfaces/public/0/ipv4/address" },
13423
+ { cloud: "Hetzner", path: "/hetzner/v1/metadata/public-ipv4" }
13424
+ ];
13425
+ var AWS_TOKEN_PATH = "/latest/api/token";
13426
+ var DMI_DIR = "/sys/class/dmi/id";
13427
+ var DMI_FIELDS = ["sys_vendor", "bios_version", "chassis_asset_tag"];
13428
+ var AZURE_ASSET_TAG = "7783-7084-3265-9085-8269-3286-77";
13429
+ function inCloud(dmi) {
13430
+ const vendor = (dmi.sys_vendor ?? "").trim();
13431
+ if (/^(Amazon|Google|DigitalOcean|Hetzner)\b/i.test(vendor))
13432
+ return true;
13433
+ if (/amazon/i.test(dmi.bios_version ?? ""))
13434
+ return true;
13435
+ return (dmi.chassis_asset_tag ?? "").trim() === AZURE_ASSET_TAG;
13436
+ }
13437
+ var TAILSCALE_PATHS = [
13438
+ "/usr/bin/tailscale",
13439
+ "/usr/local/bin/tailscale",
13440
+ "/opt/homebrew/bin/tailscale",
13441
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
13442
+ ];
13443
+ var AWS_ADDRESS_PATH = "/latest/meta-data/public-ipv4";
13444
+ function publicAddressAnswer(body) {
13445
+ const answer = body.trim();
13446
+ return addressKind(answer) === "public" ? answer : null;
13447
+ }
13448
+ function candidateMenu(candidates) {
13449
+ const width = Math.max(...candidates.map((c) => c.host.length));
13450
+ const lines = ["Which address should a phone dial?"];
13451
+ candidates.forEach((c, i) => lines.push(` ${String(i + 1).padStart(2)} ${c.host.padEnd(width)} ${c.note}`));
13452
+ return `${lines.join(`
13453
+ `)}
13454
+ `;
13455
+ }
13456
+ var CHOICE_PROMPT = "A number, or an address as host or host:port [1]";
13457
+ function pairAddress(args, sshConnection, candidates, answer) {
13319
13458
  const session = sshServerAddress(sshConnection);
13320
13459
  let port = session?.port ?? PORT_UNSET;
13460
+ let host;
13461
+ let source;
13462
+ let note = "";
13463
+ if (args.host !== undefined) {
13464
+ host = args.host;
13465
+ source = "flag";
13466
+ } else if (answer !== undefined && answer.trim() !== "") {
13467
+ const typed = answer.trim();
13468
+ if (/^\d+$/.test(typed)) {
13469
+ const pick = candidates[Number(typed) - 1];
13470
+ if (!pick)
13471
+ return { error: `There is no address ${typed} in the list.` };
13472
+ ({ host, source, note } = pick);
13473
+ } else {
13474
+ const colon = typed.indexOf(":");
13475
+ host = colon < 0 ? typed : typed.slice(0, colon);
13476
+ source = "typed";
13477
+ if (colon >= 0) {
13478
+ const parsed = parsePort(typed.slice(colon + 1));
13479
+ if (parsed === null || parsed === PORT_UNSET)
13480
+ return { error: `"${typed.slice(colon + 1)}" is not a port from 1 to 65535.` };
13481
+ port = parsed === DEFAULT_PORT ? PORT_UNSET : parsed;
13482
+ }
13483
+ }
13484
+ } else {
13485
+ const pick = candidates[0];
13486
+ if (!pick)
13487
+ return { error: "This machine has no address a phone could dial. Run again with --host." };
13488
+ ({ host, source, note } = pick);
13489
+ }
13321
13490
  if (args.port !== undefined) {
13322
13491
  const parsed = parsePort(args.port);
13323
13492
  if (parsed === null || parsed === PORT_UNSET)
13324
13493
  return { error: `"${args.port}" is not a port from 1 to 65535.` };
13325
13494
  port = parsed === DEFAULT_PORT ? PORT_UNSET : parsed;
13326
13495
  }
13327
- if (args.host !== undefined)
13328
- return { host: args.host, port, hostFrom: "flag" };
13329
- if (session)
13330
- return { host: session.host, port, hostFrom: "ssh" };
13331
- return { host: hostname, port, hostFrom: "name" };
13496
+ return { host, port, source, note };
13332
13497
  }
13333
13498
  function phoneHostKeys(keygenOutput) {
13334
13499
  const keys = [];
@@ -13374,12 +13539,18 @@ function terminalQR(text) {
13374
13539
  function terminalQRWidth(text) {
13375
13540
  return encode(text, QR_OPTIONS).size;
13376
13541
  }
13377
- var HOST_NOTES = {
13378
- flag: "",
13379
- ssh: " (the address this ssh session reached)",
13380
- name: " (this machine's name; if your phone cannot reach it by that name, run again with --host)"
13381
- };
13382
- function pairReport({ code, keys, hostFrom, columns }) {
13542
+ function othersNote(others) {
13543
+ if (others.length === 0)
13544
+ return "";
13545
+ const width = Math.max(...others.map((c) => c.host.length));
13546
+ const lines = ["Other addresses this machine has (run again with --host to use one):"];
13547
+ for (const c of others)
13548
+ lines.push(` ${c.host.padEnd(width)} ${c.note}`);
13549
+ return `${lines.join(`
13550
+ `)}
13551
+ `;
13552
+ }
13553
+ function pairReport({ code, keys, note, columns }) {
13383
13554
  const link = pairingLink(code);
13384
13555
  const width = terminalQRWidth(link);
13385
13556
  const out2 = [];
@@ -13388,7 +13559,7 @@ function pairReport({ code, keys, hostFrom, columns }) {
13388
13559
  } else {
13389
13560
  out2.push(...terminalQR(link));
13390
13561
  }
13391
- out2.push("", "Scan the code with Ledge on your phone. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${HOST_NOTES[hostFrom]}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
13562
+ out2.push("", "Scan the code with Ledge on your phone. It names this server and its host keys, and holds no password or key.", "", ` Account ${code.user}`, ` Host ${code.host}${note === "" ? "" : ` (${note})`}`, ` Port ${code.port === PORT_UNSET ? DEFAULT_PORT : code.port}`, ...keys.map((k, i) => ` ${i === 0 ? "Host keys" : " "} ${k.fingerprint} (${k.keyType})`), "", link);
13392
13563
  return `${out2.join(`
13393
13564
  `)}
13394
13565
  `;
@@ -13401,7 +13572,7 @@ function pairCode(user, address, keys) {
13401
13572
 
13402
13573
  // src/bun/serve.ts
13403
13574
  import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync4 } from "fs";
13404
- import { hostname, userInfo } from "os";
13575
+ import { hostname, networkInterfaces, userInfo } from "os";
13405
13576
  import { join as join20 } from "path";
13406
13577
  async function serve2() {
13407
13578
  const upstream = await connectToDaemon();
@@ -13458,9 +13629,18 @@ ${PAIR_USAGE}`, 2);
13458
13629
  if (refusal)
13459
13630
  return fail2(refusal);
13460
13631
  }
13461
- const address = pairAddress(args, process.env.SSH_CONNECTION, hostname());
13632
+ const candidates = args.host === undefined ? await gatherCandidates() : [];
13633
+ const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
13634
+ let answer;
13635
+ if (interactive) {
13636
+ process.stderr.write(candidateMenu(candidates));
13637
+ answer = await ask(CHOICE_PROMPT);
13638
+ }
13639
+ const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
13462
13640
  if ("error" in address)
13463
13641
  return fail2(address.error, 2);
13642
+ if (!interactive)
13643
+ process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
13464
13644
  let keyText = "";
13465
13645
  if (args.keys === "-")
13466
13646
  keyText = await Bun.stdin.text();
@@ -13503,13 +13683,70 @@ ${PAIR_USAGE}`, 2);
13503
13683
  if ("error" in code)
13504
13684
  return fail2(code.error);
13505
13685
  const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
13506
- process.stdout.write(pairReport({ code, keys, hostFrom: address.hostFrom, columns }));
13686
+ process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
13507
13687
  return 0;
13508
13688
  }
13689
+ async function gatherCandidates() {
13690
+ const interfaces = [];
13691
+ for (const [name, list] of Object.entries(networkInterfaces())) {
13692
+ for (const i of list ?? [])
13693
+ if (i.family === "IPv4" && !i.internal)
13694
+ interfaces.push({ name, address: i.address });
13695
+ }
13696
+ const [tailnet, cloud] = await Promise.all([tailnetSelf(), process.platform === "linux" && inCloud(dmi()) ? cloudAddress() : null]);
13697
+ return addressCandidates({ sshConnection: process.env.SSH_CONNECTION, hostname: hostname(), tailnet, cloudAddress: cloud, interfaces });
13698
+ }
13699
+ var LOOKUP_MS = 1500;
13700
+ function dmi() {
13701
+ const out2 = {};
13702
+ for (const field of DMI_FIELDS) {
13703
+ try {
13704
+ out2[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
13705
+ } catch {}
13706
+ }
13707
+ return out2;
13708
+ }
13709
+ async function tailnetSelf() {
13710
+ const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
13711
+ if (!path)
13712
+ return null;
13713
+ try {
13714
+ const p = Bun.spawn([path, "status", "--json"], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
13715
+ const timer = setTimeout(() => p.kill(), LOOKUP_MS);
13716
+ const json = await new Response(p.stdout).text();
13717
+ clearTimeout(timer);
13718
+ return tailscaleSelf(json);
13719
+ } catch {
13720
+ return null;
13721
+ }
13722
+ }
13723
+ async function metadata(path, init = {}) {
13724
+ try {
13725
+ const res = await fetch(`${METADATA_ORIGIN}${path}`, { ...init, signal: AbortSignal.timeout(LOOKUP_MS) });
13726
+ return res.ok ? await res.text() : null;
13727
+ } catch {
13728
+ return null;
13729
+ }
13730
+ }
13731
+ async function cloudAddress() {
13732
+ const aws = (async () => {
13733
+ const token = await metadata(AWS_TOKEN_PATH, { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "60" } });
13734
+ return metadata(AWS_ADDRESS_PATH, token ? { headers: { "X-aws-ec2-metadata-token": token } } : {});
13735
+ })();
13736
+ const others = METADATA_PATHS.map((m) => metadata(m.path, { headers: m.headers }));
13737
+ const answers = await Promise.all([aws, ...others]);
13738
+ for (const body of answers) {
13739
+ const address = body === null ? null : publicAddressAnswer(body);
13740
+ if (address)
13741
+ return address;
13742
+ }
13743
+ return null;
13744
+ }
13509
13745
  var PAIR_USAGE = [
13510
13746
  "usage: ledge pair [--user NAME] [--host ADDRESS] [--port N] [--keys FILE]",
13511
13747
  " --user the account a phone signs in as (default: whoever runs pair)",
13512
- " --host the name or IPv4 address a phone dials (default: this ssh session's address, or the machine's name)",
13748
+ " --host the name or IPv4 address a phone dials (default: a menu of this machine's addresses on a terminal,",
13749
+ " else the first of them: its tailnet name, this ssh session's address, its public address, its name)",
13513
13750
  " --port sshd's port (default: this ssh session's port, or 22)",
13514
13751
  " --keys public host keys to describe, - for stdin (default: /etc/ssh/ssh_host_*_key.pub)"
13515
13752
  ].join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ledge-server",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "The Ledge server: your notes and shells on another machine, reached over ssh.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",