OXYGENHUB

Swarm: TLS between peers, pinned by the hub ecce3719da80

Each runner makes its own TLS certificate on first use and keeps it, and
reports its BLAKE3 with its swarm address; the hub hands it on with the
peer. A fetcher trusts only that certificate, with the handshake's
signature checked against it, so no CA is needed and tickets, sessions and
chunks are unreadable on the network. A busy peer answers 503 inside TLS
and is asked again rather than dropped. Plain HTTP is gone.

closes #48
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 16:03 UTC
Signature
✔ signed by Andy McCall
Hash
ecce3719da80c26416452cf173e50d4275e23c78b571eabb139bc91fdb5dea6e
Depends on
2814f4213e7c 59078d2b8d46 b14aeaae683a c3db750ed96e e0918b887850 f849c7889471
Depended on by
3554afefb6aa 28cc603f737d d60aac6c3f8a
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
78 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +1 −1
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 -
- **Runners share images (#47, docs/jetstream.md §8.1).** A runner with a `[runner.swarm]` section serves the image chunks it holds to other runners in its site, and fetches an image it lacks from them and the hub together, rarest chunks first. The hub is asked only for chunks no peer has or is fetching, so runners starting together split its work. Peers admit each other with a ticket the hub signs, and every chunk is checked against its hash. Uploads are capped (`upload_mib_s`, default 50) at 16 connections. Without the section a runner fetches from the hub alone, as before.
7 +
- **Runners share images (#47, docs/jetstream.md §8.1).** A runner with a `[runner.swarm]` section serves the image chunks it holds to other runners in its site, and fetches an image it lacks from them and the hub together, rarest chunks first. The hub is asked only for chunks no peer has or is fetching, so runners starting together split its work. Peers talk over TLS: each runner keeps its own certificate, the hub hands its fingerprint on with the peer, and a fetcher trusts no other. Peers admit each other with a ticket the hub signs, and every chunk is checked against its hash. Uploads are capped (`upload_mib_s`, default 50) at 16 connections. Without the section a runner fetches from the hub alone, as before.
8 8
9 9
- **A runner declines a job it cannot run (#36, spec §11.5).** Before fetching anything it checks the tier, the architecture, that the image is there and verifies, and that the sandbox's memory is free. If any check fails it hands the job back to the hub with the reason instead of running and failing it.
10 10
…
25 unchanged lines
modified Cargo.lock +273 −7
…
79 unchanged lines
80 80
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
81 81
82 82
[[package]]
83 +
name = "asn1-rs"
84 +
version = "0.7.2"
85 +
source = "registry+https://github.com/rust-lang/crates.io-index"
86 +
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
87 +
dependencies = [
88 +
 "asn1-rs-derive",
89 +
 "asn1-rs-impl",
90 +
 "displaydoc",
91 +
 "nom",
92 +
 "num-traits",
93 +
 "rusticata-macros",
94 +
 "thiserror 2.0.21",
95 +
 "time",
96 +
]
97 +
98 +
[[package]]
99 +
name = "asn1-rs-derive"
100 +
version = "0.6.0"
101 +
source = "registry+https://github.com/rust-lang/crates.io-index"
102 +
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
103 +
dependencies = [
104 +
 "proc-macro2",
105 +
 "quote",
106 +
 "syn 2.0.119",
107 +
 "synstructure 0.13.2",
108 +
]
109 +
110 +
[[package]]
111 +
name = "asn1-rs-impl"
112 +
version = "0.2.0"
113 +
source = "registry+https://github.com/rust-lang/crates.io-index"
114 +
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
115 +
dependencies = [
116 +
 "proc-macro2",
117 +
 "quote",
118 +
 "syn 2.0.119",
119 +
]
120 +
121 +
[[package]]
83 122
name = "autocfg"
84 123
version = "1.5.1"
85 124
source = "registry+https://github.com/rust-lang/crates.io-index"
…
4 unchanged lines
90 129
version = "0.22.1"
91 130
source = "registry+https://github.com/rust-lang/crates.io-index"
92 131
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
132 +
133 +
[[package]]
134 +
name = "base64"
135 +
version = "0.23.1"
136 +
source = "registry+https://github.com/rust-lang/crates.io-index"
137 +
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
93 138
94 139
[[package]]
95 140
name = "base64ct"
…
2 unchanged lines
98 143
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
99 144
100 145
[[package]]
146 +
name = "bit-vec"
147 +
version = "0.9.1"
148 +
source = "registry+https://github.com/rust-lang/crates.io-index"
149 +
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
150 +
dependencies = [
151 +
 "serde",
152 +
]
153 +
154 +
[[package]]
101 155
name = "bitflags"
102 156
version = "2.13.2"
103 157
source = "registry+https://github.com/rust-lang/crates.io-index"
…
211 unchanged lines
315 369
]
316 370
317 371
[[package]]
372 +
name = "data-encoding"
373 +
version = "2.11.1"
374 +
source = "registry+https://github.com/rust-lang/crates.io-index"
375 +
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
376 +
377 +
[[package]]
318 378
name = "der"
319 379
version = "0.8.2"
320 380
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1 unchanged lines
322 382
dependencies = [
323 383
 "const-oid",
324 384
 "zeroize",
385 +
]
386 +
387 +
[[package]]
388 +
name = "der-parser"
389 +
version = "10.0.0"
390 +
source = "registry+https://github.com/rust-lang/crates.io-index"
391 +
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
392 +
dependencies = [
393 +
 "asn1-rs",
394 +
 "displaydoc",
395 +
 "nom",
396 +
 "num-bigint",
397 +
 "num-traits",
398 +
 "rusticata-macros",
325 399
]
326 400
327 401
[[package]]
402 +
name = "deranged"
403 +
version = "0.5.8"
404 +
source = "registry+https://github.com/rust-lang/crates.io-index"
405 +
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
406 +
407 +
[[package]]
328 408
name = "digest"
329 409
version = "0.11.3"
330 410
source = "registry+https://github.com/rust-lang/crates.io-index"
…
100 unchanged lines
431 511
 "jet",
432 512
 "libc",
433 513
 "oxy-core",
514 +
 "rcgen",
515 +
 "rustls",
434 516
 "serde",
435 517
 "serde_json",
436 518
 "tempfile",
…
24 unchanged lines
461 543
 "oxy-core",
462 544
 "serde",
463 545
 "serde_json",
464 -
 "thiserror",
546 +
 "thiserror 1.0.69",
465 547
]
466 548
467 549
[[package]]
…
245 unchanged lines
713 795
 "serde",
714 796
 "serde_json",
715 797
 "tempfile",
716 -
 "thiserror",
798 +
 "thiserror 1.0.69",
717 799
]
718 800
719 801
[[package]]
…
29 unchanged lines
749 831
]
750 832
751 833
[[package]]
834 +
name = "lazy_static"
835 +
version = "1.5.1"
836 +
source = "registry+https://github.com/rust-lang/crates.io-index"
837 +
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
838 +
839 +
[[package]]
752 840
name = "libc"
753 841
version = "0.2.189"
754 842
source = "registry+https://github.com/rust-lang/crates.io-index"
…
44 unchanged lines
799 887
 "quote",
