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.
- package/README.md +87 -35
- package/lib/serve.js +315 -78
- 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://
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
14
|
+
Signed in as the account Ledge should use:
|
|
12
15
|
|
|
13
16
|
```sh
|
|
14
|
-
curl -fsSL https://
|
|
17
|
+
curl -fsSL https://ledge.sh/server.sh | sh
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
|
|
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
|
-
|
|
28
|
+
ledge pair
|
|
21
29
|
```
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
The verbs, if you want them:
|
|
83
|
+
## The verbs
|
|
55
84
|
|
|
56
85
|
| Verb | What it does |
|
|
57
86
|
| --- | --- |
|
|
58
|
-
| `ledge serve` |
|
|
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
|
|
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
|
-
|
|
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="/
|
|
103
|
+
restrict,command="PATH=$HOME/.ledge/.server/bin:$PATH ledge serve" ssh-ed25519 AAAA... ledge@laptop
|
|
73
104
|
```
|
|
74
105
|
|
|
75
|
-
That key
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
|
|
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}${
|
|
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
|
|
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,
|
|
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
|
|
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(`
|