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 |