ledge-server 0.0.1 → 0.0.3
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 +382 -100
- 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 your other devices should use. 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 your other devices should use. `--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.
|
|
@@ -6234,7 +6236,7 @@ Run them as the account the server runs as, on the machine the server runs on.
|
|
|
6234
6236
|
|
|
6235
6237
|
| Verb | What it does |
|
|
6236
6238
|
| --- | --- |
|
|
6237
|
-
| \`ledge backup setup\` | Asks for the bucket and its key, fetches restic if none is installed, writes the credentials to the \`backup\` profile, creates the repository, and takes the first backup. \`--existing\` joins a repository that already has backups in it. |
|
|
6239
|
+
| \`ledge backup setup\` | Asks for the bucket and its key, fetches restic if none is installed, writes the credentials to the \`backup\` profile, creates the repository, and takes the first backup. \`--existing\` joins a repository that already has backups in it, and takes no first backup, since a machine that has just joined has nothing on it to back up. |
|
|
6238
6240
|
| \`ledge backup now\` | Takes a backup and thins old snapshots. |
|
|
6239
6241
|
| \`ledge backup status\` | When the last backup ran and how it went, when the next is due, and any attached folder the last run could not find. |
|
|
6240
6242
|
| \`ledge backup snapshots\` | The snapshots in the repository, newest first. |
|
|
@@ -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 devices 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
|
|
6422
|
+
The code names one address, and the reader connects to exactly that, so pick the one your other devices reach from where they will be. A tailnet name works from anywhere a device is on the tailnet. A home network address works from a device 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
|
|
@@ -7172,6 +7176,18 @@ Nothing else needs installing: no timer, no unit file, no line in a crontab. One
|
|
|
7172
7176
|
|
|
7173
7177
|
\`ledge backup now\` takes a backup at any time and is safe to run beside the schedule.
|
|
7174
7178
|
|
|
7179
|
+
Old snapshots are thinned after each backup, and what survives is fixed:
|
|
7180
|
+
|
|
7181
|
+
| Kept | For |
|
|
7182
|
+
| --- | --- |
|
|
7183
|
+
| The ten newest snapshots | however close together they were taken |
|
|
7184
|
+
| One an hour | a day |
|
|
7185
|
+
| One a day | a month |
|
|
7186
|
+
| One a week | a quarter |
|
|
7187
|
+
| One a month | two years |
|
|
7188
|
+
|
|
7189
|
+
Only the snapshots Ledge took are thinned, so a bucket shared with another tool's backups keeps those whatever this policy says.
|
|
7190
|
+
|
|
7175
7191
|
## 4. Check on it
|
|
7176
7192
|
|
|
7177
7193
|
\`\`\`sh norun
|
|
@@ -7207,13 +7223,13 @@ On a fresh machine with Ledge installed, the app on a Mac or the server on a VPS
|
|
|
7207
7223
|
ledge backup setup --existing
|
|
7208
7224
|
\`\`\`
|
|
7209
7225
|
|
|
7210
|
-
It asks the same questions plus the password, opens the repository instead of creating one, and
|
|
7226
|
+
It asks the same questions plus the password, opens the repository instead of creating one, writes the profile, and prints the newest snapshot in it. It takes no backup, since there is nothing on this machine to back up yet. Then, with the app quit or the daemon stopped:
|
|
7211
7227
|
|
|
7212
7228
|
\`\`\`sh norun
|
|
7213
7229
|
ledge backup restore --in-place
|
|
7214
7230
|
\`\`\`
|
|
7215
7231
|
|
|
7216
|
-
The paths inside the backup are absolute, so this puts the app home, the attached folders, and the profiles back where they were. Then open Ledge, or connect to the server. Your workspaces, images, trash, profiles, and vault are all there, and locked notes open with the passphrase they had ([[Note Locking]]). Backups continue on the new machine with the same repository.
|
|
7232
|
+
The paths inside the backup are absolute, so this puts the app home, the attached folders, and the profiles back where they were. \`--snapshot ID\` restores an older one than the newest. Then open Ledge, or connect to the server. Your workspaces, images, trash, profiles, and vault are all there, and locked notes open with the passphrase they had ([[Note Locking]]). Backups continue on the new machine with the same repository.
|
|
7217
7233
|
|
|
7218
7234
|
## Run restic yourself
|
|
7219
7235
|
|
|
@@ -11447,7 +11463,7 @@ function createOpLog(opts) {
|
|
|
11447
11463
|
}
|
|
11448
11464
|
|
|
11449
11465
|
// src/shared/version.ts
|
|
11450
|
-
var BUILD_VERSION = "0.0.
|
|
11466
|
+
var BUILD_VERSION = "0.0.3";
|
|
11451
11467
|
|
|
11452
11468
|
// src/bun/daemon.ts
|
|
11453
11469
|
var SOCKET_PATH = join16(APP_HOME, ".server.sock");
|
|
@@ -11713,6 +11729,65 @@ import { randomBytes as randomBytes2 } from "crypto";
|
|
|
11713
11729
|
import { homedir as homedir7 } from "os";
|
|
11714
11730
|
import { join as join19 } from "path";
|
|
11715
11731
|
|
|
11732
|
+
// src/bun/ask.ts
|
|
11733
|
+
var reader = null;
|
|
11734
|
+
var pending = "";
|
|
11735
|
+
async function readLine() {
|
|
11736
|
+
reader ??= Bun.stdin.stream().getReader();
|
|
11737
|
+
const decoder2 = new TextDecoder;
|
|
11738
|
+
for (;; ) {
|
|
11739
|
+
const nl = pending.indexOf(`
|
|
11740
|
+
`);
|
|
11741
|
+
if (nl >= 0) {
|
|
11742
|
+
const line = pending.slice(0, nl);
|
|
11743
|
+
pending = pending.slice(nl + 1);
|
|
11744
|
+
return line.replace(/\r$/, "");
|
|
11745
|
+
}
|
|
11746
|
+
const { value, done } = await reader.read();
|
|
11747
|
+
if (done) {
|
|
11748
|
+
const line = pending;
|
|
11749
|
+
pending = "";
|
|
11750
|
+
return line;
|
|
11751
|
+
}
|
|
11752
|
+
pending += decoder2.decode(value, { stream: true });
|
|
11753
|
+
}
|
|
11754
|
+
}
|
|
11755
|
+
async function readHidden() {
|
|
11756
|
+
const stdin = process.stdin;
|
|
11757
|
+
stdin.setRawMode?.(true);
|
|
11758
|
+
reader ??= Bun.stdin.stream().getReader();
|
|
11759
|
+
let line = "";
|
|
11760
|
+
try {
|
|
11761
|
+
for (;; ) {
|
|
11762
|
+
const { value, done } = await reader.read();
|
|
11763
|
+
if (done)
|
|
11764
|
+
return line;
|
|
11765
|
+
for (const byte of value) {
|
|
11766
|
+
if (byte === 3)
|
|
11767
|
+
process.exit(130);
|
|
11768
|
+
if (byte === 13 || byte === 10)
|
|
11769
|
+
return line;
|
|
11770
|
+
if (byte === 127 || byte === 8)
|
|
11771
|
+
line = line.slice(0, -1);
|
|
11772
|
+
else if (byte >= 32)
|
|
11773
|
+
line += String.fromCharCode(byte);
|
|
11774
|
+
}
|
|
11775
|
+
}
|
|
11776
|
+
} finally {
|
|
11777
|
+
stdin.setRawMode?.(false);
|
|
11778
|
+
}
|
|
11779
|
+
}
|
|
11780
|
+
async function ask(question, o = {}) {
|
|
11781
|
+
process.stderr.write(`${question}: `);
|
|
11782
|
+
if (o.hidden && process.stdin.isTTY) {
|
|
11783
|
+
const line = await readHidden();
|
|
11784
|
+
process.stderr.write(`
|
|
11785
|
+
`);
|
|
11786
|
+
return line.trim();
|
|
11787
|
+
}
|
|
11788
|
+
return (await readLine()).trim();
|
|
11789
|
+
}
|
|
11790
|
+
|
|
11716
11791
|
// src/bun/backup.ts
|
|
11717
11792
|
import { join as join17 } from "path";
|
|
11718
11793
|
function backupSet(input) {
|
|
@@ -11797,7 +11872,7 @@ function versionAtLeast(version, min) {
|
|
|
11797
11872
|
var BACKUP_EVERY_MS = 60 * 60 * 1000;
|
|
11798
11873
|
var PRUNE_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
11799
11874
|
var IDLE_EXIT_MIN_GAP_MS = 10 * 60 * 1000;
|
|
11800
|
-
var KEEP = { hourly: 24, daily: 30, weekly: 12, monthly: 24 };
|
|
11875
|
+
var KEEP = { last: 10, hourly: 24, daily: 30, weekly: 12, monthly: 24 };
|
|
11801
11876
|
var SNAPSHOT_TAG = "ledge";
|
|
11802
11877
|
var EMPTY_STATE = {
|
|
11803
11878
|
version: 1,
|
|
@@ -11848,6 +11923,9 @@ function isOverdue(state, now, every = BACKUP_EVERY_MS) {
|
|
|
11848
11923
|
function pruneDue(state, now, every = PRUNE_EVERY_MS) {
|
|
11849
11924
|
return !state.lastPrune || Date.parse(state.lastPrune) + every <= now.getTime();
|
|
11850
11925
|
}
|
|
11926
|
+
function forgetDue(state) {
|
|
11927
|
+
return state.lastSnapshot !== null;
|
|
11928
|
+
}
|
|
11851
11929
|
function idleExitWorthIt(state, now, gap = IDLE_EXIT_MIN_GAP_MS) {
|
|
11852
11930
|
return !state.lastOk || Date.parse(state.lastOk) + gap <= now.getTime();
|
|
11853
11931
|
}
|
|
@@ -11869,6 +11947,8 @@ function forgetArgs(prune) {
|
|
|
11869
11947
|
"forget",
|
|
11870
11948
|
"--tag",
|
|
11871
11949
|
SNAPSHOT_TAG,
|
|
11950
|
+
"--keep-last",
|
|
11951
|
+
String(KEEP.last),
|
|
11872
11952
|
"--keep-hourly",
|
|
11873
11953
|
String(KEEP.hourly),
|
|
11874
11954
|
"--keep-daily",
|
|
@@ -12184,7 +12264,8 @@ async function runBackup(o = { reason: "now" }) {
|
|
|
12184
12264
|
const out = parseBackupOutput(backed.stdout);
|
|
12185
12265
|
for (const e of out.errors)
|
|
12186
12266
|
log(`[backup] restic: ${e}`);
|
|
12187
|
-
const
|
|
12267
|
+
const state = readState();
|
|
12268
|
+
const previous = state.lastSnapshot;
|
|
12188
12269
|
if (out.snapshot && previous && previous !== out.snapshot) {
|
|
12189
12270
|
const diff = await runRestic(restic.path, diffArgs(previous, out.snapshot), env);
|
|
12190
12271
|
if (diff.code === 0 && parseDiffChanges(diff.stdout) === 0) {
|
|
@@ -12195,9 +12276,12 @@ async function runBackup(o = { reason: "now" }) {
|
|
|
12195
12276
|
out.snapshot = null;
|
|
12196
12277
|
}
|
|
12197
12278
|
}
|
|
12198
|
-
const
|
|
12199
|
-
const
|
|
12200
|
-
if (
|
|
12279
|
+
const thin = forgetDue(state);
|
|
12280
|
+
const prune = thin && pruneDue(state, at);
|
|
12281
|
+
if (!thin)
|
|
12282
|
+
log("[backup] the first backup from this machine: the snapshots already in the repository are left as they are");
|
|
12283
|
+
const forgot = thin ? await runRestic(restic.path, forgetArgs(prune), env) : null;
|
|
12284
|
+
if (forgot && forgot.code !== 0) {
|
|
12201
12285
|
const error = `snapshot ${out.snapshot?.slice(0, 8) ?? "kept"}, but restic forget failed: ${resticSaid(forgot)}`;
|
|
12202
12286
|
log(`[backup] ${error}`);
|
|
12203
12287
|
writeState(recordRun(readState(), { at, ok: false, error, snapshot: out.snapshot, skipped }));
|
|
@@ -12420,33 +12504,54 @@ async function setup(args) {
|
|
|
12420
12504
|
vars["RESTIC_PASSWORD"] = generated;
|
|
12421
12505
|
}
|
|
12422
12506
|
}
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
say(`${PROFILE_PATH} is missing ${read.missing.join(", ")}`);
|
|
12507
|
+
const proposed = parseBackupConfig(backupProfileText(vars));
|
|
12508
|
+
if (!("config" in proposed)) {
|
|
12509
|
+
say(`a backup needs ${proposed.missing.join(", ")}`);
|
|
12427
12510
|
return 1;
|
|
12428
12511
|
}
|
|
12429
|
-
const env = resticEnv(
|
|
12512
|
+
const env = resticEnv(proposed.config);
|
|
12513
|
+
const unchanged = already ? `
|
|
12514
|
+
Nothing here changed: this machine still backs up to ${already.repository}.` : `
|
|
12515
|
+
Nothing was written.`;
|
|
12430
12516
|
if (existing) {
|
|
12431
|
-
say(`Opening ${
|
|
12517
|
+
say(`Opening ${proposed.config.repository}...`);
|
|
12432
12518
|
const r = await runRestic(restic.path, ["cat", "config"], env);
|
|
12433
12519
|
if (r.code !== 0) {
|
|
12434
|
-
say(`Could not open the repository: ${resticSaid(r)}
|
|
12435
|
-
The profile is written at ${PROFILE_PATH}; fix it and run setup again with --replace.`);
|
|
12520
|
+
say(`Could not open the repository: ${resticSaid(r)}${unchanged}`);
|
|
12436
12521
|
return 1;
|
|
12437
12522
|
}
|
|
12438
12523
|
} else {
|
|
12439
|
-
say(`Creating the repository at ${
|
|
12524
|
+
say(`Creating the repository at ${proposed.config.repository}...`);
|
|
12440
12525
|
const r = await runRestic(restic.path, ["init"], env);
|
|
12441
12526
|
if (r.code !== 0) {
|
|
12442
12527
|
const said = resticSaid(r);
|
|
12443
12528
|
const hint = /already (exists|initialized)/i.test(said) ? `
|
|
12444
|
-
That repository already has backups in it. Run setup again with --existing and its password.` :
|
|
12445
|
-
|
|
12446
|
-
say(`Could not create the repository: ${said}${hint}`);
|
|
12529
|
+
That repository already has backups in it. Run setup again with --existing and its password.` : "";
|
|
12530
|
+
say(`Could not create the repository: ${said}${hint}${unchanged}`);
|
|
12447
12531
|
return 1;
|
|
12448
12532
|
}
|
|
12449
12533
|
}
|
|
12534
|
+
await writeProfile(BACKUP_PROFILE, backupProfileText(vars));
|
|
12535
|
+
if (existing) {
|
|
12536
|
+
const listed = await listSnapshots();
|
|
12537
|
+
say("");
|
|
12538
|
+
if ("error" in listed)
|
|
12539
|
+
say(`The repository opened, though its snapshots could not be listed: ${listed.error}`);
|
|
12540
|
+
else if (listed.snapshots.length === 0)
|
|
12541
|
+
say("The repository opened. It holds no snapshots yet.");
|
|
12542
|
+
else {
|
|
12543
|
+
const newest = listed.snapshots[0];
|
|
12544
|
+
say(`The repository opened. Its newest snapshot is ${newest.short_id}, from ${snapshotTime(newest.time)}; \`ledge backup snapshots\` lists the rest.`);
|
|
12545
|
+
}
|
|
12546
|
+
say("");
|
|
12547
|
+
say("Nothing has been backed up from this machine yet. To restore this machine, with the app quit or the daemon stopped:");
|
|
12548
|
+
say("");
|
|
12549
|
+
say(" ledge backup restore --in-place");
|
|
12550
|
+
say("");
|
|
12551
|
+
say("Backups run every hour while this machine's Ledge server is up, and once more before it exits.");
|
|
12552
|
+
say(`The repository and its credentials are in ${PROFILE_PATH}, the "${BACKUP_PROFILE}" profile.`);
|
|
12553
|
+
return 0;
|
|
12554
|
+
}
|
|
12450
12555
|
say("Taking the first backup...");
|
|
12451
12556
|
await loadWorkspaces();
|
|
12452
12557
|
const result = await runBackup({ log: say, reason: "setup" });
|
|
@@ -12503,9 +12608,12 @@ async function snapshots() {
|
|
|
12503
12608
|
return 1;
|
|
12504
12609
|
}
|
|
12505
12610
|
for (const s2 of r.snapshots)
|
|
12506
|
-
out(`${s2.short_id} ${s2.time
|
|
12611
|
+
out(`${s2.short_id} ${snapshotTime(s2.time)} ${s2.hostname} ${s2.paths.length} path${s2.paths.length === 1 ? "" : "s"}`);
|
|
12507
12612
|
return 0;
|
|
12508
12613
|
}
|
|
12614
|
+
function snapshotTime(time) {
|
|
12615
|
+
return time.replace(/\.\d+/, "").replace("T", " ");
|
|
12616
|
+
}
|
|
12509
12617
|
async function restore(args) {
|
|
12510
12618
|
const config = configured();
|
|
12511
12619
|
if (!config) {
|
|
@@ -12578,63 +12686,6 @@ function valueOf(args, flag) {
|
|
|
12578
12686
|
const at = args.indexOf(flag);
|
|
12579
12687
|
return at >= 0 ? args[at + 1] ?? null : null;
|
|
12580
12688
|
}
|
|
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
12689
|
|
|
12639
12690
|
// node_modules/uqr/dist/index.mjs
|
|
12640
12691
|
var QrCodeDataType = /* @__PURE__ */ ((QrCodeDataType2) => {
|
|
@@ -13315,20 +13366,179 @@ function sshServerAddress(sshConnection) {
|
|
|
13315
13366
|
return null;
|
|
13316
13367
|
return { host: ip, port: port === DEFAULT_PORT ? PORT_UNSET : port };
|
|
13317
13368
|
}
|
|
13318
|
-
function
|
|
13369
|
+
function addressKind(ip) {
|
|
13370
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
|
|
13371
|
+
if (!match)
|
|
13372
|
+
return null;
|
|
13373
|
+
const [a, b, c, d] = match.slice(1).map(Number);
|
|
13374
|
+
if (a > 255 || b > 255 || c > 255 || d > 255)
|
|
13375
|
+
return null;
|
|
13376
|
+
if (a === 127)
|
|
13377
|
+
return "loopback";
|
|
13378
|
+
if (a === 169 && b === 254)
|
|
13379
|
+
return "linkLocal";
|
|
13380
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
13381
|
+
return "tailnet";
|
|
13382
|
+
if (a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168)
|
|
13383
|
+
return "private";
|
|
13384
|
+
return "public";
|
|
13385
|
+
}
|
|
13386
|
+
function sshClientAddress(sshConnection) {
|
|
13387
|
+
const parts = (sshConnection ?? "").trim().split(/\s+/);
|
|
13388
|
+
if (parts.length !== 4)
|
|
13389
|
+
return null;
|
|
13390
|
+
const ip = parts[0].replace(/^::ffff:/i, "");
|
|
13391
|
+
return addressKind(ip) === null ? null : ip;
|
|
13392
|
+
}
|
|
13393
|
+
var VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|vmnet|vboxnet)/;
|
|
13394
|
+
var NOTES = {
|
|
13395
|
+
tailnetName: "this machine's tailnet name, reachable from anywhere on the tailnet",
|
|
13396
|
+
tailnetAddress: "this machine's tailnet address, reachable from anywhere on the tailnet",
|
|
13397
|
+
ssh: "the address this ssh session reached",
|
|
13398
|
+
sshInside: "the address this ssh session reached, inside a NAT the session came through; a device outside needs the outside address",
|
|
13399
|
+
cloud: "this machine's public address, from the cloud's metadata service",
|
|
13400
|
+
public: (name) => `the public address on ${name}`,
|
|
13401
|
+
private: (name) => `the local network address on ${name}`,
|
|
13402
|
+
name: "this machine's name"
|
|
13403
|
+
};
|
|
13404
|
+
function addressCandidates(inputs) {
|
|
13405
|
+
const out2 = [];
|
|
13406
|
+
const seen = new Set;
|
|
13407
|
+
const add = (host, source, note) => {
|
|
13408
|
+
const key2 = host.toLowerCase();
|
|
13409
|
+
if (host === "" || seen.has(key2))
|
|
13410
|
+
return;
|
|
13411
|
+
seen.add(key2);
|
|
13412
|
+
out2.push({ host, source, note });
|
|
13413
|
+
};
|
|
13414
|
+
if (inputs.tailnet?.name)
|
|
13415
|
+
add(inputs.tailnet.name, "tailnet", NOTES.tailnetName);
|
|
13416
|
+
for (const ip of inputs.tailnet?.addresses ?? [])
|
|
13417
|
+
if (addressKind(ip) === "tailnet")
|
|
13418
|
+
add(ip, "tailnet", NOTES.tailnetAddress);
|
|
13419
|
+
for (const i of inputs.interfaces)
|
|
13420
|
+
if (addressKind(i.address) === "tailnet")
|
|
13421
|
+
add(i.address, "tailnet", NOTES.tailnetAddress);
|
|
13422
|
+
const session = sshServerAddress(inputs.sshConnection);
|
|
13423
|
+
const client = sshClientAddress(inputs.sshConnection);
|
|
13424
|
+
const crossedNat = session !== null && addressKind(session.host) === "private" && client !== null && addressKind(client) === "public";
|
|
13425
|
+
const cloud = inputs.cloudAddress && addressKind(inputs.cloudAddress) === "public" ? inputs.cloudAddress : null;
|
|
13426
|
+
if (session && !crossedNat)
|
|
13427
|
+
add(session.host, "ssh", NOTES.ssh);
|
|
13428
|
+
if (cloud)
|
|
13429
|
+
add(cloud, "cloud", NOTES.cloud);
|
|
13430
|
+
if (session && crossedNat)
|
|
13431
|
+
add(session.host, "ssh", NOTES.sshInside);
|
|
13432
|
+
const usable = inputs.interfaces.filter((i) => !VIRTUAL_INTERFACE.test(i.name));
|
|
13433
|
+
for (const i of usable)
|
|
13434
|
+
if (addressKind(i.address) === "public")
|
|
13435
|
+
add(i.address, "interface", NOTES.public(i.name));
|
|
13436
|
+
for (const i of usable)
|
|
13437
|
+
if (addressKind(i.address) === "private")
|
|
13438
|
+
add(i.address, "interface", NOTES.private(i.name));
|
|
13439
|
+
add(inputs.hostname, "name", NOTES.name);
|
|
13440
|
+
return out2;
|
|
13441
|
+
}
|
|
13442
|
+
function tailscaleSelf(json) {
|
|
13443
|
+
let status2;
|
|
13444
|
+
try {
|
|
13445
|
+
status2 = JSON.parse(json);
|
|
13446
|
+
} catch {
|
|
13447
|
+
return null;
|
|
13448
|
+
}
|
|
13449
|
+
if (typeof status2 !== "object" || status2 === null)
|
|
13450
|
+
return null;
|
|
13451
|
+
const { BackendState, Self } = status2;
|
|
13452
|
+
if (BackendState !== "Running" || typeof Self !== "object" || Self === null)
|
|
13453
|
+
return null;
|
|
13454
|
+
const name = typeof Self.DNSName === "string" ? Self.DNSName.replace(/\.$/, "") : "";
|
|
13455
|
+
const ips = Array.isArray(Self.TailscaleIPs) ? Self.TailscaleIPs : [];
|
|
13456
|
+
const addresses = ips.filter((ip) => typeof ip === "string" && addressKind(ip) === "tailnet");
|
|
13457
|
+
return { name: name === "" ? null : name, addresses };
|
|
13458
|
+
}
|
|
13459
|
+
var METADATA_ORIGIN = "http://169.254.169.254";
|
|
13460
|
+
var METADATA_PATHS = [
|
|
13461
|
+
{ cloud: "Google Cloud", path: "/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip", headers: { "Metadata-Flavor": "Google" } },
|
|
13462
|
+
{
|
|
13463
|
+
cloud: "Azure",
|
|
13464
|
+
path: "/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01&format=text",
|
|
13465
|
+
headers: { Metadata: "true" }
|
|
13466
|
+
},
|
|
13467
|
+
{ cloud: "DigitalOcean", path: "/metadata/v1/interfaces/public/0/ipv4/address" },
|
|
13468
|
+
{ cloud: "Hetzner", path: "/hetzner/v1/metadata/public-ipv4" }
|
|
13469
|
+
];
|
|
13470
|
+
var AWS_TOKEN_PATH = "/latest/api/token";
|
|
13471
|
+
var DMI_DIR = "/sys/class/dmi/id";
|
|
13472
|
+
var DMI_FIELDS = ["sys_vendor", "bios_version", "chassis_asset_tag"];
|
|
13473
|
+
var AZURE_ASSET_TAG = "7783-7084-3265-9085-8269-3286-77";
|
|
13474
|
+
function inCloud(dmi) {
|
|
13475
|
+
const vendor = (dmi.sys_vendor ?? "").trim();
|
|
13476
|
+
if (/^(Amazon|Google|DigitalOcean|Hetzner)\b/i.test(vendor))
|
|
13477
|
+
return true;
|
|
13478
|
+
if (/amazon/i.test(dmi.bios_version ?? ""))
|
|
13479
|
+
return true;
|
|
13480
|
+
return (dmi.chassis_asset_tag ?? "").trim() === AZURE_ASSET_TAG;
|
|
13481
|
+
}
|
|
13482
|
+
var TAILSCALE_PATHS = [
|
|
13483
|
+
"/usr/bin/tailscale",
|
|
13484
|
+
"/usr/local/bin/tailscale",
|
|
13485
|
+
"/opt/homebrew/bin/tailscale",
|
|
13486
|
+
"/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
13487
|
+
];
|
|
13488
|
+
var AWS_ADDRESS_PATH = "/latest/meta-data/public-ipv4";
|
|
13489
|
+
function publicAddressAnswer(body) {
|
|
13490
|
+
const answer = body.trim();
|
|
13491
|
+
return addressKind(answer) === "public" ? answer : null;
|
|
13492
|
+
}
|
|
13493
|
+
function candidateMenu(candidates) {
|
|
13494
|
+
const width = Math.max(...candidates.map((c) => c.host.length));
|
|
13495
|
+
const lines = ["Which address should Ledge on your other devices use to reach this server?"];
|
|
13496
|
+
candidates.forEach((c, i) => lines.push(` ${String(i + 1).padStart(2)} ${c.host.padEnd(width)} ${c.note}`));
|
|
13497
|
+
return `${lines.join(`
|
|
13498
|
+
`)}
|
|
13499
|
+
`;
|
|
13500
|
+
}
|
|
13501
|
+
var CHOICE_PROMPT = "A number, or an address as host or host:port [1]";
|
|
13502
|
+
function pairAddress(args, sshConnection, candidates, answer) {
|
|
13319
13503
|
const session = sshServerAddress(sshConnection);
|
|
13320
13504
|
let port = session?.port ?? PORT_UNSET;
|
|
13505
|
+
let host;
|
|
13506
|
+
let source;
|
|
13507
|
+
let note = "";
|
|
13508
|
+
if (args.host !== undefined) {
|
|
13509
|
+
host = args.host;
|
|
13510
|
+
source = "flag";
|
|
13511
|
+
} else if (answer !== undefined && answer.trim() !== "") {
|
|
13512
|
+
const typed = answer.trim();
|
|
13513
|
+
if (/^\d+$/.test(typed)) {
|
|
13514
|
+
const pick = candidates[Number(typed) - 1];
|
|
13515
|
+
if (!pick)
|
|
13516
|
+
return { error: `There is no address ${typed} in the list.` };
|
|
13517
|
+
({ host, source, note } = pick);
|
|
13518
|
+
} else {
|
|
13519
|
+
const colon = typed.indexOf(":");
|
|
13520
|
+
host = colon < 0 ? typed : typed.slice(0, colon);
|
|
13521
|
+
source = "typed";
|
|
13522
|
+
if (colon >= 0) {
|
|
13523
|
+
const parsed = parsePort(typed.slice(colon + 1));
|
|
13524
|
+
if (parsed === null || parsed === PORT_UNSET)
|
|
13525
|
+
return { error: `"${typed.slice(colon + 1)}" is not a port from 1 to 65535.` };
|
|
13526
|
+
port = parsed === DEFAULT_PORT ? PORT_UNSET : parsed;
|
|
13527
|
+
}
|
|
13528
|
+
}
|
|
13529
|
+
} else {
|
|
13530
|
+
const pick = candidates[0];
|
|
13531
|
+
if (!pick)
|
|
13532
|
+
return { error: "This machine has no address to put in the code. Run again with --host." };
|
|
13533
|
+
({ host, source, note } = pick);
|
|
13534
|
+
}
|
|
13321
13535
|
if (args.port !== undefined) {
|
|
13322
13536
|
const parsed = parsePort(args.port);
|
|
13323
13537
|
if (parsed === null || parsed === PORT_UNSET)
|
|
13324
13538
|
return { error: `"${args.port}" is not a port from 1 to 65535.` };
|
|
13325
13539
|
port = parsed === DEFAULT_PORT ? PORT_UNSET : parsed;
|
|
13326
13540
|
}
|
|
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" };
|
|
13541
|
+
return { host, port, source, note };
|
|
13332
13542
|
}
|
|
13333
13543
|
function phoneHostKeys(keygenOutput) {
|
|
13334
13544
|
const keys = [];
|
|
@@ -13374,12 +13584,18 @@ function terminalQR(text) {
|
|
|
13374
13584
|
function terminalQRWidth(text) {
|
|
13375
13585
|
return encode(text, QR_OPTIONS).size;
|
|
13376
13586
|
}
|
|
13377
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
|
|
13587
|
+
function othersNote(others) {
|
|
13588
|
+
if (others.length === 0)
|
|
13589
|
+
return "";
|
|
13590
|
+
const width = Math.max(...others.map((c) => c.host.length));
|
|
13591
|
+
const lines = ["Other addresses this machine has (run again with --host to use one):"];
|
|
13592
|
+
for (const c of others)
|
|
13593
|
+
lines.push(` ${c.host.padEnd(width)} ${c.note}`);
|
|
13594
|
+
return `${lines.join(`
|
|
13595
|
+
`)}
|
|
13596
|
+
`;
|
|
13597
|
+
}
|
|
13598
|
+
function pairReport({ code, keys, note, columns }) {
|
|
13383
13599
|
const link = pairingLink(code);
|
|
13384
13600
|
const width = terminalQRWidth(link);
|
|
13385
13601
|
const out2 = [];
|
|
@@ -13388,7 +13604,7 @@ function pairReport({ code, keys, hostFrom, columns }) {
|
|
|
13388
13604
|
} else {
|
|
13389
13605
|
out2.push(...terminalQR(link));
|
|
13390
13606
|
}
|
|
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}${
|
|
13607
|
+
out2.push("", "Scan the code with Ledge on your phone, or paste the link below into the Mac app's Add Server form. 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
13608
|
return `${out2.join(`
|
|
13393
13609
|
`)}
|
|
13394
13610
|
`;
|
|
@@ -13401,7 +13617,7 @@ function pairCode(user, address, keys) {
|
|
|
13401
13617
|
|
|
13402
13618
|
// src/bun/serve.ts
|
|
13403
13619
|
import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
13404
|
-
import { hostname, userInfo } from "os";
|
|
13620
|
+
import { hostname, networkInterfaces, userInfo } from "os";
|
|
13405
13621
|
import { join as join20 } from "path";
|
|
13406
13622
|
async function serve2() {
|
|
13407
13623
|
const upstream = await connectToDaemon();
|
|
@@ -13458,9 +13674,18 @@ ${PAIR_USAGE}`, 2);
|
|
|
13458
13674
|
if (refusal)
|
|
13459
13675
|
return fail2(refusal);
|
|
13460
13676
|
}
|
|
13461
|
-
const
|
|
13677
|
+
const candidates = args.host === undefined ? await gatherCandidates() : [];
|
|
13678
|
+
const interactive = args.host === undefined && args.keys !== "-" && process.stdin.isTTY && process.stdout.isTTY;
|
|
13679
|
+
let answer;
|
|
13680
|
+
if (interactive) {
|
|
13681
|
+
process.stderr.write(candidateMenu(candidates));
|
|
13682
|
+
answer = await ask(CHOICE_PROMPT);
|
|
13683
|
+
}
|
|
13684
|
+
const address = pairAddress(args, process.env.SSH_CONNECTION, candidates, answer);
|
|
13462
13685
|
if ("error" in address)
|
|
13463
13686
|
return fail2(address.error, 2);
|
|
13687
|
+
if (!interactive)
|
|
13688
|
+
process.stderr.write(othersNote(candidates.filter((c) => c.host !== address.host)));
|
|
13464
13689
|
let keyText = "";
|
|
13465
13690
|
if (args.keys === "-")
|
|
13466
13691
|
keyText = await Bun.stdin.text();
|
|
@@ -13503,13 +13728,70 @@ ${PAIR_USAGE}`, 2);
|
|
|
13503
13728
|
if ("error" in code)
|
|
13504
13729
|
return fail2(code.error);
|
|
13505
13730
|
const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
|
|
13506
|
-
process.stdout.write(pairReport({ code, keys,
|
|
13731
|
+
process.stdout.write(pairReport({ code, keys, note: address.note, columns }));
|
|
13507
13732
|
return 0;
|
|
13508
13733
|
}
|
|
13734
|
+
async function gatherCandidates() {
|
|
13735
|
+
const interfaces = [];
|
|
13736
|
+
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
13737
|
+
for (const i of list ?? [])
|
|
13738
|
+
if (i.family === "IPv4" && !i.internal)
|
|
13739
|
+
interfaces.push({ name, address: i.address });
|
|
13740
|
+
}
|
|
13741
|
+
const [tailnet, cloud] = await Promise.all([tailnetSelf(), process.platform === "linux" && inCloud(dmi()) ? cloudAddress() : null]);
|
|
13742
|
+
return addressCandidates({ sshConnection: process.env.SSH_CONNECTION, hostname: hostname(), tailnet, cloudAddress: cloud, interfaces });
|
|
13743
|
+
}
|
|
13744
|
+
var LOOKUP_MS = 1500;
|
|
13745
|
+
function dmi() {
|
|
13746
|
+
const out2 = {};
|
|
13747
|
+
for (const field of DMI_FIELDS) {
|
|
13748
|
+
try {
|
|
13749
|
+
out2[field] = readFileSync4(join20(DMI_DIR, field), "utf8");
|
|
13750
|
+
} catch {}
|
|
13751
|
+
}
|
|
13752
|
+
return out2;
|
|
13753
|
+
}
|
|
13754
|
+
async function tailnetSelf() {
|
|
13755
|
+
const path = TAILSCALE_PATHS.find((p) => existsSync3(p));
|
|
13756
|
+
if (!path)
|
|
13757
|
+
return null;
|
|
13758
|
+
try {
|
|
13759
|
+
const p = Bun.spawn([path, "status", "--json"], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
13760
|
+
const timer = setTimeout(() => p.kill(), LOOKUP_MS);
|
|
13761
|
+
const json = await new Response(p.stdout).text();
|
|
13762
|
+
clearTimeout(timer);
|
|
13763
|
+
return tailscaleSelf(json);
|
|
13764
|
+
} catch {
|
|
13765
|
+
return null;
|
|
13766
|
+
}
|
|
13767
|
+
}
|
|
13768
|
+
async function metadata(path, init = {}) {
|
|
13769
|
+
try {
|
|
13770
|
+
const res = await fetch(`${METADATA_ORIGIN}${path}`, { ...init, signal: AbortSignal.timeout(LOOKUP_MS) });
|
|
13771
|
+
return res.ok ? await res.text() : null;
|
|
13772
|
+
} catch {
|
|
13773
|
+
return null;
|
|
13774
|
+
}
|
|
13775
|
+
}
|
|
13776
|
+
async function cloudAddress() {
|
|
13777
|
+
const aws = (async () => {
|
|
13778
|
+
const token = await metadata(AWS_TOKEN_PATH, { method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "60" } });
|
|
13779
|
+
return metadata(AWS_ADDRESS_PATH, token ? { headers: { "X-aws-ec2-metadata-token": token } } : {});
|
|
13780
|
+
})();
|
|
13781
|
+
const others = METADATA_PATHS.map((m) => metadata(m.path, { headers: m.headers }));
|
|
13782
|
+
const answers = await Promise.all([aws, ...others]);
|
|
13783
|
+
for (const body of answers) {
|
|
13784
|
+
const address = body === null ? null : publicAddressAnswer(body);
|
|
13785
|
+
if (address)
|
|
13786
|
+
return address;
|
|
13787
|
+
}
|
|
13788
|
+
return null;
|
|
13789
|
+
}
|
|
13509
13790
|
var PAIR_USAGE = [
|
|
13510
13791
|
"usage: ledge pair [--user NAME] [--host ADDRESS] [--port N] [--keys FILE]",
|
|
13511
|
-
" --user the account
|
|
13512
|
-
" --host the name or IPv4 address
|
|
13792
|
+
" --user the account Ledge signs in as (default: whoever runs pair)",
|
|
13793
|
+
" --host the name or IPv4 address Ledge connects to (default: a menu of this machine's addresses on a terminal,",
|
|
13794
|
+
" else the first of them: its tailnet name, this ssh session's address, its public address, its name)",
|
|
13513
13795
|
" --port sshd's port (default: this ssh session's port, or 22)",
|
|
13514
13796
|
" --keys public host keys to describe, - for stdin (default: /etc/ssh/ssh_host_*_key.pub)"
|
|
13515
13797
|
].join(`
|