800 888
 "syn 2.0.119",
801 889
]
890 +
891 +
[[package]]
892 +
name = "minimal-lexical"
893 +
version = "0.2.1"
894 +
source = "registry+https://github.com/rust-lang/crates.io-index"
895 +
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
802 896
803 897
[[package]]
804 898
name = "miniz_oxide"
…
42 unchanged lines
847 941
 "cfg-if",
848 942
 "cfg_aliases",
849 943
 "libc",
944 +
]
945 +
946 +
[[package]]
947 +
name = "nom"
948 +
version = "7.1.3"
949 +
source = "registry+https://github.com/rust-lang/crates.io-index"
950 +
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
951 +
dependencies = [
952 +
 "memchr",
953 +
 "minimal-lexical",
954 +
]
955 +
956 +
[[package]]
957 +
name = "num-bigint"
958 +
version = "0.4.8"
959 +
source = "registry+https://github.com/rust-lang/crates.io-index"
960 +
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
961 +
dependencies = [
962 +
 "num-integer",
963 +
 "num-traits",
964 +
]
965 +
966 +
[[package]]
967 +
name = "num-conv"
968 +
version = "0.2.2"
969 +
source = "registry+https://github.com/rust-lang/crates.io-index"
970 +
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
971 +
972 +
[[package]]
973 +
name = "num-integer"
974 +
version = "0.1.47"
975 +
source = "registry+https://github.com/rust-lang/crates.io-index"
976 +
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
977 +
dependencies = [
978 +
 "num-traits",
850 979
]
851 980
852 981
[[package]]
…
3 unchanged lines
856 985
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
857 986
dependencies = [
858 987
 "autocfg",
988 +
]
989 +
990 +
[[package]]
991 +
name = "oid-registry"
992 +
version = "0.8.1"
993 +
source = "registry+https://github.com/rust-lang/crates.io-index"
994 +
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
995 +
dependencies = [
996 +
 "asn1-rs",
859 997
]
860 998
861 999
[[package]]
…
23 unchanged lines
885 1023
 "rustls",
886 1024
 "rustls-pemfile",
887 1025
 "similar",
888 -
 "thiserror",
1026 +
 "thiserror 1.0.69",
889 1027
 "ureq",
890 1028
 "webpki-roots 1.0.9",
891 1029
 "zstd",
…
7 unchanged lines
899 1037
dependencies = [
900 1038
 "libc",
901 1039
 "winapi",
1040 +
]
1041 +
1042 +
[[package]]
1043 +
name = "pem"
1044 +
version = "4.0.0"
1045 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1046 +
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
1047 +
dependencies = [
1048 +
 "base64 0.23.1",
1049 +
 "serde_core",
902 1050
]
903 1051
904 1052
[[package]]
…
26 unchanged lines
931 1079
dependencies = [
932 1080
 "zerovec",
933 1081
]
1082 +
1083 +
[[package]]
1084 +
name = "powerfmt"
1085 +
version = "0.2.1"
1086 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1087 +
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
934 1088
935 1089
[[package]]
936 1090
name = "ppv-lite86"
…
85 unchanged lines
1022 1176
]
1023 1177
1024 1178
[[package]]
1179 +
name = "rcgen"
1180 +
version = "0.14.10"
1181 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1182 +
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
1183 +
dependencies = [
1184 +
 "pem",
1185 +
 "ring",
1186 +
 "rustls-pki-types",
1187 +
 "time",
1188 +
 "x509-parser",
1189 +
 "yasna",
1190 +
]
1191 +
1192 +
[[package]]
1025 1193
name = "regex-automata"
1026 1194
version = "0.4.18"
1027 1195
source = "registry+https://github.com/rust-lang/crates.io-index"
…
31 unchanged lines
1059 1227
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1060 1228
dependencies = [
1061 1229
 "semver",
1230 +
]
1231 +
1232 +
[[package]]
1233 +
name = "rusticata-macros"
1234 +
version = "4.1.0"
1235 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1236 +
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
1237 +
dependencies = [
1238 +
 "nom",
1062 1239
]
1063 1240
1064 1241
[[package]]
…
234 unchanged lines
1299 1476
1300 1477
[[package]]
1301 1478
name = "synstructure"
1479 +
version = "0.13.2"
1480 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1481 +
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1482 +
dependencies = [
1483 +
 "proc-macro2",
1484 +
 "quote",
1485 +
 "syn 2.0.119",
1486 +
]
1487 +
1488 +
[[package]]
1489 +
name = "synstructure"
1302 1490
version = "0.14.0"
1303 1491
source = "registry+https://github.com/rust-lang/crates.io-index"
1304 1492
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
…
21 unchanged lines
1326 1514
version = "1.0.69"
1327 1515
source = "registry+https://github.com/rust-lang/crates.io-index"
1328 1516
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1517 +
dependencies = [
1518 +
 "thiserror-impl 1.0.69",
1519 +
]
1520 +
1521 +
[[package]]
1522 +
name = "thiserror"
1523 +
version = "2.0.21"
1524 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1525 +
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
1329 1526
dependencies = [
1330 -
 "thiserror-impl",
1527 +
 "thiserror-impl 2.0.21",
1331 1528
]
1332 1529
1333 1530
[[package]]
…
5 unchanged lines
1339 1536
 "proc-macro2",
