Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
688 lines (607 loc) · 52.5 KB
/
Copy pathindex.html
File metadata and controls
688 lines (607 loc) · 52.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Secure Remote Access</title>
<meta name="description" content="Reach your own laptops, servers and phones from anywhere — privately, and without losing your session when the connection drops.">
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="assets/style.css">
</head>
<body data-page="index" data-depth="0">
<div class="layout">
<aside class="sidebar"></aside>
<main class="main">
<div class="content">
<p class="eyebrow">The whole picture</p>
<h1>Reach your own machines from anywhere</h1>
<p class="lede">
The goal is narrow and worth stating precisely: open a phone or a laptop anywhere in the
world, reach your own machines in under two seconds, and lose nothing when the train enters
a tunnel. Everything in this guide exists to serve that sentence.
</p>
<p>
Most remote-access setups fail in one of two directions. They are either wide open — a
password-authenticated SSH port exposed to the internet, quietly absorbing thousands of
login attempts a day — or they are so locked down that you stop using them, which is its own
kind of failure. This guide aims at a third thing: a network where your machines are
<em>invisible</em> to everyone but you, and a session layer that treats disconnection as
normal rather than exceptional.
</p>
<p>
Those are two separable layers, and it is worth knowing which one you actually need. The
<strong>network layer</strong> — Tailscale and SSH, plus the hardening chapter for each
machine — gives you private reachability, and it is useful whatever you point at it: a
database that should never touch the public internet, a home server, a NAS, a web app you
are not ready to publish. The <strong>session layer</strong> — Mosh and tmux, chapters 03
and 04 — is what turns that connection into somewhere you can work for hours from a phone on
a moving train. Build the first on its own if that is all you want; the second earns its
keep only if you live in a terminal.
</p>
<h2>The four machines</h2>
<p>
Four devices, one private network. The phone is a client only. The other three are
machines you work on, and each is hardened in its own chapter.
</p>
<figure class="diagram sim" data-sim data-step-ms="4000" tabindex="0"
aria-label="Interactive walkthrough of how four machines end up on one private network. Pick what is going on, then step through joining, addressing, and the path the packets actually take. Use the left and right arrow keys to step through it.">
<div class="sim-bar">
<span class="sim-bar-label">What is going on</span>
<button type="button" class="sim-scn is-on" data-scenario="direct" aria-pressed="true">Everything up</button>
<button type="button" class="sim-scn" data-scenario="relay" aria-pressed="false">A NAT that refuses</button>
<button type="button" class="sim-scn" data-scenario="coordoff" aria-pressed="false">Coordinator offline</button>
</div>
<svg viewBox="0 0 900 500" role="img" aria-label="Four devices — a phone, a MacBook Pro, an Ubuntu laptop and a Linux VPS — register their public keys with a Tailscale coordination server, receive addresses in the 100.64.0.0/10 range, and then exchange traffic directly with each other, falling back to an encrypted DERP relay when a NAT refuses to cooperate.">
<defs>
<marker id="tp-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" class="svg-accent"/>
</marker>
<marker id="tp-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity=".45"/>
</marker>
<marker id="tp-w" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" class="svg-warn"/>
</marker>
</defs>
<!-- ============ the four machines (always present) ============ -->
<g data-at="0+" class="sim-pop">
<rect x="28" y="120" width="184" height="84" rx="10" class="svg-card svg-stroke" stroke-width="1.5"/>
<rect x="28" y="120" width="4" height="84" rx="2" class="svg-accent"/>
<text x="48" y="148" class="svg-label">Phone</text>
<text x="48" y="167" class="svg-sub">Blink or Termux + Mosh</text>
<text x="48" y="185" class="svg-sub">no port listens here</text>
</g>
<g data-at="0+" class="sim-pop">
<rect x="688" y="120" width="184" height="84" rx="10" class="svg-card svg-stroke" stroke-width="1.5"/>
<rect x="688" y="120" width="4" height="84" rx="2" class="svg-accent"/>
<text x="708" y="148" class="svg-label">MacBook Pro</text>
<text x="708" y="167" class="svg-sub">sshd on the tailnet IP</text>
<text x="708" y="185" class="svg-sub">tmux + Herdr</text>
</g>
<g data-at="0+" class="sim-pop">
<rect x="28" y="326" width="184" height="84" rx="10" class="svg-card svg-stroke" stroke-width="1.5"/>
<rect x="28" y="326" width="4" height="84" rx="2" class="svg-accent"/>
<text x="48" y="354" class="svg-label">Ubuntu laptop</text>
<text x="48" y="373" class="svg-sub">LUKS + UFW</text>
<text x="48" y="391" class="svg-sub">roams networks</text>
</g>
<g data-at="0+" class="sim-pop">
<rect x="688" y="326" width="184" height="84" rx="10" class="svg-card svg-stroke" stroke-width="1.5"/>
<rect x="688" y="326" width="4" height="84" rx="2" class="svg-accent"/>
<text x="708" y="354" class="svg-label">Linux VPS</text>
<text x="708" y="373" class="svg-sub">public IP · always on</text>
<text x="708" y="391" class="svg-sub">Tailscale SSH ready</text>
</g>
<!-- ============ step 1 · the control plane ============ -->
<g data-at="1+" class="sim-pop">
<rect x="316" y="14" width="268" height="64" rx="10" class="svg-card svg-stroke" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="450" y="40" text-anchor="middle" class="svg-label">Tailscale coordination server</text>
<text x="450" y="62" text-anchor="middle" class="svg-sub">public keys and ACL policy</text>
</g>
<g data-at="1">
<path data-flow="muted" data-count="2" data-speed="0.2"
d="M212 140 C 254 112, 284 74, 316 46"
class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none" marker-end="url(#tp-m)"/>
<path data-flow="muted" data-count="2" data-speed="0.2"
d="M688 140 C 646 112, 616 74, 584 46"
class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none" marker-end="url(#tp-m)"/>
<path data-flow="muted" data-count="3" data-speed="0.2"
d="M28 348 L16 348 L16 40 L316 40"
class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none" marker-end="url(#tp-m)"/>
<path data-flow="muted" data-count="3" data-speed="0.2"
d="M872 348 L884 348 L884 40 L584 40"
class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none" marker-end="url(#tp-m)"/>
</g>
<!-- ============ step 2 · the tailnet itself ============ -->
<path data-at="2+" d="M450 78 L450 196" class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none"/>
<text data-at="2+" x="462" y="165" class="svg-sub">control plane</text>
<g data-at="2+" class="sim-pop">
<rect x="330" y="196" width="240" height="110" rx="12" class="svg-card svg-accent-s" stroke-width="2"/>
<text x="450" y="224" text-anchor="middle" class="svg-label">Your tailnet</text>
<text x="450" y="246" text-anchor="middle" class="svg-sub">100.64.0.0/10</text>
<text x="450" y="267" text-anchor="middle" class="svg-sub">WireGuard · end to end</text>
<text x="450" y="288" text-anchor="middle" class="svg-sub">ACLs enforced per packet</text>
</g>
<text data-at="2+" x="48" y="224" class="svg-mono-accent">100.126.245.94</text>
<text data-at="2+" x="708" y="224" class="svg-mono-accent">100.117.202.65</text>
<text data-at="2+" x="48" y="430" class="svg-mono-accent">100.71.19.4</text>
<text data-at="2+" x="708" y="430" class="svg-mono-accent">100.98.140.2</text>
<g data-at="2+" class="svg-muted-s" stroke-width="1.6" fill="none">
<path d="M212 162 C 262 168, 292 200, 330 222"/>
<path d="M688 162 C 638 168, 608 200, 570 222"/>
<path d="M212 368 C 262 362, 292 306, 330 284"/>
<path d="M688 368 C 638 362, 608 306, 570 284"/>
</g>
<!-- ============ step 3 · traffic, and the standby relay ============ -->
<g data-at="3+" class="sim-pop">
<rect x="336" y="398" width="228" height="56" rx="10" class="svg-surface svg-stroke" stroke-width="1.4" stroke-dasharray="5 3"/>
<text x="450" y="422" text-anchor="middle" class="svg-label">DERP relay</text>
<text x="450" y="442" text-anchor="middle" class="svg-sub">encrypted · cannot read it</text>
</g>
<path data-at="3+" d="M450 306 L450 398" class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none"/>
<text data-at="3+" x="234" y="210" class="svg-sub">encrypted</text>
<text data-at="3+" x="666" y="210" text-anchor="end" class="svg-sub">encrypted</text>
<g data-at="3" data-scn="direct relay coordoff">
<path data-flow="accent" data-count="3" data-speed="0.26" data-r="4"
d="M212 162 C 262 168, 292 200, 330 222"
class="svg-accent-s" stroke-width="2.2" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.26" data-r="4"
d="M688 162 C 638 168, 608 200, 570 222"
class="svg-accent-s" stroke-width="2.2" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.26" data-r="4"
d="M212 368 C 262 362, 292 306, 330 284"
class="svg-accent-s" stroke-width="2.2" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.26" data-r="4"
d="M688 368 C 638 362, 608 306, 570 284"
class="svg-accent-s" stroke-width="2.2" fill="none" marker-end="url(#tp-a)"/>
</g>
<!-- ============ step 4 · which path won ============ -->
<g data-at="4" data-scn="direct coordoff" data-poster>
<path data-flow="accent" data-count="3" data-speed="0.3" data-r="4.5"
d="M212 162 C 262 168, 292 200, 330 222"
class="svg-accent-s" stroke-width="2.4" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.3" data-r="4.5"
d="M688 162 C 638 168, 608 200, 570 222"
class="svg-accent-s" stroke-width="2.4" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.3" data-r="4.5"
d="M212 368 C 262 362, 292 306, 330 284"
class="svg-accent-s" stroke-width="2.4" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.3" data-r="4.5"
d="M688 368 C 638 362, 608 306, 570 284"
class="svg-accent-s" stroke-width="2.4" fill="none" marker-end="url(#tp-a)"/>
</g>
<text data-at="4" data-scn="direct" data-poster x="20" y="478" class="svg-mono-accent">direct · encrypted end to end · ~48 ms</text>
<text data-at="4" data-scn="direct" data-poster x="316" y="430" text-anchor="end" class="svg-sub">idle — no traffic</text>
<g data-at="4" data-scn="relay">
<path d="M212 162 C 262 168, 292 200, 330 222"
class="svg-warn-s" stroke-width="1.6" stroke-dasharray="3 6" fill="none" opacity=".7"/>
<path d="M688 162 C 638 168, 608 200, 570 222"
class="svg-warn-s" stroke-width="1.6" stroke-dasharray="3 6" fill="none" opacity=".7"/>
<path data-flow="accent" data-count="3" data-speed="0.3" data-r="4.5"
d="M212 368 C 262 362, 292 306, 330 284"
class="svg-accent-s" stroke-width="2.4" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="accent" data-count="3" data-speed="0.3" data-r="4.5"
d="M688 368 C 638 362, 608 306, 570 284"
class="svg-accent-s" stroke-width="2.4" fill="none" marker-end="url(#tp-a)"/>
<path data-flow="warn" data-count="3" data-speed="0.22" data-r="4"
d="M450 306 L450 398"
class="svg-warn-s" stroke-width="2.2" fill="none" marker-end="url(#tp-w)"/>
<rect x="336" y="398" width="228" height="56" rx="10" fill="none" class="svg-warn-s" stroke-width="2.4"/>
<text x="316" y="430" text-anchor="end" class="svg-mono-warn">carrying it now</text>
<text x="20" y="478" class="svg-mono-warn">relayed · same encryption · ~620 ms</text>
</g>
<g data-at="4" data-scn="coordoff">
<rect x="316" y="14" width="268" height="64" rx="10" fill="none" class="svg-danger-s" stroke-width="2.4"/>
<text x="450" y="98" text-anchor="middle" class="svg-mono-danger">offline — keys already handed out</text>
<circle cx="450" cy="128" r="11" class="svg-card svg-danger-s" stroke-width="1.4"/>
<path d="M445.5 123.5 L454.5 132.5 M454.5 123.5 L445.5 132.5" class="svg-danger-s" stroke-width="2" fill="none" stroke-linecap="round"/>
<text x="20" y="478" class="svg-mono-accent">traffic unaffected · no new device can join</text>
</g>
</svg>
<div class="sim-controls">
<button type="button" class="sim-btn" data-act="prev" aria-label="Previous step"><span class="sim-glyph">◀</span></button>
<button type="button" class="sim-btn sim-btn-play" data-act="play" aria-label="Play">
<span class="sim-glyph">▶</span><span class="sim-btn-text">Play</span>
</button>
<button type="button" class="sim-btn" data-act="next" aria-label="Next step"><span class="sim-glyph">▶</span></button>
<div class="sim-track" role="group" aria-label="Jump to step">
<button type="button" class="sim-dot" data-goto="0" aria-label="Step 0, four separate machines">0</button>
<button type="button" class="sim-dot" data-goto="1" aria-label="Step 1, joining the tailnet">1</button>
<button type="button" class="sim-dot" data-goto="2" aria-label="Step 2, addresses">2</button>
<button type="button" class="sim-dot" data-goto="3" aria-label="Step 3, traffic">3</button>
<button type="button" class="sim-dot" data-goto="4" aria-label="Step 4, which path won">4</button>
</div>
<div class="sim-progress" aria-hidden="true"><span></span></div>
</div>
<div class="sim-term" aria-hidden="true">
<div data-at="0"><span class="tp">$</span> tailscale status<br><span class="tdim">Tailscale is stopped.</span></div>
<div data-at="1"><span class="tp">$</span> sudo tailscale up<br><span class="tdim">authenticating with controlplane.tailscale.com…</span></div>
<div data-at="2"><span class="tp">$</span> tailscale status<br>100.117.202.65 macbook macOS <span class="tok">-</span> 100.126.245.94 iphone iOS <span class="tok">-</span></div>
<div data-at="3"><span class="tp">$</span> tailscale ping macbook<br>pong from macbook (100.117.202.65) via <span class="twarn">DERP(dbi)</span> in 618ms</div>
<div data-at="4" data-scn="direct"><span class="tp">$</span> tailscale ping macbook<br>pong from macbook (100.117.202.65) via <span class="tok">192.168.1.222:41641</span> in 48ms</div>
<div data-at="4" data-scn="relay"><span class="tp">$</span> tailscale ping macbook<br>pong from macbook (100.117.202.65) via <span class="twarn">DERP(dbi)</span> in 621ms</div>
<div data-at="4" data-scn="coordoff"><span class="tp">$</span> tailscale status<br><span class="twarn"># Health check: not in sync with the control server</span> <span class="tdim">— sessions still up</span></div>
</div>
<ol class="sim-steps" aria-live="polite">
<li data-at="0"><b>Four machines, no shared network</b>A phone, two laptops and a server. Three of them sit behind NAT with no reachable public port, and nothing here can address anything else. This is the honest starting point.</li>
<li data-at="1"><b>1 · Each device registers</b>Every machine sends its <em>public</em> key and a list of candidate endpoints to the coordination server, and gets back the keys and ACL policy for the others. Private keys are generated on-device and never move.</li>
<li data-at="2"><b>2 · The tailnet exists</b>Each device now holds a stable <code>100.x</code> address that follows it between Wi-Fi, LTE and ethernet. Rules are written against these identities, not against whatever public IP the café hands you today.</li>
<li data-at="3"><b>3 · Traffic flows between devices</b>Packets go device to device, sealed with WireGuard keys the coordination server has never held. It handed out public keys and policy and then got out of the way — it is not on this path.</li>
<li data-at="4" data-scn="direct" data-poster><b>4 · Direct, and fast</b>Both ends punched through their NATs, so packets take the shortest route the internet will give them. The relay sits idle. This is the ordinary case, and it is why a shell from your phone feels local.</li>
<li data-at="4" data-scn="relay"><b>4 · Relayed, and still private</b>One NAT refused to cooperate, so that pair falls back to a DERP relay. The relay forwards sealed packets it has no key to open: you pay latency, not confidentiality. Never disable it to "force security" — you will just lose connectivity.</li>
<li data-at="4" data-scn="coordoff"><b>4 · The coordinator goes away</b>Your traffic does not care. Devices already hold each other's keys, so existing paths keep carrying packets. What you lose is the control plane: no new device can join, and ACL changes will not propagate until it is back.</li>
</ol>
<figcaption>
<b>The coordination server never carries your traffic.</b> It distributes public keys and
policy, then gets out of the way — packets flow directly between devices, encrypted with
keys the server has never held. Switch to <b>A NAT that refuses</b> above to watch a pair
fall back to an encrypted DERP relay that still cannot read them, and to
<b>Coordinator offline</b> to see how little the control plane matters once the keys are out.
</figcaption>
</figure>
<h2>The three planes</h2>
<p>
It helps to stop thinking of this as "a VPN plus SSH" and start thinking in planes. Each
answers a different question, and each fails differently.
</p>
<div class="grid2">
<div class="card">
<div class="k">Plane 1</div>
<h4>Identity — who are you?</h4>
<p>Tailscale ties every device to an account and a signed node key. Access rules are written
against identities, not IP addresses. Revoking a stolen phone is one click, not a key hunt.</p>
</div>
<div class="card">
<div class="k">Plane 2</div>
<h4>Network — can you even reach it?</h4>
<p>WireGuard builds encrypted point-to-point tunnels. Your SSH port binds to the tailnet
address only, so from the public internet and even your own café Wi-Fi, the port does not exist.</p>
</div>
<div class="card">
<div class="k">Plane 3</div>
<h4>Session — does your work survive?</h4>
<p>Mosh survives roaming and sleep. tmux survives the client dying entirely. Herdr keeps
coding agents alive and tells you which ones are blocked on you.</p>
</div>
</div>
<h2>How the session layers stack</h2>
<p>
This is the part people usually get wrong, because the layers look redundant. They are not.
Each one survives a failure the layer below it cannot.
</p>
<figure class="diagram sim" data-sim data-step-ms="3600" tabindex="0"
aria-label="Interactive walkthrough of the session stack. Pick something that goes wrong, then climb from the hostile network up through WireGuard, SSH, Mosh, tmux and Herdr, and watch which layer actually saves you. Use the left and right arrow keys to step through it.">
<div class="sim-bar">
<span class="sim-bar-label">What goes wrong</span>
<button type="button" class="sim-scn is-on" data-scenario="ip" aria-pressed="true">Your IP changes</button>
<button type="button" class="sim-scn" data-scenario="sleep" aria-pressed="false">20 min in a pocket</button>
<button type="button" class="sim-scn" data-scenario="kill" aria-pressed="false">You close Blink</button>
<button type="button" class="sim-scn" data-scenario="reboot" aria-pressed="false">The host reboots</button>
</div>
<svg viewBox="0 0 880 430" role="img" aria-label="Six stacked layers, read from the bottom: the hostile network, WireGuard and Tailscale, SSH, Mosh, tmux, and Herdr. For a chosen failure, each layer reports whether it holds, dies, or was never involved, so you can see which single layer is the one that saves your work.">
<text x="16" y="22" class="svg-sub">LAYER</text>
<text x="250" y="22" class="svg-sub">WHAT IT GIVES YOU</text>
<text x="612" y="22" class="svg-sub">IN THIS FAILURE</text>
<!-- ============ the stack itself (always present) ============ -->
<g data-at="0+">
<path d="M8 392 L8 48" class="svg-muted-s" stroke-width="1.4" stroke-dasharray="4 4" fill="none"/>
<g class="svg-muted-s" stroke-width="1.2" fill="none">
<path d="M8 61 L16 61"/><path d="M8 123 L16 123"/><path d="M8 185 L16 185"/>
<path d="M8 247 L16 247"/><path d="M8 309 L16 309"/><path d="M8 368 L16 368"/>
</g>
<rect x="16" y="34" width="848" height="54" rx="9" class="svg-card svg-accent-s" stroke-width="1.8"/>
<rect x="16" y="34" width="5" height="54" rx="2.5" class="svg-accent"/>
<text x="36" y="56" class="svg-label">Herdr</text>
<text x="36" y="74" class="svg-sub">agent multiplexer</text>
<text x="250" y="56" class="svg-sub">panes that know which agent is blocked on you</text>
<rect x="16" y="96" width="848" height="54" rx="9" class="svg-card svg-accent-s" stroke-width="1.8"/>
<rect x="16" y="96" width="5" height="54" rx="2.5" class="svg-accent"/>
<text x="36" y="118" class="svg-label">tmux</text>
<text x="36" y="136" class="svg-sub">session persistence</text>
<text x="250" y="118" class="svg-sub">processes that keep running, nothing attached</text>
<rect x="16" y="158" width="848" height="54" rx="9" class="svg-card svg-accent-s" stroke-width="1.8"/>
<rect x="16" y="158" width="5" height="54" rx="2.5" class="svg-accent"/>
<text x="36" y="180" class="svg-label">Mosh</text>
<text x="36" y="198" class="svg-sub">roaming transport</text>
<text x="250" y="180" class="svg-sub">UDP state sync and local echo, no TCP to break</text>
<rect x="16" y="220" width="848" height="54" rx="9" class="svg-card svg-stroke" stroke-width="1.6"/>
<text x="36" y="242" class="svg-label">SSH</text>
<text x="36" y="260" class="svg-sub">authenticated channel</text>
<text x="250" y="242" class="svg-sub">proves who you are, then carries the PTY</text>
<rect x="16" y="282" width="848" height="54" rx="9" class="svg-card svg-stroke" stroke-width="1.6"/>
<text x="36" y="304" class="svg-label">WireGuard / Tailscale</text>
<text x="36" y="322" class="svg-sub">private network</text>
<text x="250" y="304" class="svg-sub">encryption, identity, and no reachable port</text>
<rect x="16" y="344" width="848" height="48" rx="9" class="svg-surface svg-danger-s" stroke-width="1.5" stroke-dasharray="5 3"/>
<text x="36" y="366" class="svg-label">Wi-Fi · LTE · the internet</text>
<text x="36" y="384" class="svg-sub">assumed hostile</text>
</g>
<!-- ============ the climb ============ -->
<path data-at="0" data-flow="accent" data-count="1" data-speed="0.14" data-r="3.5" d="M8 392 L8 366" stroke="none" fill="none"/>
<path data-at="1" data-flow="accent" data-count="2" data-speed="0.14" data-r="3.5" d="M8 392 L8 309" stroke="none" fill="none"/>
<path data-at="2" data-flow="accent" data-count="2" data-speed="0.14" data-r="3.5" d="M8 392 L8 247" stroke="none" fill="none"/>
<path data-at="3" data-flow="accent" data-count="3" data-speed="0.14" data-r="3.5" d="M8 392 L8 185" stroke="none" fill="none"/>
<path data-at="4" data-flow="accent" data-count="3" data-speed="0.14" data-r="3.5" d="M8 392 L8 123" stroke="none" fill="none"/>
<path data-at="5" data-flow="accent" data-count="4" data-speed="0.14" data-r="3.5" d="M8 392 L8 61" stroke="none" fill="none"/>
<!-- ============ step 0 · the network, and what happened ============ -->
<g data-at="0+" data-scn="ip sleep kill reboot" data-poster class="sim-pop">
<rect x="612" y="357" width="250" height="26" rx="7" class="svg-surface svg-stroke" stroke-width="1.3" stroke-dasharray="5 4"/>
</g>
<text data-at="0+" data-scn="ip" data-poster x="737" y="374" text-anchor="middle" class="svg-mono-warn">Wi-Fi → LTE</text>
<text data-at="0+" data-scn="sleep" x="737" y="374" text-anchor="middle" class="svg-mono-warn">20 min asleep</text>
<text data-at="0+" data-scn="kill" x="737" y="374" text-anchor="middle" class="svg-mono-warn">client closed</text>
<text data-at="0+" data-scn="reboot" x="737" y="374" text-anchor="middle" class="svg-mono-warn">host restarted</text>
<text data-at="0+" data-scn="ip" data-poster x="250" y="384" class="svg-sub">the handoff happens mid-sentence — your IP changes</text>
<text data-at="0+" data-scn="sleep" x="250" y="384" class="svg-sub">the phone suspends the app and the radio sleeps</text>
<text data-at="0+" data-scn="kill" x="250" y="384" class="svg-sub">the client is gone, and so is anything it held</text>
<text data-at="0+" data-scn="reboot" x="250" y="384" class="svg-sub">the machine you were working on restarts</text>
<rect data-at="0" data-scn="ip sleep kill reboot" x="16" y="344" width="848" height="48" rx="9"
fill="none" class="svg-warn-s" stroke-width="2.6"/>
<!-- ============ step 1 · WireGuard / Tailscale ============ -->
<g data-at="1+" data-scn="ip sleep kill reboot" data-poster class="sim-pop">
<rect x="612" y="295" width="250" height="28" rx="7" class="svg-card svg-accent-s" stroke-width="1.5"/>
</g>
<text data-at="1+" data-scn="ip" data-poster x="737" y="313" text-anchor="middle" class="svg-mono-accent">holds · same 100.x</text>
<text data-at="1+" data-scn="sleep" x="737" y="313" text-anchor="middle" class="svg-mono-accent">reconnects on wake</text>
<text data-at="1+" data-scn="kill" x="737" y="313" text-anchor="middle" class="svg-mono-accent">nothing left to carry</text>
<text data-at="1+" data-scn="reboot" x="737" y="313" text-anchor="middle" class="svg-mono-accent">rejoins at boot</text>
<text data-at="1+" data-scn="ip" data-poster x="250" y="322" class="svg-sub">the tailnet address does not change with the network</text>
<text data-at="1+" data-scn="sleep" x="250" y="322" class="svg-sub">the tunnel comes back by itself, same address</text>
<text data-at="1+" data-scn="kill" x="250" y="322" class="svg-sub">the tunnel is fine — there is simply no client on it</text>
<text data-at="1+" data-scn="reboot" x="250" y="322" class="svg-sub">tailscaled starts on boot and takes the same address</text>
<rect data-at="1" data-scn="ip sleep kill reboot" x="16" y="282" width="848" height="54" rx="9"
fill="none" class="svg-accent-s" stroke-width="2.6"/>
<!-- ============ step 2 · SSH ============ -->
<g data-at="2+" data-scn="ip sleep kill reboot" data-poster class="sim-pop">
<rect x="612" y="233" width="250" height="28" rx="7" class="svg-card svg-danger-s" stroke-width="1.7"/>
</g>
<text data-at="2+" data-scn="ip" data-poster x="737" y="251" text-anchor="middle" class="svg-mono-danger">dead · broken pipe</text>
<text data-at="2+" data-scn="sleep" x="737" y="251" text-anchor="middle" class="svg-mono-danger">dead · timed out</text>
<text data-at="2+" data-scn="kill" x="737" y="251" text-anchor="middle" class="svg-mono-danger">dead with the client</text>
<text data-at="2+" data-scn="reboot" x="737" y="251" text-anchor="middle" class="svg-mono-danger">dead · host restarted</text>
<text data-at="2+" data-scn="ip" data-poster x="250" y="260" class="svg-sub">TCP is pinned to the old IP — it cannot follow you</text>
<text data-at="2+" data-scn="sleep" x="250" y="260" class="svg-sub">the TCP session expired while the app was suspended</text>
<text data-at="2+" data-scn="kill" x="250" y="260" class="svg-sub">the connection lived inside the process you closed</text>
<text data-at="2+" data-scn="reboot" x="250" y="260" class="svg-sub">every session on that machine ended at once</text>
<rect data-at="2" data-scn="ip sleep kill reboot" x="16" y="220" width="848" height="54" rx="9"
fill="none" class="svg-danger-s" stroke-width="2.6"/>
<!-- ============ step 3 · Mosh ============ -->
<g data-at="3+" data-scn="ip sleep" data-poster class="sim-pop">
<rect x="612" y="171" width="250" height="28" rx="7" class="svg-card svg-accent-s" stroke-width="1.5"/>
</g>
<g data-at="3+" data-scn="kill reboot" class="sim-pop">
<rect x="612" y="171" width="250" height="28" rx="7" class="svg-card svg-danger-s" stroke-width="1.7"/>
</g>
<text data-at="3+" data-scn="ip" data-poster x="737" y="189" text-anchor="middle" class="svg-mono-accent">survives · re-homes</text>
<text data-at="3+" data-scn="sleep" x="737" y="189" text-anchor="middle" class="svg-mono-accent">resumes in seconds</text>
<text data-at="3+" data-scn="kill" x="737" y="189" text-anchor="middle" class="svg-mono-danger">gone with the client</text>
<text data-at="3+" data-scn="reboot" x="737" y="189" text-anchor="middle" class="svg-mono-danger">gone · it is a process</text>
<text data-at="3+" data-scn="ip" data-poster x="250" y="198" class="svg-sub">no connection to break — it just uses the new IP</text>
<text data-at="3+" data-scn="sleep" x="250" y="198" class="svg-sub">the client re-syncs screen state on wake and catches up</text>
<text data-at="3+" data-scn="kill" x="250" y="198" class="svg-sub">mosh-client held the state; closing it ends the session</text>
<text data-at="3+" data-scn="reboot" x="250" y="198" class="svg-sub">mosh-server died with everything else on the host</text>
<rect data-at="3" data-scn="ip sleep" x="16" y="158" width="848" height="54" rx="9"
fill="none" class="svg-accent-s" stroke-width="2.6"/>
<rect data-at="3" data-scn="kill reboot" x="16" y="158" width="848" height="54" rx="9"
fill="none" class="svg-danger-s" stroke-width="2.6"/>
<!-- ============ step 4 · tmux ============ -->
<g data-at="4+" data-scn="ip sleep kill" data-poster class="sim-pop">
<rect x="612" y="109" width="250" height="28" rx="7" class="svg-card svg-accent-s" stroke-width="1.5"/>
</g>
<g data-at="4+" data-scn="reboot" class="sim-pop">
<rect x="612" y="109" width="250" height="28" rx="7" class="svg-card svg-danger-s" stroke-width="1.7"/>
</g>
<text data-at="4+" data-scn="ip sleep" data-poster x="737" y="127" text-anchor="middle" class="svg-mono-accent">never noticed</text>
<text data-at="4+" data-scn="kill" x="737" y="127" text-anchor="middle" class="svg-mono-accent">your work is alive</text>
<text data-at="4+" data-scn="reboot" x="737" y="127" text-anchor="middle" class="svg-mono-danger">gone · the honest limit</text>
<text data-at="4+" data-scn="ip sleep" data-poster x="250" y="136" class="svg-sub">it was never attached to your phone in the first place</text>
<text data-at="4+" data-scn="kill" x="250" y="136" class="svg-sub">processes keep running with no terminal attached at all</text>
<text data-at="4+" data-scn="reboot" x="250" y="136" class="svg-sub">tmux is a process too — sessions do not survive a reboot</text>
<rect data-at="4" data-scn="ip sleep kill" x="16" y="96" width="848" height="54" rx="9"
fill="none" class="svg-accent-s" stroke-width="2.6"/>
<rect data-at="4" data-scn="reboot" x="16" y="96" width="848" height="54" rx="9"
fill="none" class="svg-danger-s" stroke-width="2.6"/>
<!-- ============ step 5 · Herdr ============ -->
<g data-at="5" data-scn="ip sleep kill" data-poster class="sim-pop">
<rect x="612" y="47" width="250" height="28" rx="7" class="svg-card svg-accent-s" stroke-width="1.5"/>
</g>
<g data-at="5" data-scn="reboot" class="sim-pop">
<rect x="612" y="47" width="250" height="28" rx="7" class="svg-card svg-warn-s" stroke-width="1.7"/>
</g>
<text data-at="5" data-scn="ip" data-poster x="737" y="65" text-anchor="middle" class="svg-mono-accent">panes carry through</text>
<text data-at="5" data-scn="sleep" x="737" y="65" text-anchor="middle" class="svg-mono-accent">shows what changed</text>
<text data-at="5" data-scn="kill" x="737" y="65" text-anchor="middle" class="svg-mono-accent">reattach, unchanged</text>
<text data-at="5" data-scn="reboot" x="737" y="65" text-anchor="middle" class="svg-mono-warn">brings the panes back</text>
<text data-at="5" data-scn="ip" data-poster x="250" y="74" class="svg-sub">every agent kept running and kept its status</text>
<text data-at="5" data-scn="sleep" x="250" y="74" class="svg-sub">which agents finished, and which are waiting on you</text>
<text data-at="5" data-scn="kill" x="250" y="74" class="svg-sub">one command puts you back in front of the same panes</text>
<text data-at="5" data-scn="reboot" x="250" y="74" class="svg-sub">resume_agents_on_restore restores the conversations</text>
<rect data-at="5" data-scn="ip sleep kill" x="16" y="34" width="848" height="54" rx="9"
fill="none" class="svg-accent-s" stroke-width="2.6"/>
<rect data-at="5" data-scn="reboot" x="16" y="34" width="848" height="54" rx="9"
fill="none" class="svg-warn-s" stroke-width="2.6"/>
<!-- ============ the verdict ============ -->
<text data-at="5" data-scn="ip" data-poster x="16" y="418" class="svg-mono-accent">you never noticed — Mosh absorbed the whole thing</text>
<text data-at="5" data-scn="sleep" x="16" y="418" class="svg-mono-accent">a second of catch-up on wake, then you are typing again</text>
<text data-at="5" data-scn="kill" x="16" y="418" class="svg-mono-accent">one command and you are back where you were</text>
<text data-at="5" data-scn="reboot" x="16" y="418" class="svg-mono-warn">the one failure that costs you work — plan reboots</text>
</svg>
<div class="sim-controls">
<button type="button" class="sim-btn" data-act="prev" aria-label="Previous step"><span class="sim-glyph">◀</span></button>
<button type="button" class="sim-btn sim-btn-play" data-act="play" aria-label="Play">
<span class="sim-glyph">▶</span><span class="sim-btn-text">Play</span>
</button>
<button type="button" class="sim-btn" data-act="next" aria-label="Next step"><span class="sim-glyph">▶</span></button>
<div class="sim-track" role="group" aria-label="Jump to layer">
<button type="button" class="sim-dot" data-goto="0" aria-label="Step 0, the network">0</button>
<button type="button" class="sim-dot" data-goto="1" aria-label="Layer 1, WireGuard and Tailscale">1</button>
<button type="button" class="sim-dot" data-goto="2" aria-label="Layer 2, SSH">2</button>
<button type="button" class="sim-dot" data-goto="3" aria-label="Layer 3, Mosh">3</button>
<button type="button" class="sim-dot" data-goto="4" aria-label="Layer 4, tmux">4</button>
<button type="button" class="sim-dot" data-goto="5" aria-label="Layer 5, Herdr">5</button>
</div>
<div class="sim-progress" aria-hidden="true"><span></span></div>
</div>
<div class="sim-term" aria-hidden="true">
<div data-at="0" data-scn="ip"><span class="tp">#</span> the train leaves the station<br><span class="tdim">en0 down · pdp_ip0 up — new public IP</span></div>
<div data-at="0" data-scn="sleep"><span class="tp">#</span> phone into pocket, screen off<br><span class="tdim">the phone suspends the app after a few seconds</span></div>
<div data-at="0" data-scn="kill"><span class="tp">#</span> you swipe the terminal away<br><span class="tdim">no client process, no local state, nothing held</span></div>
<div data-at="0" data-scn="reboot"><span class="tp">$</span> sudo reboot<br><span class="tdim">Connection to macbook closed by remote host.</span></div>
<div data-at="1"><span class="tp">$</span> tailscale ip -4<br>100.117.202.65 <span class="tok">— unchanged</span></div>
<div data-at="2" data-scn="ip sleep"><span class="tp">$</span> ssh macbook<br><span class="twarn">client_loop: send disconnect: Broken pipe</span></div>
<div data-at="2" data-scn="kill"><span class="tp">#</span> the ssh process went with the app<br><span class="twarn">no session, no PTY, nothing to reattach to</span></div>
<div data-at="2" data-scn="reboot"><span class="tp">#</span> every sshd session on the host ended<br><span class="twarn">Connection reset by peer</span></div>
<div data-at="3" data-scn="ip"><span class="tp">$</span> mosh macbook<br><span class="tok">[mosh] roaming to 10.44.7.19 — no reconnect needed</span></div>
<div data-at="3" data-scn="sleep"><span class="tp">$</span> mosh macbook<br><span class="twarn">[mosh] Last contact 21:04:11 …</span> <span class="tok">resumed</span></div>
<div data-at="3" data-scn="kill"><span class="tdim"># mosh-client is gone, and it held the session state</span><br><span class="twarn">[mosh] no client to resume from</span></div>
<div data-at="3" data-scn="reboot"><span class="tdim"># mosh-server is a process on the host</span><br><span class="twarn">it died with everything else</span></div>
<div data-at="4" data-scn="ip sleep"><span class="tp">$</span> tmux ls<br>phone: 3 windows (created Mon Aug 18) <span class="tok">(attached)</span></div>
<div data-at="4" data-scn="kill"><span class="tp">$</span> tmux ls<br>phone: 3 windows (created Mon Aug 18) <span class="tok">— still running, detached</span></div>
<div data-at="4" data-scn="reboot"><span class="tp">$</span> tmux ls<br><span class="twarn">no server running on /tmp/tmux-501/default</span></div>
<div data-at="5" data-scn="ip sleep kill"><span class="tp">$</span> mosh macbook -- tmux new -A -s phone<br><span class="tok">herdr · 5 panes · 1 blocked</span> <span class="tdim">— exactly where you left it</span></div>
<div data-at="5" data-scn="reboot"><span class="tp">$</span> mosh macbook -- tmux new -A -s phone<br><span class="twarn">herdr · panes restored, scrollback gone</span> <span class="tdim">— start the agents again</span></div>
</div>
<ol class="sim-steps" aria-live="polite">
<li data-at="0" data-scn="ip" data-poster><b>Your IP changes</b>The train leaves the station and the phone hands off from Wi-Fi to LTE. Your public address changes mid-sentence. Nothing you did caused it and nothing warns you first — this is the most common failure of the four by a wide margin.</li>
<li data-at="0" data-scn="sleep"><b>Twenty minutes in a pocket</b>The screen goes off, the phone suspends the app, and the radio goes quiet. From the server's point of view your client simply stopped talking, which is indistinguishable from your client having died.</li>
<li data-at="0" data-scn="kill"><b>You close the terminal</b>Not a network problem at all. The client process is gone, and so is everything it was holding in memory. Anything that lived only on the phone is now unrecoverable.</li>
<li data-at="0" data-scn="reboot"><b>The host reboots</b>A kernel update, a power cut, or you typing <code>sudo reboot</code>. This one is different from the other three: the failure is on the far side, where all of your actual work lives.</li>
<li data-at="1"><b>1 · WireGuard / Tailscale holds</b>The tailnet address is tied to the device, not to whatever network it is standing on. It survives the handoff, comes back on wake, and returns at boot. This layer is about <em>reachability and identity</em> — it has no opinion at all about your session.</li>
<li data-at="2"><b>2 · SSH dies — in every one of the four</b>SSH is a TCP connection, and TCP is pinned to a pair of addresses for its whole life. Change one, stay quiet too long, kill either end, and it is over: <code>client_loop: send disconnect: Broken pipe</code>. SSH is not a persistence layer and was never trying to be.</li>
<li data-at="3" data-scn="ip" data-poster><b>3 · Mosh does not care</b>There is no connection to break. Mosh syncs screen <em>state</em> over UDP, so a new IP is just a new return address. You keep typing — with local echo, the keystrokes were never waiting on the network anyway.</li>
<li data-at="3" data-scn="sleep"><b>3 · Mosh picks up where it stopped</b>On wake, the client sends its last known state and the server sends the difference. A second of catch-up, no reconnect, no lost scrollback. This is the layer that makes a phone a usable terminal.</li>
<li data-at="3" data-scn="kill"><b>3 · Mosh goes with the client</b>The session state lived in <code>mosh-client</code>. Closing the app took it with you, and a fresh client cannot adopt the old session. Mosh survives bad networks, not a dead client — which is exactly what the layer above it is for.</li>
<li data-at="3" data-scn="reboot"><b>3 · Mosh is a process on that host</b><code>mosh-server</code> died with everything else when the machine went down. No transport can survive the disappearance of the thing it was talking to.</li>
<li data-at="4" data-scn="ip sleep" data-poster><b>4 · tmux never noticed</b>Your processes were never attached to the phone. They are attached to a tmux server on the far machine, which spent the whole incident doing nothing unusual. Nothing here needed rescuing — but the two failures below did not need this layer either.</li>
<li data-at="4" data-scn="kill"><b>4 · tmux is what saves you</b>This is the failure the layer exists for. Your shells, builds and agents keep running with no terminal attached at all, because they never depended on one. The phone was a window, not a container.</li>
<li data-at="4" data-scn="reboot"><b>4 · tmux is the honest limit</b>tmux is a process too. When the host restarts, the tmux server and every process inside it go with it. Nothing in this stack survives a reboot of the machine your work runs on — which is why long jobs belong in something that writes to disk.</li>
<li data-at="5" data-scn="ip" data-poster><b>5 · Herdr carried straight through</b>Every agent pane kept running and kept its status. Five layers, one failure, and the only one that had to do any work was Mosh. Each layer sits idle until the specific failure it owns shows up. <b>That idleness is the design, not waste.</b></li>
<li data-at="5" data-scn="sleep"><b>5 · Herdr tells you what changed</b>You come back to a page that already says which agents finished while you were away and which are blocked on you. Twenty minutes of not looking cost you nothing, and you do not have to open five panes to find out.</li>
<li data-at="5" data-scn="kill"><b>5 · Reattach, unchanged</b><code>mosh macbook -- tmux new -A -s phone</code> puts you back in front of the same panes with the same agents. Closing the app was never destructive, which is what makes the phone usable as a real client.</li>
<li data-at="5" data-scn="reboot"><b>5 · Herdr rebuilds the room, not the work</b>With <code>resume_agents_on_restore</code> set, Herdr brings the agent conversations back after its server restarts. Your layout and context return; the scrollback and any half-finished build do not. This is the failure worth planning for — the other three plan for themselves.</li>
</ol>
<figcaption>
<b>Read it from the bottom.</b> SSH alone survives nothing: it rides on a TCP connection, and
when your phone's IP changes or iOS suspends the app, that connection is gone —
<code>client_loop: send disconnect: Broken pipe</code>. Mosh removes the TCP dependency.
tmux removes the client dependency entirely. Herdr adds awareness of what your agents are
doing. Switch the failure above and watch how few layers do any work in each one — that
idleness is the design, not waste.
</figcaption>
</figure>
<div class="callout tip">
<div class="ct">The one-line version</div>
<p>
Tailscale decides <em>who can reach the port</em>. SSH decides <em>who can log in</em>.
Mosh decides <em>whether a bad network ends your session</em>. tmux decides
<em>whether closing your phone ends your work</em>. Confusing these four is the root of
most bad setups.
</p>
</div>
<h2>What you will actually type</h2>
<p>
The end state is short enough to memorise. From Blink Shell on the phone:
</p>
<div class="filename">Blink Shell — the daily command</div>
<pre><code>mosh macbook -- tmux new -A -s phone</code></pre>
<p>
That single line rides an encrypted WireGuard tunnel to a host that is invisible to the rest
of the internet, authenticates with a key that cannot be extracted from your phone, survives
you walking into a lift, and reattaches to a session that has been running for a week. Inside
it, Herdr holds your coding agents.
</p>
<h2>Chapters</h2>
<p>
The first four explain the technologies well enough that the build chapters are obvious rather
than magical. If you only want it working, jump to chapter 05 and refer back.
</p>
<div class="chapter-cards" id="cards"></div>
<p>
And one part of this repository that is not a chapter:
<a href="lab/README.md"><code>lab/</code></a> is the same topology as real containers —
four machines on four isolated segments, each behind its own NAT, running real
<code>tailscaled</code> on real TUN devices, with a control page where every switch is a
command that runs inside a container. <code>docker compose up</code> and a browser is the
whole setup; Docker is the only prerequisite. It is the counterpart to
<a href="chapters/14-sandbox.html">chapter 14</a> — the sandbox can only show you what
somebody modelled, and the lab has no model, so it can surprise you. It already has, and
every surprise is written up with what we measured and which of the two we corrected.
</p>
<div class="callout info">
<div class="ct">How to read this</div>
<p>
Checklists throughout remember what you have ticked, per chapter, in this browser. The
theme toggle sits in the bottom-right. Every command block has a copy button. Nothing here
phones home, and the whole guide works offline from the filesystem.
</p>
</div>
<h2 id="disclaimer">Disclaimer</h2>
<p>
This guide is published under <a href="LICENSE-docs">CC BY-SA 4.0</a>, and the code that
drives it under <a href="LICENSE">GPL-3.0-or-later</a>. Both come
with <strong>no warranty of any kind</strong>. Read that as it is meant: nobody who wrote,
reviewed or contributed to this is responsible for what happens on your machines.
</p>
<p>
Take that seriously rather than as boilerplate, because of what the commands here actually
do. They change firewall rules, disable password login, rewrite <code>sshd_config</code>,
and deliberately remove the only route into a machine you may be a long way from. Done in
the wrong order, on a box you cannot walk over to, several of them will lock you out — and
the guide says so, loudly, at every point where that is a real risk. Those warnings are not
decoration.
</p>
<div class="callout danger">
<div class="ct">Before you run any of it against something you care about</div>
<ul>
<li>
<b>Your environment is not this one.</b> Versions drift, distributions differ, your
provider's networking is its own. A command that is correct here can be wrong for you.
Understand what a line does before you run it — the guide explains the why for exactly
this reason.
</li>
<li>
<b>Practise somewhere disposable.</b>
<a href="chapters/14-sandbox.html">Chapter 14</a> is a simulation and costs nothing;
<a href="chapters/13-lab.html">chapter 13</a> builds throwaway VMs;
<a href="lab/README.md"><code>lab/</code></a> builds throwaway containers in one
command. None of them is your production box, and a green result in any of them is not
a promise about your real one.
</li>
<li>
<b>Keep a second way in.</b> A second SSH session, your provider's web console, physical
access — proven working <em>before</em> you change anything, not assumed.
</li>
<li>
<b>The decision is yours, and so is the outcome.</b> You accept the risk of running any
of this. If something breaks, that is not a fault of this repository, its owner, or any
contributor.
</li>
</ul>
</div>
<p>
None of that is a reason not to do the work. It is a reason to do it in the order the guide
gives you, on something you can afford to break first.
</p>
<nav class="pager"></nav>
<p class="site-foot">The commands in this guide change firewall and login settings, and can lock you out of a machine. Practise on something disposable first. Everything here is provided <b>as is</b>, with no warranty — you accept the risk of running it. <a href="#disclaimer">Read the disclaimer</a>.</p>
</div>
</main>
<aside class="toc"></aside>
</div>
<script src="assets/nav.js"></script>
<script src="assets/app.js"></script>
<script src="assets/anim.js"></script>
<script>
(function () {
var host = document.getElementById("cards");
if (!host || !window.GUIDE_FLAT) return;
host.innerHTML = window.GUIDE_FLAT.filter(function (c) { return c.id !== "index"; })
.map(function (c) {
return '<a href="' + c.file + '"><div class="n">' + c.n + '</div>' +
'<div class="t">' + c.title + '</div>' +
'<div class="d">' + c.desc + '</div></a>';
}).join("");
})();
</script>
</body>
</html>