1340 1537
 "quote",
1341 1538
 "syn 2.0.119",
1539 +
]
1540 +
1541 +
[[package]]
1542 +
name = "thiserror-impl"
1543 +
version = "2.0.21"
1544 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1545 +
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
1546 +
dependencies = [
1547 +
 "proc-macro2",
1548 +
 "quote",
1549 +
 "syn 3.0.6",
1550 +
]
1551 +
1552 +
[[package]]
1553 +
name = "time"
1554 +
version = "0.3.55"
1555 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1556 +
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
1557 +
dependencies = [
1558 +
 "deranged",
1559 +
 "num-conv",
1560 +
 "powerfmt",
1561 +
 "serde_core",
1562 +
 "time-core",
1563 +
 "time-macros",
1342 1564
]
1343 1565
1344 1566
[[package]]
1567 +
name = "time-core"
1568 +
version = "0.1.9"
1569 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1570 +
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
1571 +
1572 +
[[package]]
1573 +
name = "time-macros"
1574 +
version = "0.2.32"
1575 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1576 +
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
1577 +
dependencies = [
1578 +
 "num-conv",
1579 +
 "time-core",
1580 +
]
1581 +
1582 +
[[package]]
1345 1583
name = "tinystr"
1346 1584
version = "0.8.4"
1347 1585
source = "registry+https://github.com/rust-lang/crates.io-index"
…
74 unchanged lines
1422 1660
source = "registry+https://github.com/rust-lang/crates.io-index"
1423 1661
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
1424 1662
dependencies = [
1425 -
 "base64",
1663 +
 "base64 0.22.1",
1426 1664
 "flate2",
1427 1665
 "log",
1428 1666
 "once_cell",
…
196 unchanged lines
1625 1863
version = "0.6.4"
1626 1864
source = "registry+https://github.com/rust-lang/crates.io-index"
1627 1865
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
1866 +
1867 +
[[package]]
1868 +
name = "x509-parser"
1869 +
version = "0.18.1"
1870 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1871 +
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
1872 +
dependencies = [
1873 +
 "asn1-rs",
1874 +
 "data-encoding",
1875 +
 "der-parser",
1876 +
 "lazy_static",
1877 +
 "nom",
1878 +
 "oid-registry",
1879 +
 "ring",
1880 +
 "rusticata-macros",
1881 +
 "thiserror 2.0.21",
1882 +
 "time",
1883 +
]
1884 +
1885 +
[[package]]
1886 +
name = "yasna"
1887 +
version = "0.6.0"
1888 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1889 +
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
1890 +
dependencies = [
1891 +
 "bit-vec",
1892 +
 "time",
1893 +
]
1628 1894
1629 1895
[[package]]
1630 1896
name = "yoke"
…
15 unchanged lines
1646 1912
 "proc-macro2",
1647 1913
 "quote",
1648 1914
 "syn 3.0.6",
1649 -
 "synstructure",
1915 +
 "synstructure 0.14.0",
1650 1916
]
1651 1917
1652 1918
[[package]]
…
34 unchanged lines
1687 1953
 "proc-macro2",
1688 1954
 "quote",
1689 1955
 "syn 3.0.6",
1690 -
 "synstructure",
1956 +
 "synstructure 0.14.0",
1691 1957
]
1692 1958
1693 1959
[[package]]
…
77 unchanged lines
modified crates/flow-core/src/api.rs +8 −2
…
53 unchanged lines
54 54
    pub swarm: Option<SwarmAddr>,
55 55
}
56 56
57 -
/// A runner's place in the image swarm: the address peers reach it on and
58 -
/// the site it is in; peers are only ever in the same site.
57 +
/// A runner's place in the image swarm: the address peers reach it on,
58 +
/// the site it is in (peers are only ever in the same site), and the
59 +
/// BLAKE3 of its TLS certificate, which peers check it by.
59 60
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60 61
pub struct SwarmAddr {
61 62
    pub addr: String,
62 63
    #[serde(default)]
63 64
    pub site: String,
65 +
    #[serde(default)]
66 +
    pub cert: String,
64 67
}
65 68
66 69
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
…
21 unchanged lines
88 91
    pub runner: i64,
89 92
    pub name: String,
90 93
    pub addr: String,
94 +
    /// The BLAKE3 of its TLS certificate: a peer that shows another is not
95 +
    /// this runner.
96 +
    pub cert: String,
91 97
    /// It holds the whole image (rather than fetching it now).
92 98
    pub complete: bool,
93 99
}
…
232 unchanged lines
modified crates/flow/Cargo.toml +2 −0
…
22 unchanged lines
23 23
ureq = { version = "2", features = ["json"] }
24 24
ignore = "0.4"
25 25
libc = "0.2"
26 +
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
27 +
rcgen = "0.14"
26 28
27 29
[dev-dependencies]
28 30
tempfile.workspace = true
modified crates/flow/src/swarm.rs +227 −38
…
1 unchanged lines
2 2
//! `[runner.swarm]` section serves the image chunks it holds to its peers,
3 3
//! and fetches an image it lacks from its peers and the hub together.
4 4
//!
5 -
//! The server is a small HTTP/1.1 endpoint on the swarm address: a peer
5 +
//! The server is a small HTTPS endpoint on the swarm address: a peer
6 6
//! shows the ticket its hub signed and gets a session, then asks which
7 7
//! chunks of an image this runner has and for chunks by hash. A chunk is
8 8
//! served from the chunk cache, or read out of a whole image by its
9 9
//! manifest. Every chunk is checked by the side that asked for it, so a
10 10
//! peer can only fail to help.
11 +
//!
12 +
//! Each runner makes its own TLS certificate on first use and reports its
13 +
//! BLAKE3 with its swarm address; the hub hands that on with the peer, and
14 +
//! a fetcher talks only to a peer that shows that certificate and holds
15 +
//! its key. Nobody on the network reads the chunks, tickets or sessions.
11 16
12 17
use crate::home::{Home, RunnerConfig};
13 18
use crate::images::{cache_path, chunk_cache, manifest_path, save_manifest, write_image, Client, Moved};
…
2 unchanged lines
16 21
use jet::images::{is_hash, Manifest};
17 22
use jet::swarm::{Bits, Pick, Picker, Source, Ticket};
18 23
use oxy_core::identity::Identity;
24 +
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
25 +
use rustls::crypto::CryptoProvider;
26 +
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime};
27 +
use rustls::{DigitallySignedStruct, SignatureScheme};
19 28
use std::collections::HashMap;
20 29
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
21 30
use std::net::{TcpListener, TcpStream};
…
2 unchanged lines
24 33
use std::sync::{Arc, Mutex};
25 34
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
26 35
27 -
/// Connections served at once; more are told 503 and try elsewhere.
36 +
/// Connections served at once; more are told 503 and come back shortly.
28 37
const MAX_CONNS: usize = 16;
38 +
/// Connections past this are closed without a word.
39 +
const MAX_OPEN: usize = 4 * MAX_CONNS;
29 40
/// Requests in flight to one peer, and to the hub.
30 41
const PEER_WORKERS: usize = 4;
31 42
const HUB_WORKERS: usize = 8;
…
33 unchanged lines
65 76
    /// was last topped up.
66 77
    bucket: Mutex<(f64, Instant)>,
67 78
    rate: f64,
79 +
    tls: Arc<rustls::ServerConfig>,
68 80
}
69 81
70 82
/// This runner's place in the swarm.
71 83
pub struct Swarm {
72 84
    state: Arc<State>,
73 85
    pub addr: SwarmAddr,
74 -
    peer: ureq::Agent,
75 86
}
76 87
77 88
impl Swarm {
…
4 unchanged lines
82 93
        let client = Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?;
83 94
        let hub = client.hub_identity().map_err(|e| eprintln!("swarm: the hub's key: {e:#}; trying again when a peer comes")).ok();
84 95
        let rate = sc.upload_mib_s.unwrap_or(50).max(1) as f64 * (1 << 20) as f64;
96 +
        let (cert, key) = certificate(&home.root)?;
97 +
        let fingerprint = blake3::hash(&cert).to_hex().to_string();
98 +
        let tls = server_tls(cert, key)?;
85 99
        let state = Arc::new(State {
86 100
            root: home.root.clone(),
87 101
            site: sc.site.clone(),
…
5 unchanged lines
93 107
            conns: AtomicUsize::new(0),
94 108
            bucket: Mutex::new((rate, Instant::now())),
95 109
            rate,
110 +
            tls,
96 111
        });
97 112
        // Whole images: offered from their manifests; one without a
98 113
        // manifest (imported here, not fetched) is scanned for one, in the
…
20 unchanged lines
119 134
        });
120 135
        let serving = state.clone();
121 136
        std::thread::spawn(move || accept(listener, serving));
122 -
        let addr = SwarmAddr { addr: sc.advertise.clone().unwrap_or_else(|| sc.listen.clone()), site: sc.site.clone() };
123 -
        eprintln!("swarm: serving image chunks on {} (site {:?}, up to {} MiB/s)", sc.listen, sc.site, sc.upload_mib_s.unwrap_or(50));
124 -
        let peer = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(2)).timeout(Duration::from_secs(20)).build();
125 -
        Ok(Some(Swarm { state, addr, peer }))
137 +
        let addr = SwarmAddr { addr: sc.advertise.clone().unwrap_or_else(|| sc.listen.clone()), site: sc.site.clone(), cert: fingerprint };
138 +
        eprintln!(
139 +
            "swarm: serving image chunks on {} over TLS (certificate {}, site {:?}, up to {} MiB/s)",
140 +
            sc.listen,
141 +
            &addr.cert[..12],
142 +
            sc.site,
143 +
            sc.upload_mib_s.unwrap_or(50)
144 +
        );
145 +
        Ok(Some(Swarm { state, addr }))
126 146
    }
127 147
128 148
    /// Fetch image `hash` into `home` from the peers and the hub (§8.1):
…
92 unchanged lines
221 241
                            o.fetching.clear(i);
222 242
                        }
223 243
                        match (&peer, src) {
244 +
                            // A busy peer is asked again shortly.
245 +
                            (Some(_), _) if e.is::<Busy>() => std::thread::sleep(Duration::from_millis(200)),
224 246
                            (Some(p), Source::Peer(idx)) => {
225 247
                                eprintln!("swarm: peer {} failed ({e:#}); carrying on without it", p.name);
226 248
                                p.alive.store(false, Ordering::Relaxed);
…
42 unchanged lines
269 291
                    for (idx, p) in now {
270 292
                        match self.peer_have(&p, hash, needed.len()) {
271 293
                            Ok((bits, fetching)) => picker.lock().unwrap().peer_fetching(idx, bits, fetching),
294 +
                            Err(e) if e.is::<Busy>() => {}
272 295
                            Err(_) => {
273 296
                                p.alive.store(false, Ordering::Relaxed);
274 297
                                picker.lock().unwrap().drop_peer(idx);
…
30 unchanged lines
305 328
    fn join(&self, found: &[SwarmPeer], ticket: &str, hash: &str, len: usize, peers: &Mutex<Vec<Arc<Peer>>>, picker: &Mutex<Picker>) {
306 329
        for sp in found {
307 330
            if peers.lock().unwrap().iter().any(|p| p.runner == sp.runner) {
331 +
                continue;
332 +
            }
333 +
            if !is_hash(&sp.cert) {
334 +
                eprintln!("swarm: peer {} gave no certificate (an older flow); not asking it", sp.name);
308 335
                continue;
309 336
            }
310 -
            let joined = self.hello(&sp.addr, ticket).and_then(|session| {
337 +
            let joined = hello(&sp.addr, &sp.cert, ticket).and_then(|(agent, session)| {
311 338
                let p = Peer {
312 339
                    runner: sp.runner,
313 340
                    name: sp.name.clone(),
314 -
                    base: format!("http://{}/swarm/v1", sp.addr),
341 +
                    base: format!("https://{}/swarm/v1", sp.addr),
342 +
                    agent,
315 343
                    session,
316 344
                    alive: AtomicBool::new(true),
317 345
                };
…
11 unchanged lines
329 357
        }
330 358
    }
331 359
332 -
    fn hello(&self, addr: &str, ticket: &str) -> Result<String> {
333 -
        let url = format!("http://{addr}/swarm/v1/hello");
334 -
        let r: serde_json::Value = self.peer.post(&url).send_string(ticket).map_err(|e| peer_error(&url, e))?.into_json()?;
335 -
        r["session"].as_str().map(String::from).context("the peer gave no session")
336 -
    }
337 -
338 360
    /// What a peer has of image `hash`, and what it is fetching now:
339 361
    /// nothing yet when it has not begun (the tracker may name it a moment
340 362
    /// before it starts).
341 363
    fn peer_have(&self, p: &Peer, hash: &str, len: usize) -> Result<(Bits, Bits)> {
342 364
        let url = format!("{}/images/{hash}/have", p.base);
343 -
        let r = match self.peer.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call() {
365 +
        let r = match p.agent.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call() {
344 366
            Ok(r) => r,
345 367
            Err(ureq::Error::Status(404, _)) => return Ok((Bits::new(len), Bits::new(len))),
346 368
            Err(e) => return Err(peer_error(&url, e)),
…
9 unchanged lines
356 378
357 379
    fn peer_chunk(&self, p: &Peer, hash: &str) -> Result<Vec<u8>> {
358 380
        let url = format!("{}/chunks/{hash}", p.base);
359 -
        let r = self.peer.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call().map_err(|e| peer_error(&url, e))?;
381 +
        let r = p.agent.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call().map_err(|e| peer_error(&url, e))?;
360 382
        let mut out = Vec::new();
361 383
        r.into_reader().take(oxy_core::chunk::MAX_SIZE as u64 + 1).read_to_end(&mut out)?;
362 384
        Ok(out)
363 385
    }
364 386
}
365 387
366 -
/// A peer this fetch asks for chunks.
388 +
/// A peer this fetch asks for chunks, over a connection that trusts only
389 +
/// its certificate.
367 390
struct Peer {
368 391
    runner: i64,
369 392
    name: String,
370 393
    base: String,
394 +
    agent: ureq::Agent,
371 395
    session: String,
372 396
    alive: AtomicBool,
373 397
}
374 398
399 +
/// A peer at its connection limit: ask it again shortly.
400 +
#[derive(Debug)]
401 +
struct Busy;
402 +
403 +
impl std::fmt::Display for Busy {
404 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 +
        f.write_str("the peer is busy")
406 +
    }
407 +
}
408 +
409 +
impl std::error::Error for Busy {}
410 +
375 411
fn peer_error(url: &str, e: ureq::Error) -> anyhow::Error {
376 412
    match e {
413 +
        ureq::Error::Status(503, _) => anyhow::Error::new(Busy),
377 414
        ureq::Error::Status(code, r) => anyhow::anyhow!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default().trim()),
378 415
        e => anyhow::anyhow!("{url}: {e}"),
416 +
    }
417 +
}
418 +
419 +
/// Show `ticket` to the peer at `addr`, trusting only the certificate
420 +
/// `cert`: a connection to it and a session.
421 +
fn hello(addr: &str, cert: &str, ticket: &str) -> Result<(ureq::Agent, String)> {
422 +
    let agent = peer_agent(cert)?;
423 +
    let url = format!("https://{addr}/swarm/v1/hello");
424 +
    let r: serde_json::Value = agent.post(&url).send_string(ticket).map_err(|e| peer_error(&url, e))?.into_json()?;
425 +
    let session = r["session"].as_str().map(String::from).context("the peer gave no session")?;
426 +
    Ok((agent, session))
427 +
}
428 +
429 +
fn provider() -> Arc<CryptoProvider> {
430 +
    Arc::new(rustls::crypto::ring::default_provider())
431 +
}
432 +
433 +
/// A client that trusts one certificate, by its BLAKE3, whatever name
434 +
/// the peer goes by: the one the hub said this peer has.
435 +
fn peer_agent(cert: &str) -> Result<ureq::Agent> {
436 +
    let provider = provider();
437 +
    let cfg = rustls::ClientConfig::builder_with_provider(provider.clone())
438 +
        .with_safe_default_protocol_versions()?
439 +
        .dangerous()
440 +
        .with_custom_certificate_verifier(Arc::new(Pinned { cert: cert.to_string(), provider }))
441 +
        .with_no_client_auth();
442 +
    Ok(ureq::AgentBuilder::new().tls_config(Arc::new(cfg)).timeout_connect(Duration::from_secs(2)).timeout(Duration::from_secs(20)).build())
443 +
}
444 +
445 +
/// Trusts the certificate with this BLAKE3 and no other. The handshake's
446 +
/// signature is still checked against it, so only the holder of its key
447 +
/// passes.
448 +
#[derive(Debug)]
449 +
struct Pinned {
450 +
    cert: String,
451 +
    provider: Arc<CryptoProvider>,
452 +
}
453 +
454 +
impl ServerCertVerifier for Pinned {
455 +
    fn verify_server_cert(
456 +
        &self,
457 +
        end_entity: &CertificateDer<'_>,
458 +
        _intermediates: &[CertificateDer<'_>],
459 +
        _name: &ServerName<'_>,
460 +
        _ocsp: &[u8],
461 +
        _now: UnixTime,
462 +
    ) -> std::result::Result<ServerCertVerified, rustls::Error> {
463 +
        if blake3::hash(end_entity.as_ref()).to_hex().as_str() == self.cert {
464 +
            Ok(ServerCertVerified::assertion())
465 +
        } else {
466 +
            Err(rustls::Error::General("not the peer the hub named: its certificate is another".into()))
467 +
        }
468 +
    }
469 +
470 +
    fn verify_tls12_signature(
471 +
        &self,
472 +
        message: &[u8],
473 +
        cert: &CertificateDer<'_>,
474 +
        dss: &DigitallySignedStruct,
475 +
    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
476 +
        rustls::crypto::verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
477 +
    }
478 +
479 +
    fn verify_tls13_signature(
480 +
        &self,
481 +
        message: &[u8],
482 +
        cert: &CertificateDer<'_>,
483 +
        dss: &DigitallySignedStruct,
484 +
    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
485 +
        rustls::crypto::verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
486 +
    }
487 +
488 +
    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
489 +
        self.provider.signature_verification_algorithms.supported_schemes()
379 490
    }
380 491
}
381 492
493 +
/// The runner's swarm certificate and key (`swarm/cert.der`, and
494 +
/// `swarm/key.der` readable by the runner alone), made on first use and
495 +
/// kept, so its fingerprint holds across restarts.
496 +
fn certificate(root: &Path) -> Result<(Vec<u8>, Vec<u8>)> {
497 +
    let dir = root.join("swarm");
498 +
    let (cert_path, key_path) = (dir.join("cert.der"), dir.join("key.der"));
499 +
    if let (Ok(c), Ok(k)) = (std::fs::read(&cert_path), std::fs::read(&key_path)) {
500 +
        return Ok((c, k));
501 +
    }
502 +
    std::fs::create_dir_all(&dir)?;
503 +
    let made = rcgen::generate_simple_self_signed(vec!["flow-swarm".to_string()]).context("make the swarm's certificate")?;
504 +
    let (cert, key) = (made.cert.der().to_vec(), made.signing_key.serialize_der());
505 +
    {
506 +
        use std::os::unix::fs::OpenOptionsExt;
507 +
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&key_path)?;
508 +
        f.write_all(&key)?;
509 +
    }
510 +
    std::fs::write(&cert_path, &cert)?;
511 +
    Ok((cert, key))
512 +
}
513 +
514 +
fn server_tls(cert: Vec<u8>, key: Vec<u8>) -> Result<Arc<rustls::ServerConfig>> {
515 +
    let cfg = rustls::ServerConfig::builder_with_provider(provider())
516 +
        .with_safe_default_protocol_versions()?
517 +
        .with_no_client_auth()
518 +
        .with_single_cert(vec![CertificateDer::from(cert)], PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))
519 +
        .context("the swarm's certificate")?;
520 +
    Ok(Arc::new(cfg))
521 +
}
522 +
382 523
/// Offer a whole image: every chunk of it can be read out of `path`.
383 524
fn offer_whole(state: &State, m: &Manifest, path: &Path) {
384 525
    let mut located = state.located.lock().unwrap();
…
9 unchanged lines
394 535
395 536
fn accept(listener: TcpListener, state: Arc<State>) {
396 537
    for conn in listener.incoming() {
397 -
        let Ok(mut conn) = conn else { continue };
398 -
        if state.conns.load(Ordering::Relaxed) >= MAX_CONNS {
399 -
            respond(&mut conn, 503, "text/plain", b"busy: ask another peer").ok();
538 +
        let Ok(conn) = conn else { continue };
539 +
        if state.conns.load(Ordering::Relaxed) >= MAX_OPEN {
400 540
            continue;
401 541
        }
402 -
        state.conns.fetch_add(1, Ordering::Relaxed);
403 542
        let state = state.clone();
404 543
        std::thread::spawn(move || {
405 -
            serve_conn(conn, &state).ok();
544 +
            // Past the limit a peer is still answered, with 503, so it
545 +
            // comes back rather than giving up on this runner.
546 +
            let busy = state.conns.fetch_add(1, Ordering::Relaxed) >= MAX_CONNS;
547 +
            serve_tls(conn, &state, busy).ok();
406 548
            state.conns.fetch_sub(1, Ordering::Relaxed);
407 549
        });
408 550
    }
409 551
}
410 552
411 -
fn respond(conn: &mut TcpStream, code: u16, kind: &str, body: &[u8]) -> std::io::Result<()> {
553 +
fn serve_tls(conn: TcpStream, state: &State, busy: bool) -> std::io::Result<()> {
554 +
    conn.set_read_timeout(Some(Duration::from_secs(30)))?;
555 +
    conn.set_write_timeout(Some(Duration::from_secs(30)))?;
556 +
    conn.set_nodelay(true)?;
557 +
    let tls = rustls::ServerConnection::new(state.tls.clone()).map_err(std::io::Error::other)?;
558 +
    serve_conn(rustls::StreamOwned::new(tls, conn), state, busy)
559 +
}
560 +
561 +
fn respond(conn: &mut impl Write, code: u16, kind: &str, body: &[u8]) -> std::io::Result<()> {
412 562
    let reason = match code {
413 563
        200 => "OK",
414 564
        400 => "Bad Request",
…
9 unchanged lines
424 574
}
425 575
426 576
/// One connection: requests one after another while the peer keeps it.
427 -
fn serve_conn(conn: TcpStream, state: &State) -> std::io::Result<()> {
428 -
    conn.set_read_timeout(Some(Duration::from_secs(30)))?;
429 -
    let mut out = conn.try_clone()?;
577 +
fn serve_conn<S: Read + Write>(conn: S, state: &State, busy: bool) -> std::io::Result<()> {
430 578
    let mut r = BufReader::new(conn);
431 579
    loop {
432 580
        let mut line = String::new();
…
22 unchanged lines
455 603
            }
456 604
        }
457 605
        if length > MAX_BODY {
458 -
            return respond(&mut out, 400, "text/plain", b"too large");
606 +
            return respond(r.get_mut(), 400, "text/plain", b"too large");
459 607
        }
460 608
        let mut body = vec![0; length];
461 609
        r.read_exact(&mut body)?;
610 +
        if busy {
611 +
            return respond(r.get_mut(), 503, "text/plain", b"busy: ask again shortly");
612 +
        }
462 613
        let (code, kind, reply) = route(state, &method, &path, &auth, &body);
463 614
        if code == 200 && kind == "application/octet-stream" {
464 615
            throttle(state, reply.len());
465 616
        }
466 -
        respond(&mut out, code, kind, &reply)?;
617 +
        respond(r.get_mut(), code, kind, &reply)?;
467 618
        if close {
468 619
            return Ok(());
469 620
        }
…
3 unchanged lines
473 624
fn route(state: &State, method: &str, path: &str, auth: &str, body: &[u8]) -> (u16, &'static str, Vec<u8>) {
474 625
    let text = |code, s: &str| (code, "text/plain", s.as_bytes().to_vec());
475 626
    if method == "POST" && path == "/swarm/v1/hello" {
476 -
        return match hello(state, &String::from_utf8_lossy(body)) {
627 +
        return match admit(state, &String::from_utf8_lossy(body)) {
477 628
            Ok(session) => (200, "application/json", serde_json::json!({ "session": session }).to_string().into_bytes()),
478 629
            Err(why) => text(403, &why),
479 630
        };
…
25 unchanged lines
505 656
506 657
/// Admit a peer that shows a ticket from this runner's hub, for this
507 658
/// site: a session until the ticket expires.
508 -
fn hello(state: &State, ticket: &str) -> std::result::Result<String, String> {
659 +
fn admit(state: &State, ticket: &str) -> std::result::Result<String, String> {
509 660
    let hub = {
510 661
        let mut hub = state.hub.lock().unwrap();
511 662
        if hub.is_none() {
…
165 unchanged lines
677 828
        let home = Home::locate(Some(dir)).unwrap();
678 829
        let cfg = home.config.runner.clone().unwrap();
679 830
        let swarm = Swarm::start(&home, &cfg).unwrap().unwrap();
680 -
        hub.runners
681 -
            .lock()
682 -
            .unwrap()
683 -
            .push((token.into(), SwarmPeer { runner: id, name: format!("r{id}"), addr: format!("127.0.0.1:{port}"), complete: true }));
831 +
        hub.runners.lock().unwrap().push((
832 +
            token.into(),
833 +
            SwarmPeer {
834 +
                runner: id,
835 +
                name: format!("r{id}"),
836 +
                addr: format!("127.0.0.1:{port}"),
837 +
                cert: swarm.addr.cert.clone(),
838 +
                complete: true,
839 +
            },
840 +
        ));
684 841
        (home, swarm)
685 842
    }
686 843
…
77 unchanged lines
764 921
        let port = free_port();
765 922
        let (_h, o) = runner(other.path(), &hub, 3, "t3", port, "elsewhere");
766 923
        let ticket = s.state.client.announce(&m.hash, "fetching").unwrap().ticket;
767 -
        let err = o.hello(&format!("127.0.0.1:{port}"), &ticket).unwrap_err();
924 +
        let err = hello(&format!("127.0.0.1:{port}"), &o.addr.cert, &ticket).unwrap_err();
768 925
        assert!(format!("{err:#}").contains("HTTP 403"), "{err:#}");
769 926
        // And no session, no chunks.
770 -
        let url = format!("http://127.0.0.1:{port}/swarm/v1/chunks/{}", m.chunks[0].hash());
771 -
        assert!(matches!(o.peer.get(&url).call(), Err(ureq::Error::Status(401, _))));
927 +
        let url = format!("https://127.0.0.1:{port}/swarm/v1/chunks/{}", m.chunks[0].hash());
928 +
        assert!(matches!(peer_agent(&o.addr.cert).unwrap().get(&url).call(), Err(ureq::Error::Status(401, _))));
929 +
        // Nor any answer at all without TLS.
930 +
        assert!(ureq::get(&url.replacen("https", "http", 1)).call().is_err());
772 931
    }
773 932
774 933
    #[test]
934 +
    fn a_peer_with_another_certificate_is_not_trusted() {
935 +
        let bytes = image();
936 +
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
937 +
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
938 +
        let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
939 +
        std::fs::create_dir_all(a_dir.path().join("cas")).unwrap();
940 +
        std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap();
941 +
        let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab");
942 +
        while !a.state.offers.lock().unwrap().contains_key(&m.hash) {
943 +
            std::thread::sleep(Duration::from_millis(20));
944 +
        }
945 +
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
946 +
        // The hub names A, but with B's certificate: whoever answers at A's
947 +
        // address is not who the hub vouched for.
948 +
        hub.runners.lock().unwrap()[0].1.cert = b.addr.cert.clone();
949 +
        let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap();
950 +
        assert_eq!(moved.peer_bytes, 0, "nothing was taken from the stranger");
951 +
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), m.needed().len());
952 +
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes);
953 +
        // The certificate is kept: the same runner, the same fingerprint.
954 +
        let mut cfg = b_home.config.runner.clone().unwrap();
955 +
        cfg.swarm.as_mut().unwrap().listen = "127.0.0.1:0".into();
956 +
        let again = Swarm::start(&b_home, &cfg).unwrap().unwrap();
957 +
        assert_eq!(again.addr.cert, b.addr.cert);
958 +
    }
959 +
960 +
    #[test]
775 961
    fn the_upload_rate_holds_back_a_burst() {
962 +
        let dir = tempfile::tempdir().unwrap();
963 +
        let (cert, key) = certificate(dir.path()).unwrap();
776 964
        let state = State {
965 +
            tls: server_tls(cert, key).unwrap(),
777 966
            root: PathBuf::new(),
778 967
            site: String::new(),
779 968
            client: Client::new("http://127.0.0.1:1", "t", None).unwrap(),
…
15 unchanged lines
modified docs/jetstream.md +14 −7
…
248 unchanged lines
249 249
- **Trust comes from the hashes.** The manifest comes from the hub, and
250 250
  every chunk is checked against its name before it is kept. A peer can
251 251
  fail to help; it can never change what a job runs.
252 -
- **The hub is the tracker.** A runner reports its swarm address and site
253 -
  with every claim. `POST /api/v1/flow/swarm/announce {image, state}`
252 +
- **The hub is the tracker.** A runner reports its swarm address, its site
253 +
  and its certificate's fingerprint with every claim. `POST /api/v1/flow/swarm/announce {image, state}`
254 254
  (`fetching`, `done` or `stopped`) returns the peers in the same site that
255 255
  hold the image or are fetching it, and a **ticket**.
256 256
- **Tickets admit peers.** A ticket names the runner, its site and an
…
1 unchanged lines
258 258
  publishes. A runner shows its ticket once to each peer
259 259
  (`POST /swarm/v1/hello`) and gets a session for the ticket's life; no
260 260
  other runner and no account can take chunks from a peer.
261 -
- **The peer protocol** is plain HTTP on the runner's swarm address:
261 +
- **Encrypted, and pinned by the hub.** Each runner makes its own TLS
262 +
  certificate on first use (`swarm/cert.der`, its key readable by the
263 +
  runner alone) and keeps it, so its fingerprint (BLAKE3 of the
264 +
  certificate) holds across restarts. The tracker hands each peer out with
265 +
  that fingerprint, and a fetcher talks only to a peer showing that
266 +
  certificate and proving its key in the handshake; no CA is involved. A
267 +
  peer at another address, or a stranger at a peer's, gets nothing and
268 +
  sees nothing: chunks, tickets and sessions all travel inside TLS.
269 +
- **The peer protocol** is HTTPS on the runner's swarm address:
262 270
  - `POST /swarm/v1/hello` with the ticket: a session token.
263 271
  - `GET /swarm/v1/images/{image}/have`: `complete`, or two bit maps over
264 272
    the manifest's needed list (`Manifest::needed`), in its order: the
265 273
    chunks it has, then the chunks it is fetching now.
266 274
  - `GET /swarm/v1/chunks/{chunk}`: the chunk's bytes, from the chunk
267 275
    cache or read out of a whole image; `503` when the peer is at its
268 -
    connection limit.
276 +
    connection limit, and the fetcher asks it again shortly.
269 277
- **Rarest first.** The fetcher refreshes every peer's maps twice a
270 278
  second and asks each peer, four requests at a time, for the chunk the
271 279
  fewest peers hold. The hub is a seed that holds everything: it is asked
…
8 unchanged lines
280 288
- **Bounded.** A runner serves at most 16 connections and `upload_mib_s`
281 289
  (default 50 MiB/s), so serving never starves its own jobs.
282 290
- **Configured, never assumed.** Without a `[runner.swarm]` section a
283 -
  runner neither listens nor asks peers. Traffic is plain HTTP: chunks are
284 -
  public within the fleet and checked by hash, so keep the swarm address
285 -
  on a private network.
291 +
  runner neither listens nor asks peers. Peers are not offered across
292 +
  sites, so a site is the network its runners can reach each other on.
286 293
287 294
In flow.toml (or as `[swarm]` in runner.toml, which stands in for the
288 295
`[runner]` section):
…
177 unchanged lines