├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── TODO.md ├── doc ├── manual.md └── vs.md ├── localhost.crt ├── localhost.key └── src ├── args.rs ├── bin ├── http301d.rs └── httpd2.rs ├── err.rs ├── lib.rs ├── log.rs ├── percent.rs ├── picky.rs ├── serve.rs ├── sync.rs └── traversal.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "anstream" 22 | version = "0.6.18" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "is_terminal_polyfill", 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle" 37 | version = "1.0.10" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 40 | 41 | [[package]] 42 | name = "anstyle-parse" 43 | version = "0.2.6" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 46 | dependencies = [ 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-query" 52 | version = "1.1.2" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 55 | dependencies = [ 56 | "windows-sys 0.59.0", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-wincon" 61 | version = "3.0.7" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 64 | dependencies = [ 65 | "anstyle", 66 | "once_cell", 67 | "windows-sys 0.59.0", 68 | ] 69 | 70 | [[package]] 71 | name = "atomic-waker" 72 | version = "1.1.2" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 75 | 76 | [[package]] 77 | name = "autocfg" 78 | version = "1.4.0" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 81 | 82 | [[package]] 83 | name = "backtrace" 84 | version = "0.3.74" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 87 | dependencies = [ 88 | "addr2line", 89 | "cfg-if", 90 | "libc", 91 | "miniz_oxide", 92 | "object", 93 | "rustc-demangle", 94 | "windows-targets", 95 | ] 96 | 97 | [[package]] 98 | name = "bitflags" 99 | version = "1.3.2" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 102 | 103 | [[package]] 104 | name = "bitflags" 105 | version = "2.8.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 108 | 109 | [[package]] 110 | name = "block-buffer" 111 | version = "0.9.0" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" 114 | dependencies = [ 115 | "generic-array", 116 | ] 117 | 118 | [[package]] 119 | name = "bytes" 120 | version = "1.10.0" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" 123 | 124 | [[package]] 125 | name = "cc" 126 | version = "1.2.14" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" 129 | dependencies = [ 130 | "shlex", 131 | ] 132 | 133 | [[package]] 134 | name = "cfg-if" 135 | version = "1.0.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 138 | 139 | [[package]] 140 | name = "clap" 141 | version = "4.5.30" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" 144 | dependencies = [ 145 | "clap_builder", 146 | "clap_derive", 147 | ] 148 | 149 | [[package]] 150 | name = "clap_builder" 151 | version = "4.5.30" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" 154 | dependencies = [ 155 | "anstream", 156 | "anstyle", 157 | "clap_lex", 158 | "strsim", 159 | "terminal_size", 160 | ] 161 | 162 | [[package]] 163 | name = "clap_derive" 164 | version = "4.5.28" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" 167 | dependencies = [ 168 | "heck", 169 | "proc-macro2", 170 | "quote", 171 | "syn", 172 | ] 173 | 174 | [[package]] 175 | name = "clap_lex" 176 | version = "0.7.4" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 179 | 180 | [[package]] 181 | name = "colorchoice" 182 | version = "1.0.3" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 185 | 186 | [[package]] 187 | name = "cpufeatures" 188 | version = "0.2.17" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 191 | dependencies = [ 192 | "libc", 193 | ] 194 | 195 | [[package]] 196 | name = "crossbeam-channel" 197 | version = "0.5.14" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" 200 | dependencies = [ 201 | "crossbeam-utils", 202 | ] 203 | 204 | [[package]] 205 | name = "crossbeam-utils" 206 | version = "0.8.21" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 209 | 210 | [[package]] 211 | name = "crypto-mac" 212 | version = "0.11.0" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" 215 | dependencies = [ 216 | "generic-array", 217 | "subtle", 218 | ] 219 | 220 | [[package]] 221 | name = "deranged" 222 | version = "0.3.11" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 225 | dependencies = [ 226 | "powerfmt", 227 | ] 228 | 229 | [[package]] 230 | name = "digest" 231 | version = "0.9.0" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" 234 | dependencies = [ 235 | "generic-array", 236 | ] 237 | 238 | [[package]] 239 | name = "dirs-next" 240 | version = "2.0.0" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 243 | dependencies = [ 244 | "cfg-if", 245 | "dirs-sys-next", 246 | ] 247 | 248 | [[package]] 249 | name = "dirs-sys-next" 250 | version = "0.1.2" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 253 | dependencies = [ 254 | "libc", 255 | "redox_users", 256 | "winapi", 257 | ] 258 | 259 | [[package]] 260 | name = "enum-map" 261 | version = "2.7.3" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" 264 | dependencies = [ 265 | "enum-map-derive", 266 | ] 267 | 268 | [[package]] 269 | name = "enum-map-derive" 270 | version = "0.17.0" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" 273 | dependencies = [ 274 | "proc-macro2", 275 | "quote", 276 | "syn", 277 | ] 278 | 279 | [[package]] 280 | name = "equivalent" 281 | version = "1.0.2" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 284 | 285 | [[package]] 286 | name = "errno" 287 | version = "0.3.10" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 290 | dependencies = [ 291 | "libc", 292 | "windows-sys 0.59.0", 293 | ] 294 | 295 | [[package]] 296 | name = "fnv" 297 | version = "1.0.7" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 300 | 301 | [[package]] 302 | name = "futures" 303 | version = "0.3.31" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 306 | dependencies = [ 307 | "futures-channel", 308 | "futures-core", 309 | "futures-executor", 310 | "futures-io", 311 | "futures-sink", 312 | "futures-task", 313 | "futures-util", 314 | ] 315 | 316 | [[package]] 317 | name = "futures-channel" 318 | version = "0.3.31" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 321 | dependencies = [ 322 | "futures-core", 323 | "futures-sink", 324 | ] 325 | 326 | [[package]] 327 | name = "futures-core" 328 | version = "0.3.31" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 331 | 332 | [[package]] 333 | name = "futures-executor" 334 | version = "0.3.31" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 337 | dependencies = [ 338 | "futures-core", 339 | "futures-task", 340 | "futures-util", 341 | ] 342 | 343 | [[package]] 344 | name = "futures-io" 345 | version = "0.3.31" 346 | source = "registry+https://github.com/rust-lang/crates.io-index" 347 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 348 | 349 | [[package]] 350 | name = "futures-macro" 351 | version = "0.3.31" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 354 | dependencies = [ 355 | "proc-macro2", 356 | "quote", 357 | "syn", 358 | ] 359 | 360 | [[package]] 361 | name = "futures-sink" 362 | version = "0.3.31" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 365 | 366 | [[package]] 367 | name = "futures-task" 368 | version = "0.3.31" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 371 | 372 | [[package]] 373 | name = "futures-util" 374 | version = "0.3.31" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 377 | dependencies = [ 378 | "futures-channel", 379 | "futures-core", 380 | "futures-io", 381 | "futures-macro", 382 | "futures-sink", 383 | "futures-task", 384 | "memchr", 385 | "pin-project-lite", 386 | "pin-utils", 387 | "slab", 388 | ] 389 | 390 | [[package]] 391 | name = "generic-array" 392 | version = "0.14.7" 393 | source = "registry+https://github.com/rust-lang/crates.io-index" 394 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 395 | dependencies = [ 396 | "typenum", 397 | "version_check", 398 | ] 399 | 400 | [[package]] 401 | name = "getrandom" 402 | version = "0.2.15" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 405 | dependencies = [ 406 | "cfg-if", 407 | "libc", 408 | "wasi", 409 | ] 410 | 411 | [[package]] 412 | name = "gimli" 413 | version = "0.31.1" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 416 | 417 | [[package]] 418 | name = "h2" 419 | version = "0.4.8" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" 422 | dependencies = [ 423 | "atomic-waker", 424 | "bytes", 425 | "fnv", 426 | "futures-core", 427 | "futures-sink", 428 | "http", 429 | "indexmap", 430 | "slab", 431 | "tokio", 432 | "tokio-util", 433 | "tracing", 434 | ] 435 | 436 | [[package]] 437 | name = "hashbrown" 438 | version = "0.15.2" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 441 | 442 | [[package]] 443 | name = "heck" 444 | version = "0.5.0" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 447 | 448 | [[package]] 449 | name = "hermit-abi" 450 | version = "0.3.9" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 453 | 454 | [[package]] 455 | name = "hermit-abi" 456 | version = "0.4.0" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" 459 | 460 | [[package]] 461 | name = "hmac" 462 | version = "0.11.0" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" 465 | dependencies = [ 466 | "crypto-mac", 467 | "digest", 468 | ] 469 | 470 | [[package]] 471 | name = "http" 472 | version = "1.2.0" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" 475 | dependencies = [ 476 | "bytes", 477 | "fnv", 478 | "itoa", 479 | ] 480 | 481 | [[package]] 482 | name = "http-body" 483 | version = "1.0.1" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 486 | dependencies = [ 487 | "bytes", 488 | "http", 489 | ] 490 | 491 | [[package]] 492 | name = "http-body-util" 493 | version = "0.1.2" 494 | source = "registry+https://github.com/rust-lang/crates.io-index" 495 | checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" 496 | dependencies = [ 497 | "bytes", 498 | "futures-util", 499 | "http", 500 | "http-body", 501 | "pin-project-lite", 502 | ] 503 | 504 | [[package]] 505 | name = "httparse" 506 | version = "1.10.0" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" 509 | 510 | [[package]] 511 | name = "httpd2" 512 | version = "0.1.0" 513 | dependencies = [ 514 | "bytes", 515 | "clap", 516 | "enum-map", 517 | "futures", 518 | "http-body-util", 519 | "httpdate", 520 | "hyper", 521 | "hyper-util", 522 | "libc", 523 | "nix 0.27.1", 524 | "num_cpus", 525 | "rustls", 526 | "rustls-pemfile", 527 | "slog", 528 | "slog-async", 529 | "slog-journald", 530 | "slog-term", 531 | "thiserror", 532 | "tokio", 533 | "tokio-rustls", 534 | "tokio-util", 535 | ] 536 | 537 | [[package]] 538 | name = "httpdate" 539 | version = "1.0.3" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 542 | 543 | [[package]] 544 | name = "hyper" 545 | version = "1.6.0" 546 | source = "registry+https://github.com/rust-lang/crates.io-index" 547 | checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 548 | dependencies = [ 549 | "bytes", 550 | "futures-channel", 551 | "futures-util", 552 | "h2", 553 | "http", 554 | "http-body", 555 | "httparse", 556 | "httpdate", 557 | "itoa", 558 | "pin-project-lite", 559 | "smallvec", 560 | "tokio", 561 | ] 562 | 563 | [[package]] 564 | name = "hyper-util" 565 | version = "0.1.10" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" 568 | dependencies = [ 569 | "bytes", 570 | "futures-util", 571 | "http", 572 | "http-body", 573 | "hyper", 574 | "pin-project-lite", 575 | "tokio", 576 | ] 577 | 578 | [[package]] 579 | name = "indexmap" 580 | version = "2.7.1" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 583 | dependencies = [ 584 | "equivalent", 585 | "hashbrown", 586 | ] 587 | 588 | [[package]] 589 | name = "is-terminal" 590 | version = "0.4.15" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" 593 | dependencies = [ 594 | "hermit-abi 0.4.0", 595 | "libc", 596 | "windows-sys 0.59.0", 597 | ] 598 | 599 | [[package]] 600 | name = "is_terminal_polyfill" 601 | version = "1.70.1" 602 | source = "registry+https://github.com/rust-lang/crates.io-index" 603 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 604 | 605 | [[package]] 606 | name = "itoa" 607 | version = "1.0.14" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 610 | 611 | [[package]] 612 | name = "libc" 613 | version = "0.2.169" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 616 | 617 | [[package]] 618 | name = "libredox" 619 | version = "0.1.3" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 622 | dependencies = [ 623 | "bitflags 2.8.0", 624 | "libc", 625 | ] 626 | 627 | [[package]] 628 | name = "libsystemd" 629 | version = "0.4.1" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "6f4f0b5b062ba67aa075e331de778082c09e66b5ef32970ea5a1e9c37c9555d1" 632 | dependencies = [ 633 | "hmac", 634 | "libc", 635 | "log", 636 | "nix 0.23.2", 637 | "once_cell", 638 | "serde", 639 | "sha2", 640 | "thiserror", 641 | "uuid", 642 | ] 643 | 644 | [[package]] 645 | name = "linux-raw-sys" 646 | version = "0.4.15" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 649 | 650 | [[package]] 651 | name = "lock_api" 652 | version = "0.4.12" 653 | source = "registry+https://github.com/rust-lang/crates.io-index" 654 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 655 | dependencies = [ 656 | "autocfg", 657 | "scopeguard", 658 | ] 659 | 660 | [[package]] 661 | name = "log" 662 | version = "0.4.25" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" 665 | 666 | [[package]] 667 | name = "memchr" 668 | version = "2.7.4" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 671 | 672 | [[package]] 673 | name = "memoffset" 674 | version = "0.6.5" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" 677 | dependencies = [ 678 | "autocfg", 679 | ] 680 | 681 | [[package]] 682 | name = "miniz_oxide" 683 | version = "0.8.4" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" 686 | dependencies = [ 687 | "adler2", 688 | ] 689 | 690 | [[package]] 691 | name = "mio" 692 | version = "1.0.3" 693 | source = "registry+https://github.com/rust-lang/crates.io-index" 694 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 695 | dependencies = [ 696 | "libc", 697 | "wasi", 698 | "windows-sys 0.52.0", 699 | ] 700 | 701 | [[package]] 702 | name = "nix" 703 | version = "0.23.2" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" 706 | dependencies = [ 707 | "bitflags 1.3.2", 708 | "cc", 709 | "cfg-if", 710 | "libc", 711 | "memoffset", 712 | ] 713 | 714 | [[package]] 715 | name = "nix" 716 | version = "0.27.1" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" 719 | dependencies = [ 720 | "bitflags 2.8.0", 721 | "cfg-if", 722 | "libc", 723 | ] 724 | 725 | [[package]] 726 | name = "num-conv" 727 | version = "0.1.0" 728 | source = "registry+https://github.com/rust-lang/crates.io-index" 729 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 730 | 731 | [[package]] 732 | name = "num_cpus" 733 | version = "1.16.0" 734 | source = "registry+https://github.com/rust-lang/crates.io-index" 735 | checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" 736 | dependencies = [ 737 | "hermit-abi 0.3.9", 738 | "libc", 739 | ] 740 | 741 | [[package]] 742 | name = "object" 743 | version = "0.36.7" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 746 | dependencies = [ 747 | "memchr", 748 | ] 749 | 750 | [[package]] 751 | name = "once_cell" 752 | version = "1.20.3" 753 | source = "registry+https://github.com/rust-lang/crates.io-index" 754 | checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" 755 | 756 | [[package]] 757 | name = "opaque-debug" 758 | version = "0.3.1" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 761 | 762 | [[package]] 763 | name = "parking_lot" 764 | version = "0.12.3" 765 | source = "registry+https://github.com/rust-lang/crates.io-index" 766 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 767 | dependencies = [ 768 | "lock_api", 769 | "parking_lot_core", 770 | ] 771 | 772 | [[package]] 773 | name = "parking_lot_core" 774 | version = "0.9.10" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 777 | dependencies = [ 778 | "cfg-if", 779 | "libc", 780 | "redox_syscall", 781 | "smallvec", 782 | "windows-targets", 783 | ] 784 | 785 | [[package]] 786 | name = "pin-project-lite" 787 | version = "0.2.16" 788 | source = "registry+https://github.com/rust-lang/crates.io-index" 789 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 790 | 791 | [[package]] 792 | name = "pin-utils" 793 | version = "0.1.0" 794 | source = "registry+https://github.com/rust-lang/crates.io-index" 795 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 796 | 797 | [[package]] 798 | name = "powerfmt" 799 | version = "0.2.0" 800 | source = "registry+https://github.com/rust-lang/crates.io-index" 801 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 802 | 803 | [[package]] 804 | name = "proc-macro2" 805 | version = "1.0.93" 806 | source = "registry+https://github.com/rust-lang/crates.io-index" 807 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 808 | dependencies = [ 809 | "unicode-ident", 810 | ] 811 | 812 | [[package]] 813 | name = "quote" 814 | version = "1.0.38" 815 | source = "registry+https://github.com/rust-lang/crates.io-index" 816 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 817 | dependencies = [ 818 | "proc-macro2", 819 | ] 820 | 821 | [[package]] 822 | name = "redox_syscall" 823 | version = "0.5.8" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" 826 | dependencies = [ 827 | "bitflags 2.8.0", 828 | ] 829 | 830 | [[package]] 831 | name = "redox_users" 832 | version = "0.4.6" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 835 | dependencies = [ 836 | "getrandom", 837 | "libredox", 838 | "thiserror", 839 | ] 840 | 841 | [[package]] 842 | name = "ring" 843 | version = "0.17.9" 844 | source = "registry+https://github.com/rust-lang/crates.io-index" 845 | checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" 846 | dependencies = [ 847 | "cc", 848 | "cfg-if", 849 | "getrandom", 850 | "libc", 851 | "untrusted", 852 | "windows-sys 0.52.0", 853 | ] 854 | 855 | [[package]] 856 | name = "rustc-demangle" 857 | version = "0.1.24" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 860 | 861 | [[package]] 862 | name = "rustix" 863 | version = "0.38.44" 864 | source = "registry+https://github.com/rust-lang/crates.io-index" 865 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 866 | dependencies = [ 867 | "bitflags 2.8.0", 868 | "errno", 869 | "libc", 870 | "linux-raw-sys", 871 | "windows-sys 0.59.0", 872 | ] 873 | 874 | [[package]] 875 | name = "rustls" 876 | version = "0.22.4" 877 | source = "registry+https://github.com/rust-lang/crates.io-index" 878 | checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" 879 | dependencies = [ 880 | "log", 881 | "ring", 882 | "rustls-pki-types", 883 | "rustls-webpki", 884 | "subtle", 885 | "zeroize", 886 | ] 887 | 888 | [[package]] 889 | name = "rustls-pemfile" 890 | version = "2.2.0" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" 893 | dependencies = [ 894 | "rustls-pki-types", 895 | ] 896 | 897 | [[package]] 898 | name = "rustls-pki-types" 899 | version = "1.11.0" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" 902 | 903 | [[package]] 904 | name = "rustls-webpki" 905 | version = "0.102.8" 906 | source = "registry+https://github.com/rust-lang/crates.io-index" 907 | checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" 908 | dependencies = [ 909 | "ring", 910 | "rustls-pki-types", 911 | "untrusted", 912 | ] 913 | 914 | [[package]] 915 | name = "rustversion" 916 | version = "1.0.19" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" 919 | 920 | [[package]] 921 | name = "scopeguard" 922 | version = "1.2.0" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 925 | 926 | [[package]] 927 | name = "serde" 928 | version = "1.0.217" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 931 | dependencies = [ 932 | "serde_derive", 933 | ] 934 | 935 | [[package]] 936 | name = "serde_derive" 937 | version = "1.0.217" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 940 | dependencies = [ 941 | "proc-macro2", 942 | "quote", 943 | "syn", 944 | ] 945 | 946 | [[package]] 947 | name = "sha2" 948 | version = "0.9.9" 949 | source = "registry+https://github.com/rust-lang/crates.io-index" 950 | checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" 951 | dependencies = [ 952 | "block-buffer", 953 | "cfg-if", 954 | "cpufeatures", 955 | "digest", 956 | "opaque-debug", 957 | ] 958 | 959 | [[package]] 960 | name = "shlex" 961 | version = "1.3.0" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 964 | 965 | [[package]] 966 | name = "signal-hook-registry" 967 | version = "1.4.2" 968 | source = "registry+https://github.com/rust-lang/crates.io-index" 969 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 970 | dependencies = [ 971 | "libc", 972 | ] 973 | 974 | [[package]] 975 | name = "slab" 976 | version = "0.4.9" 977 | source = "registry+https://github.com/rust-lang/crates.io-index" 978 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 979 | dependencies = [ 980 | "autocfg", 981 | ] 982 | 983 | [[package]] 984 | name = "slog" 985 | version = "2.7.0" 986 | source = "registry+https://github.com/rust-lang/crates.io-index" 987 | checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" 988 | 989 | [[package]] 990 | name = "slog-async" 991 | version = "2.8.0" 992 | source = "registry+https://github.com/rust-lang/crates.io-index" 993 | checksum = "72c8038f898a2c79507940990f05386455b3a317d8f18d4caea7cbc3d5096b84" 994 | dependencies = [ 995 | "crossbeam-channel", 996 | "slog", 997 | "take_mut", 998 | "thread_local", 999 | ] 1000 | 1001 | [[package]] 1002 | name = "slog-journald" 1003 | version = "2.2.0" 1004 | source = "registry+https://github.com/rust-lang/crates.io-index" 1005 | checksum = "83e14eb8c2f5d0c8fc9fbac40e6391095e4dc5cb334f7dce99c75cb1919eb39c" 1006 | dependencies = [ 1007 | "libsystemd", 1008 | "slog", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "slog-term" 1013 | version = "2.9.1" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "b6e022d0b998abfe5c3782c1f03551a596269450ccd677ea51c56f8b214610e8" 1016 | dependencies = [ 1017 | "is-terminal", 1018 | "slog", 1019 | "term", 1020 | "thread_local", 1021 | "time", 1022 | ] 1023 | 1024 | [[package]] 1025 | name = "smallvec" 1026 | version = "1.14.0" 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" 1028 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 1029 | 1030 | [[package]] 1031 | name = "socket2" 1032 | version = "0.5.8" 1033 | source = "registry+https://github.com/rust-lang/crates.io-index" 1034 | checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" 1035 | dependencies = [ 1036 | "libc", 1037 | "windows-sys 0.52.0", 1038 | ] 1039 | 1040 | [[package]] 1041 | name = "strsim" 1042 | version = "0.11.1" 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" 1044 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1045 | 1046 | [[package]] 1047 | name = "subtle" 1048 | version = "2.6.1" 1049 | source = "registry+https://github.com/rust-lang/crates.io-index" 1050 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1051 | 1052 | [[package]] 1053 | name = "syn" 1054 | version = "2.0.98" 1055 | source = "registry+https://github.com/rust-lang/crates.io-index" 1056 | checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" 1057 | dependencies = [ 1058 | "proc-macro2", 1059 | "quote", 1060 | "unicode-ident", 1061 | ] 1062 | 1063 | [[package]] 1064 | name = "take_mut" 1065 | version = "0.2.2" 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" 1067 | checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" 1068 | 1069 | [[package]] 1070 | name = "term" 1071 | version = "0.7.0" 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" 1073 | checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" 1074 | dependencies = [ 1075 | "dirs-next", 1076 | "rustversion", 1077 | "winapi", 1078 | ] 1079 | 1080 | [[package]] 1081 | name = "terminal_size" 1082 | version = "0.4.1" 1083 | source = "registry+https://github.com/rust-lang/crates.io-index" 1084 | checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" 1085 | dependencies = [ 1086 | "rustix", 1087 | "windows-sys 0.59.0", 1088 | ] 1089 | 1090 | [[package]] 1091 | name = "thiserror" 1092 | version = "1.0.69" 1093 | source = "registry+https://github.com/rust-lang/crates.io-index" 1094 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1095 | dependencies = [ 1096 | "thiserror-impl", 1097 | ] 1098 | 1099 | [[package]] 1100 | name = "thiserror-impl" 1101 | version = "1.0.69" 1102 | source = "registry+https://github.com/rust-lang/crates.io-index" 1103 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1104 | dependencies = [ 1105 | "proc-macro2", 1106 | "quote", 1107 | "syn", 1108 | ] 1109 | 1110 | [[package]] 1111 | name = "thread_local" 1112 | version = "1.1.8" 1113 | source = "registry+https://github.com/rust-lang/crates.io-index" 1114 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 1115 | dependencies = [ 1116 | "cfg-if", 1117 | "once_cell", 1118 | ] 1119 | 1120 | [[package]] 1121 | name = "time" 1122 | version = "0.3.37" 1123 | source = "registry+https://github.com/rust-lang/crates.io-index" 1124 | checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" 1125 | dependencies = [ 1126 | "deranged", 1127 | "itoa", 1128 | "num-conv", 1129 | "powerfmt", 1130 | "serde", 1131 | "time-core", 1132 | "time-macros", 1133 | ] 1134 | 1135 | [[package]] 1136 | name = "time-core" 1137 | version = "0.1.2" 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" 1139 | checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" 1140 | 1141 | [[package]] 1142 | name = "time-macros" 1143 | version = "0.2.19" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" 1146 | dependencies = [ 1147 | "num-conv", 1148 | "time-core", 1149 | ] 1150 | 1151 | [[package]] 1152 | name = "tokio" 1153 | version = "1.43.0" 1154 | source = "registry+https://github.com/rust-lang/crates.io-index" 1155 | checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" 1156 | dependencies = [ 1157 | "backtrace", 1158 | "bytes", 1159 | "libc", 1160 | "mio", 1161 | "parking_lot", 1162 | "pin-project-lite", 1163 | "signal-hook-registry", 1164 | "socket2", 1165 | "tokio-macros", 1166 | "windows-sys 0.52.0", 1167 | ] 1168 | 1169 | [[package]] 1170 | name = "tokio-macros" 1171 | version = "2.5.0" 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" 1173 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 1174 | dependencies = [ 1175 | "proc-macro2", 1176 | "quote", 1177 | "syn", 1178 | ] 1179 | 1180 | [[package]] 1181 | name = "tokio-rustls" 1182 | version = "0.25.0" 1183 | source = "registry+https://github.com/rust-lang/crates.io-index" 1184 | checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" 1185 | dependencies = [ 1186 | "rustls", 1187 | "rustls-pki-types", 1188 | "tokio", 1189 | ] 1190 | 1191 | [[package]] 1192 | name = "tokio-util" 1193 | version = "0.7.13" 1194 | source = "registry+https://github.com/rust-lang/crates.io-index" 1195 | checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" 1196 | dependencies = [ 1197 | "bytes", 1198 | "futures-core", 1199 | "futures-sink", 1200 | "pin-project-lite", 1201 | "tokio", 1202 | ] 1203 | 1204 | [[package]] 1205 | name = "tracing" 1206 | version = "0.1.41" 1207 | source = "registry+https://github.com/rust-lang/crates.io-index" 1208 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1209 | dependencies = [ 1210 | "pin-project-lite", 1211 | "tracing-core", 1212 | ] 1213 | 1214 | [[package]] 1215 | name = "tracing-core" 1216 | version = "0.1.33" 1217 | source = "registry+https://github.com/rust-lang/crates.io-index" 1218 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 1219 | dependencies = [ 1220 | "once_cell", 1221 | ] 1222 | 1223 | [[package]] 1224 | name = "typenum" 1225 | version = "1.18.0" 1226 | source = "registry+https://github.com/rust-lang/crates.io-index" 1227 | checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 1228 | 1229 | [[package]] 1230 | name = "unicode-ident" 1231 | version = "1.0.16" 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" 1233 | checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" 1234 | 1235 | [[package]] 1236 | name = "untrusted" 1237 | version = "0.9.0" 1238 | source = "registry+https://github.com/rust-lang/crates.io-index" 1239 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 1240 | 1241 | [[package]] 1242 | name = "utf8parse" 1243 | version = "0.2.2" 1244 | source = "registry+https://github.com/rust-lang/crates.io-index" 1245 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1246 | 1247 | [[package]] 1248 | name = "uuid" 1249 | version = "0.8.2" 1250 | source = "registry+https://github.com/rust-lang/crates.io-index" 1251 | checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" 1252 | dependencies = [ 1253 | "serde", 1254 | ] 1255 | 1256 | [[package]] 1257 | name = "version_check" 1258 | version = "0.9.5" 1259 | source = "registry+https://github.com/rust-lang/crates.io-index" 1260 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1261 | 1262 | [[package]] 1263 | name = "wasi" 1264 | version = "0.11.0+wasi-snapshot-preview1" 1265 | source = "registry+https://github.com/rust-lang/crates.io-index" 1266 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1267 | 1268 | [[package]] 1269 | name = "winapi" 1270 | version = "0.3.9" 1271 | source = "registry+https://github.com/rust-lang/crates.io-index" 1272 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1273 | dependencies = [ 1274 | "winapi-i686-pc-windows-gnu", 1275 | "winapi-x86_64-pc-windows-gnu", 1276 | ] 1277 | 1278 | [[package]] 1279 | name = "winapi-i686-pc-windows-gnu" 1280 | version = "0.4.0" 1281 | source = "registry+https://github.com/rust-lang/crates.io-index" 1282 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1283 | 1284 | [[package]] 1285 | name = "winapi-x86_64-pc-windows-gnu" 1286 | version = "0.4.0" 1287 | source = "registry+https://github.com/rust-lang/crates.io-index" 1288 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1289 | 1290 | [[package]] 1291 | name = "windows-sys" 1292 | version = "0.52.0" 1293 | source = "registry+https://github.com/rust-lang/crates.io-index" 1294 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1295 | dependencies = [ 1296 | "windows-targets", 1297 | ] 1298 | 1299 | [[package]] 1300 | name = "windows-sys" 1301 | version = "0.59.0" 1302 | source = "registry+https://github.com/rust-lang/crates.io-index" 1303 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1304 | dependencies = [ 1305 | "windows-targets", 1306 | ] 1307 | 1308 | [[package]] 1309 | name = "windows-targets" 1310 | version = "0.52.6" 1311 | source = "registry+https://github.com/rust-lang/crates.io-index" 1312 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1313 | dependencies = [ 1314 | "windows_aarch64_gnullvm", 1315 | "windows_aarch64_msvc", 1316 | "windows_i686_gnu", 1317 | "windows_i686_gnullvm", 1318 | "windows_i686_msvc", 1319 | "windows_x86_64_gnu", 1320 | "windows_x86_64_gnullvm", 1321 | "windows_x86_64_msvc", 1322 | ] 1323 | 1324 | [[package]] 1325 | name = "windows_aarch64_gnullvm" 1326 | version = "0.52.6" 1327 | source = "registry+https://github.com/rust-lang/crates.io-index" 1328 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1329 | 1330 | [[package]] 1331 | name = "windows_aarch64_msvc" 1332 | version = "0.52.6" 1333 | source = "registry+https://github.com/rust-lang/crates.io-index" 1334 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1335 | 1336 | [[package]] 1337 | name = "windows_i686_gnu" 1338 | version = "0.52.6" 1339 | source = "registry+https://github.com/rust-lang/crates.io-index" 1340 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1341 | 1342 | [[package]] 1343 | name = "windows_i686_gnullvm" 1344 | version = "0.52.6" 1345 | source = "registry+https://github.com/rust-lang/crates.io-index" 1346 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1347 | 1348 | [[package]] 1349 | name = "windows_i686_msvc" 1350 | version = "0.52.6" 1351 | source = "registry+https://github.com/rust-lang/crates.io-index" 1352 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1353 | 1354 | [[package]] 1355 | name = "windows_x86_64_gnu" 1356 | version = "0.52.6" 1357 | source = "registry+https://github.com/rust-lang/crates.io-index" 1358 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1359 | 1360 | [[package]] 1361 | name = "windows_x86_64_gnullvm" 1362 | version = "0.52.6" 1363 | source = "registry+https://github.com/rust-lang/crates.io-index" 1364 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1365 | 1366 | [[package]] 1367 | name = "windows_x86_64_msvc" 1368 | version = "0.52.6" 1369 | source = "registry+https://github.com/rust-lang/crates.io-index" 1370 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1371 | 1372 | [[package]] 1373 | name = "zeroize" 1374 | version = "1.8.1" 1375 | source = "registry+https://github.com/rust-lang/crates.io-index" 1376 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 1377 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "httpd2" 3 | version = "0.1.0" 4 | authors = ["Cliff L. Biffle "] 5 | edition = "2021" 6 | default-run = "httpd2" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [features] 11 | default = [] 12 | # Enable logging to journald. 13 | journald = ["slog-journald"] 14 | # Use the system allocator (intended for heap profiling only). 15 | system_allocator = [] 16 | 17 | [dependencies] 18 | hyper = { version = "1.1", features = ["server", "http1", "http2"] } 19 | tokio = { version = "1.35.0", features = ["full"] } 20 | futures = "0.3.30" 21 | rustls = "0.22.2" 22 | tokio-rustls = "0.25.0" 23 | nix = { version = "0.27.1", features = ["user", "fs"] } 24 | libc = "0.2.152" 25 | tokio-util = { version = "0.7.10", features = ["codec"] } 26 | bytes = "1.5.0" 27 | httpdate = "1.0.3" 28 | slog = "2.7.0" 29 | slog-async = "2.7.0" 30 | slog-term = "2.8.0" 31 | slog-journald = { version = "2.1.1", optional = true } 32 | num_cpus = "1.13.0" 33 | clap = { version = "4.4.15", features = ["derive", "wrap_help"] } 34 | http-body-util = "0.1.0" 35 | rustls-pemfile = "2.0.0" 36 | hyper-util = { version = "0.1.2", features = ["server", "server-auto", "tokio"] } 37 | enum-map = "2.7.3" 38 | thiserror = "1.0.56" 39 | 40 | [profile.release] 41 | debug = 2 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Cliff L. Biffle 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A modern static file server 2 | 3 | `httpd2` (working title, as it's the second one I've written recently) is a 4 | program that serves web pages / resources to the public. And that's it. For more 5 | details see [the manual](doc/manual.md). 6 | 7 | It is inspired by, and patterned after, [`publicfile`], the security-conscious 8 | static file server — except it is HTTPS-native and supports HTTP/2. (I 9 | have a [detailed analysis vs. publicfile](doc/vs.md) if you're into that sort 10 | of thing.) 11 | 12 | This repo also includes a derivative server, `http301d`, which only serves 13 | unencrypted HTTP/1 301 Moved Permanently redirects to your HTTPS server, and 14 | doens't read files from disk etc. It makes a nice port-80 counterpart to 15 | `httpd2`. 16 | 17 | ## Disclaimer 18 | 19 | I make no claims that this software is secure or impervious. I wrote this as an 20 | exercise in applying secure programming principles to a modern HTTP server. 21 | 22 | ## Features 23 | 24 | Basics: 25 | 26 | - Serves the contents of a single directory as a public website. 27 | 28 | - Supports modern web standards: HTTP/1.1, HTTP/2, TLS v1.3, etc. 29 | 30 | - Supports GZIP content encoding to reduce bandwidth and improve latency, using 31 | _precompressed_ files to reduce server load. 32 | 33 | - Scales fairly well. (Tested with 10,000+ concurrent connections using multiple 34 | pipelined requests each.) 35 | 36 | Architecture and resource usage: 37 | 38 | - Fully asynchronous architecture means it can handle a large number of 39 | simultaneous connections (10,000+) for not a lot of resources. 40 | 41 | - SMP-aware: requests are distributed over threads, and throughput increases as 42 | cores are added. 43 | 44 | - Allocates memory in proportion to the number of simultaneous outstanding 45 | requests, not the size of any files on disk. 46 | 47 | Security practices: 48 | 49 | - Before so much as reading from its socket, `httpd2` chroots into the content 50 | directory and drops privileges. This makes it less likely to provide 51 | root-level exploits, disclose files outside the content directory like 52 | `/etc/passwd`, or run system binaries. 53 | 54 | - Because the server is designed to serve files that are "public," meaning they 55 | are handed to any web user, there is no user/password database or admin 56 | interface to probe or expose. 57 | 58 | - `httpd2` can't list directory contents. Navigating to a directory serves an 59 | `index.html` file if available, and that's it. 60 | 61 | - `httpd2` declines all the HTTP state modification commands — it only 62 | honors GET and HEAD. 63 | 64 | - `httpd2` never runs another program, including script files (there is no CGI 65 | etc. support). 66 | 67 | - `httpd2` ignores files that are not user+group+world readable on the local 68 | filesystem, so even if you accidentally copy a sensitive file into the web 69 | root, it's unlikely to be served. 70 | 71 | - `httpd2` is written in Rust. Compared to C, this means that certain kind of 72 | exploits are much less likely (particularly buffer overruns, use-after-free, 73 | and [integer overflow][djb-qmail-cve]). Compared to other memory-safe 74 | languages, certain kinds of availability problems are much less likely (such 75 | as memory leaks, latency stutter, and the like). 76 | 77 | - `httpd2` uses pinned versions of well-tested libraries, which are statically 78 | linked into the binary, rather than loading whatever version happens to be 79 | installed at startup. (You can build it against MUSL to get a really-really 80 | static binary.) 81 | 82 | ## Getting started 83 | 84 | Pick a directory containing some world-readable files. (No, really, they must be 85 | mode `0444` or higher.) Let's call that directory `your_dir_here`. 86 | 87 | After checking out the server source code, run: 88 | 89 | ```shell 90 | $ cargo run your_dir_here 91 | ``` 92 | 93 | You should now have an HTTPS server running on `https://localhost:8000/` using a 94 | self-signed certificate. Your browser will freak out the first time you try to 95 | visit it, because the certificate is self-signed. In an actual deployment you'd 96 | use an actual certificate. A reasonable configuration for that might be (note 97 | that you'd have to run this as `root`): 98 | 99 | ```shell 100 | # httpd2 -k /etc/letsencrypt/privkey.pem \ 101 | -r /etc/letsencrypt/fullchain.pem \ 102 | --chroot \ 103 | --uid 65534 --gid 65534 \ 104 | -A [::]:443 \ 105 | --upgrade \ 106 | /public/file/0 107 | ``` 108 | 109 | This will... 110 | 111 | - Use a particular key and cert. 112 | - Chroot into the content directory. (This is currently optional because it 113 | requires root privileges.) 114 | - `setuid`/`setgid` to the given numeric IDs. 115 | - Bind to all interfaces' IPv6/IPv4 addresses on port 443. 116 | - Send the `upgrade-insecure-requests` directive, which tells clients that find 117 | an `http:` link within our site to try `https:` instead because our CMS is 118 | old. 119 | - Serve files found in `/public/file/0`. 120 | 121 | ## MUSL 122 | 123 | If you'd like to produce a fully static program without _any_ system 124 | dependencies on Linux (not even glibc), the current incantation for building 125 | with MUSL on AMD64 is: 126 | 127 | ```shell 128 | $ cargo build --release --target=x86_64-unknown-linux-musl 129 | ``` 130 | 131 | Or with journald support: 132 | 133 | ```shell 134 | $ PKG_CONFIG_ALLOW_CROSS=1 cargo build --release --features journald --target=x86_64-unknown-linux-musl 135 | ``` 136 | 137 | ## More docs 138 | 139 | - [Manual](doc/manual.md) 140 | - [My analysis of this program vs `publicfile`.](doc/vs.md) 141 | 142 | [`publicfile`]: https://cr.yp.to/publicfile.html 143 | [djb-qmail-cve]: https://www.qualys.com/2020/05/19/cve-2005-1513/remote-code-execution-qmail.txt 144 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Feature parity section 2 | 3 | - Parse host from requests, serve different roots per host. 4 | - So, for HTTPS, there's a TLS extension that provides the server name before 5 | encryption starts, so that the right cert can be chosen. 6 | - There's a newer version (ESNI) that encrypts the server name to close the 7 | server name disclosure hole in the original. 8 | - Should probably use a directory layout for host keys: D/host/{cert,key} 9 | 10 | - Customizable xtension to mimetype mapping. 11 | - Mechanism? 12 | 13 | - Unencrypted HTTP handling so I can turn off publicfile. 14 | - I feel like a simple 301 redirect to HTTPS would suffice. 15 | - Are there clients that don't use HTTPS still? 16 | - Looks like 307 with `Non-Authoritative-Reason: HSTS` is the right way. 17 | 18 | # New feature section 19 | 20 | - Support more content-encodings 21 | - Brotli (`br`) shows about a 20% improvement over gzip for HTML. 22 | -------------------------------------------------------------------------------- /doc/manual.md: -------------------------------------------------------------------------------- 1 | # `httpd2` user manual 2 | 3 | **IMPORTANT:** Read the section below titled _Minimum Secure Configuration_ 4 | before running `httpd2` on the open internet. It is possible to configure 5 | `httpd2` in an insecure mode if you don't do this. 6 | 7 | ## Intro 8 | 9 | `httpd2` is a _static file webserver._ It accepts requests from other computers 10 | on the internet, checks to see if they match a file it's been asked to serve, 11 | and if so send the file in response. 12 | 13 | At a high level, that's the whole story, but of course the devil's in the 14 | details. 15 | 16 | I designed `httpd2` to behave differently from most other webservers. It's 17 | intended to serve websites -- mostly, small websites -- very quickly, with 18 | minimal load on the server and minimal extra work for clients. I also designed 19 | it to be resistant to most known classes of attacks on webservers these days. 20 | 21 | `httpd2` is what you might call an _opinionated_ webserver. It does things one 22 | way and generally resists being configurable, except in small cases. This is to 23 | ensure that there aren't lesser-used paths through the code sitting there 24 | untested and rotting. I've tried to make its way of doing things pretty general, 25 | so you can probably use it to serve an existing static-file website without 26 | modifying it. That being said, like any opinionated thing, `httpd2` is probably 27 | not for everyone, and that's okay. 28 | 29 | ## Ways `httpd2` is opinionated, and the reasons why 30 | 31 | You'll want to be cool with these opinions before using `httpd2` or the 32 | experience will be frustrating: 33 | 34 | - Static sites only. `httpd2` reads files from disk and sends them to the 35 | network. That's it. It won't run a PHP script, it won't connect to a database. 36 | It won't even compress or decompress anything. 37 | - This eliminates a large class of potential remote attacks that rely on code 38 | execution or generating large amounts of load. 39 | - This also keeps server load as low as possible, ensuring that the site 40 | scales well and is inexpensive to operate. 41 | - This includes generating directory listings -- `httpd2` doesn't know how to 42 | do that, which prevents attackers from being able to snoop around for files. 43 | - You can always run a second dynamic webserver on a separate port or domain 44 | if you need to. 45 | 46 | - No secrets other than the private key. Anything in the web content directory 47 | is liable to be served to anyone on the internet. There is no authentication 48 | and no notion of privileged vs. unprivileged users. 49 | - This ensures there is no authentication mechanism to attack. 50 | - There are some defenses built in against serving content that is 51 | _unintentionally_ included in the web content directory, such as dotfiles 52 | like `.git`. More details on that below. 53 | 54 | - HTTPS (HTTP over TLS) only. No unencrypted service, and no _effectively_ 55 | unencrypted service (TLS 1.1, weak ciphers). 56 | - Getting a high-quality certificate is now free, so there's no reason not to 57 | do it. 58 | - HTTP-only (unencrypted) sites are increasingly ignored by search engines and 59 | other tools. 60 | - HTTP/2 greatly improves the user experience while reducing server load, and 61 | is only available over TLS. 62 | - You might not feel like your site contains anything important enough to 63 | encrypt, but your users might feel differently depending on the laws in 64 | their jurisdiction -- plus, TLS prevents spoofing and meddling-in-the-middle 65 | attacks. 66 | 67 | - Disclose as little information as possible to remote users. Even a server 68 | panic is returned as a 404 rather than a 500 Internal Server Error. In 69 | particular, never send a stack trace to an untrusted party! 70 | - This makes it difficult to probe the server for crashes and potentially 71 | exploitable issues. 72 | - This doesn't meaningfully affect the end-user experience, where an error is 73 | an error. 74 | - Actual errors are recorded in the logs for your reference, since (I assume!) 75 | you trust yourself to see them. 76 | - `httpd2` also does not identify itself with an HTTP `server:` header. 77 | 78 | - Supplement the other security mitigations by relying on Rust. 79 | - Bounds-based attacks such as stack smashing and buffer overruns are 80 | significantly less likely. 81 | - Each additional connection is very cheap thanks to `async` and tight control 82 | over memory allocation. 83 | - Responses are reliably fast and not subject to garbage collection pauses. 84 | 85 | ## How you use `httpd2` at a high level 86 | 87 | Put your website in a directory on your server's filesystem. It should be HTML 88 | and similar things -- it cannot be PHP or Markdown or anything that would 89 | require transformation before it can be served to clients. Static site 90 | generators like Jekyll or Zola or others make this straightforward, but if you 91 | want an active website with a database, `httpd2` is not for you. 92 | 93 | Point `httpd2` at that directory. It will serve requests from files in that 94 | directory, and try really hard not to accidentally serve requests for files 95 | _outside_ that directory, like your server's password database. 96 | 97 | Before attempting this on a public server, please read the _Minimum Secure 98 | Configuration_ section below. 99 | 100 | 101 | ## What `httpd2` does, specifically 102 | 103 | It's worth understanding what this program actually does, so, here's a 104 | walkthrough. It's a bit security-oriented, because I'm a bit security-oriented. 105 | 106 | ### Startup 107 | 108 | `httpd2` starts up by: 109 | 110 | 1. Parsing command line arguments. 111 | 2. Loading its identity (private key and certificate chain) for TLS from disk. 112 | 3. Binding its service port (generally 443). 113 | 4. Performing a `chroot` into its content directory. 114 | 4. Dropping privileges (changing to a different Unix user and group). 115 | 116 | `chroot` and dropping privileges are mitigations for potential bugs within 117 | `httpd2` that might allow an attacker to attempt arbitrary filesystem access or 118 | other system calls. The `chroot` ensures that files outside of the web content 119 | directory can't be accessed, even if a bug in `httpd2` let an attacker try it. 120 | Dropping privileges prevents e.g. a remote code execution bug from doing root 121 | things on your server. 122 | 123 | Notice that, at this point, `httpd2` has not read any traffic from the network, 124 | nor has it loaded any files from your website. This is deliberate. Reading 125 | traffic from the network allows attacks, and we want attacks to occur once we're 126 | safely sandboxed. 127 | 128 | ### Connection handling 129 | 130 | Now that that's out of the way, `httpd2` handles incoming connections, by 131 | 132 | 1. Accepting an incoming connection (up to a concurrent connection limit you 133 | specify). 134 | 2. Attempting a TLS handshake using a somewhat narrow set of permitted options 135 | (TLS 1.2 and later, using a suite of non-crappy ciphers). 136 | 3. Reading an HTTP request from the connection. 137 | 4. Checking whether the path in the request matches a file that can be served 138 | (details below). 139 | 5. If so, sending the contents of the file. 140 | 6. Repeat. 141 | 142 | This is a little simplified -- in particular, `httpd2` can concurrently handle 143 | many requests on a single connection. But that's not important for our purposes 144 | here. 145 | 146 | How does `httpd2` decide whether a file can be served? Through a two step 147 | process, detailed below. 148 | 149 | ### Path sanitization 150 | 151 | The first step is _sanitization._ Sanitizing a path involves rewriting it into a 152 | canonical form. This doesn't involve any filesystem accesses! The path is 153 | rewritten in memory according to the following rules: 154 | 155 | - Paths are required to be in 7-bit ASCII. Other bytes should be 156 | percent-encoded. This avoids fun behavior in server locale settings. 157 | - Paths are forced to be _relative_ by ensuring they start with `./`, prepending 158 | either or both characters if necessary. 159 | - Repeated slashes (like `///`) are collapsed into a single slash (`/`). 160 | - A dot after a slash (`/.`) becomes a colon (`/:`) to prevent accidental 161 | serving of dotfiles and directory traversal, while still letting you serve 162 | files that _appear_ to start with a dot. More on this below. 163 | - A NUL character is replaced by an underscore (`_`). NUL characters don't 164 | generally bother Rust code but definitely _do_ bother Unix system calls, so we 165 | eliminate them. 166 | 167 | As a result of this, `httpd2` won't perform path traversal: if you attempt to 168 | load `/foo/../bar`, the path does not get translated into `/bar`. Instead, 169 | `httpd2` translates it to `/foo/:./bar` and serves the request only if a `:.` 170 | directory exists. This prevents serving of dotfiles and traversal in the general 171 | case, while allowing you to deliverately serve dot-names like `.well-known` by 172 | creating the directory with the name `:well-known`. 173 | 174 | ### Picky file opening 175 | 176 | After sanitization we come to the second step in the process, _picky open._ The 177 | picky open algorithm is designed to avoid serving any resource that is 178 | _accidentally_ reachable from the web content directory. 179 | 180 | It is at this point that `httpd2` starts making filesystem accesses. 181 | 182 | - If the path refers to a directory, we rewrite it to refer to `index.html` 183 | within that directory and then proceed with the rest of the checks. (This 184 | only happens once.) 185 | - If it refers to a file, the file must meet the following requirements: 186 | 1. It must be accessible to the user `httpd2` is running as, clearly. 187 | 2. It must be world, group, and user readable (Unix mode 0o444 or better). 188 | 3. If the file is world-executable, it must also be user-executable. 189 | 4. It must be a regular file (not a pipe or device or directory). 190 | 191 | File metadata operations use the system calls that operate on open file 192 | descriptors (e.g. `fstat` instead of `stat`) to avoid TOCTOU vulnerabilities in 193 | the algorithm. 194 | 195 | ### Encoded alternates 196 | 197 | Once the process above completes successfully, `httpd2` performs a final check 198 | for an _encoded alternate_ of the file: 199 | - It checks the request's `accept-encoding` HTTP header to see if `gzip` is an 200 | option. 201 | - If so, it appends `.gz` to the path in your web content directory and performs 202 | the picky open process again. 203 | - If it succeeds, `httpd2` checks that the `.gz` alternate was last modified _at 204 | the same time or later than_ the base file, to try to avoid confusing stale 205 | compressed files. 206 | - If it succeeds, the contents of the `.gz` file are sent with 207 | `content-encoding: gzip`. 208 | - If any of that fails, or if the user didn't specify `accept-encoding: gzip`, 209 | the contents of the original file are sent without a `content-encoding`. 210 | 211 | This is designed to let you gzip-compress files that benefit from it ahead of 212 | time, and then serve them to clients without needing to compress or decompress 213 | on the fly. 214 | 215 | 216 | ## Minimum Secure Configuration 217 | 218 | To run `httpd2` with all the security features enabled, you need to do the 219 | following: 220 | 221 | 1. Point it at your server private key (`-k` option) and cert (`-r` option). 222 | 223 | 2. Pass the `--chroot` / `-c` flag to ask `httpd2` to chroot into the web 224 | content directory, removing its ability to see other stuff. 225 | 226 | 3. Pass the `--uid` / `-U` and `--gid` / `-G` flags to ask `httpd2` to switch to 227 | an unprivileged user account after startup (e.g. `nobody`, or a dedicated 228 | user). 229 | 230 | An example minimal secure configuration for `httpd2` might be: 231 | 232 | ``` 233 | httpd2 -c -U 65534 -G 65534 \ 234 | -k /etc/letsencrypt/config/live/yoursite.com/privkey.pem \ 235 | -r /etc/letsencrypt/config/live/yoursite.com/fullchain.pem \ 236 | /your/site/content/directory 237 | ``` 238 | 239 | Security hygiene tips: 240 | 241 | - Don't put your private key in the web content directory. That's asking the 242 | webserver to serve your private key to others, which would make the "private" 243 | part less meaningful. Ideally, put it in a directory that isn't even 244 | accessible to the user ID the server uses after startup (here, 65534). 245 | 246 | - Avoid putting hardlinks from _inside_ your web content directory to other 247 | places on the system. This could allow a chroot escape. If you do this, be 248 | careful where you point them. 249 | 250 | ## Running `httpd2` for development 251 | 252 | `httpd2` requires a Unix-like system, because its security model depends on Unix 253 | features. 254 | 255 | After checking out the sources (and installing a Rust toolchain, natch), run: 256 | 257 | ```shell 258 | cargo run path_to_web_pages 259 | ``` 260 | 261 | ...where `path_to_web_pages` is a path (absolute or relative) to a directory of 262 | web pages you would like to serve. This will start the server on port 8000 as an 263 | unprivileged user without chrooting, using a self-signed key. 264 | 265 | Note that Linux users can also enable structured logging to journald by adding 266 | `--features journald`. 267 | 268 | **By default, the server is compiled with minimal optimizations,** so this 269 | configuration isn't ideal for load testing. To fix this, add the `--release` 270 | flag to `run`. 271 | 272 | ## Logs 273 | 274 | `httpd2` uses an event-oriented structured log format that is, for better or 275 | worse, different from other HTTP servers. It's designed to allow you to inspect 276 | server activity, even for connections that are still outstanding, and to 277 | accurately represent complex multiplexed or pipelined request chains happening 278 | in parallel across many connections. 279 | 280 | By default, logs are sent to `stderr`. If you set up `httpd2` to run as a 281 | service under `systemd` on Linux, that will be routed to `journald` 282 | automatically. (On other systems, you can do something similar.) Note that if 283 | your log handler (e.g. `journald` or `syslog`) prepends timestamps to received 284 | log lines, you might want to add the `--suppress-log-timestamps` command line 285 | argument to `httpd2` or you'll get the timestamps twice. 286 | 287 | On Linux specifically, `httpd2` can also send logs to `journald` directly, by 288 | enabling the `--features journald` build option, and then specifying `--log 289 | journald` at the command line. 290 | 291 | Here is an example log snippet for explanatory purposes. I have wrapped the 292 | lines for clarity in the web browser; they do not wrap in reality to make it 293 | easier to process the logs. 294 | 295 | ``` 296 | Jan 14 19:02:09 : INFO connect, cid: 23938, peer: [REDACTED]:15741 297 | Jan 14 19:02:09 : INFO tls-init, cid: 23938, alpn: h2, tls: TLSv1_3, \ 298 | cipher: TLS13_AES_128_GCM_SHA256 299 | Jan 14 19:02:09 : INFO GET, cid: 23938, rid: 0, \ 300 | uri: https://cliffle.com/blog/making-website-faster/, version: HTTP/2.0, \ 301 | referrer: "https://lobste.rs/" 302 | Jan 14 19:02:09 : INFO response, cid: 23938, rid: 0, status: 200, len: 13661, \ 303 | enc: gzip 304 | Jan 14 19:02:09 : INFO GET, cid: 23938, rid: 1, \ 305 | uri: https://cliffle.com/main.css, version: HTTP/2.0, \ 306 | referrer: "https://cliffle.com/blog/making-website-faster/" 307 | Jan 14 19:02:09 : INFO GET, cid: 23938, rid: 2, \ 308 | uri: https://cliffle.com/blog/making-website-faster/timeline-before.png, \ 309 | version: HTTP/2.0, \ 310 | referrer: "https://cliffle.com/blog/making-website-faster/" 311 | Jan 14 19:02:09 : INFO response, cid: 23938, rid: 1, status: 200, len: 2791, \ 312 | enc: gzip 313 | Jan 14 19:02:09 : INFO response, cid: 23938, rid: 2, status: 200, len: 32043, \ 314 | enc: gzip 315 | Jan 14 19:02:10 : INFO closed, cid: 23937 316 | Jan 14 19:02:10 : INFO GET, cid: 23938, rid: 3, \ 317 | uri: https://cliffle.com/blog/making-website-faster/timeline-after.png, \ 318 | version: HTTP/2.0, \ 319 | referrer: "https://cliffle.com/blog/making-website-faster/" 320 | Jan 14 19:02:10 : INFO response, cid: 23938, rid: 3, status: 200, len: 25263, \ 321 | enc: gzip 322 | Jan 14 19:05:10 : INFO closed, cid: 23938, cause: timeout 323 | ``` 324 | 325 | From top to bottom: 326 | 327 | - Every incoming connection, whether TLS negotiation succeeds or not, generates 328 | a `connect` event. `connect` events have two attributes: `cid` gives a unique 329 | ID to the connection so you can follow it across log events, and `peer` names 330 | the address and port where the connection came from. 331 | 332 | - The `tls-init` event indicates that TLS negotiation succeeded on the 333 | connection that was previously announced with the same `cid` (here, 23938). It 334 | includes a lot of metadata by default: `alpn` here shows that it's an 335 | HTTP/2-aware client requesting a protocol upgrade; `tls` shows that they're 336 | using TLS version 1.3; and `cipher` indicates the cipher they've agreed to. 337 | 338 | - `GET` events indicate that the server has received a request for a resource on 339 | an existing connection (23938). `rid` assigns that request a unique ID within 340 | the connection, so we can follow it; request IDs are always assigned starting 341 | from 0. The `uri` attribute tells us what the user requested, `version` gives 342 | the protocol version they're using (which can vary for each request!), and 343 | `referrer` is the contents of the HTTP referer header (an optional feature 344 | which can be turned on by adding `--log-referer`). 345 | 346 | - The following `response` event indicates that the server is responding to 347 | `cid: 23938, rid: 0` with an HTTP status 200, which means "OK," so we've 348 | decided to serve a file. `len` gives the length of the response we're sending 349 | (13661 bytes), while `enc: gzip` indicates that we found a gzipped alternate 350 | and the client is okay with that. 351 | 352 | - The extra `GET` and `response` events after that follow the same pattern, but 353 | notice that they're starting to interleave: we get two `GET` events before 354 | either of them gets a `response`. This is typical on a pipelined or 355 | multiplexed connection, and being able to track these events accurately in 356 | time is part of the motivation for this log format. 357 | 358 | - The `closed` event we see first is actually for a _different_ connection that 359 | someone had left open: `cid: 23937`. I included this as a reminder that the 360 | log stream is multiplexed across all open connections. 361 | 362 | - Finally we see our `cid: 23938` close at the very end ... three minutes later 363 | due to `timeout`. This is because `httpd2` is running with is default 364 | connection timeout of 181 seconds; you can override this with the 365 | `--connection-time-limit` flag. 366 | 367 | ## Configuring httpd2 to run under systemd 368 | 369 | Here's how I configured `httpd2` to run on my Linux server. `httpd2` doesn't 370 | require (or even particularly know about) systemd, so it should also work fine 371 | using some other approach. I just happen to like systemd. 372 | 373 | First, create a service file. In my case this went into 374 | `/etc/systemd/system/httpd2.service`, because it's a system-local service not 375 | managed by the distro (so it doesn't belong in `/usr`). My service file reads: 376 | 377 | ``` 378 | [Unit] 379 | Description=httpd2 380 | After=network.target 381 | 382 | [Service] 383 | EnvironmentFile=-/etc/default/httpd2 384 | ExecStart=/usr/local/bin/httpd2 $HTTPD2_OPTS 385 | KillMode=process 386 | Restart=on-failure 387 | 388 | [Install] 389 | WantedBy=multi-user.target 390 | ``` 391 | 392 | Notice that I've punted configuration to an environment file. This will also let 393 | me expand `httpd2` to allow more configuration options in the environment down 394 | the road. For now, though, it mostly keeps all the command line noise out of the 395 | service file. My environment file lives at `/etc/default/httpd2` and reads: 396 | 397 | ``` 398 | # Default settings for httpd2. 399 | 400 | # Options to pass to httpd2 401 | HTTPD2_OPTS=\ 402 | -k /etc/letsencrypt/config/live/cliffle.com/privkey.pem \ 403 | -r /etc/letsencrypt/config/live/cliffle.com/fullchain.pem \ 404 | -c -U 65534 -G 65534 \ 405 | \ 406 | -A [::]:443 \ 407 | \ 408 | --upgrade \ 409 | --log-user-agent \ 410 | --log-referer \ 411 | --suppress-log-timestamps \ 412 | --max-threads=10 \ 413 | \ 414 | /public/file/0 415 | ``` 416 | 417 | From top to bottom, we have: 418 | 419 | - The `-k` and `-r` options naming the private key and cert chain, respectively 420 | - The `-c`, `-U`, and `-G` options requesting a chroot and switch to the 421 | `nobody` user (which on my system is numerically 65534). 422 | - An explicit request to bind to `[::]:443`, which forces IPv6. 423 | - `--upgrade` sends the `upgrade-insecure-requests` HTTP header, requesting that 424 | browsers not follow unencrypted `http:` links to my site. 425 | - `--log-user-agent` adds the HTTP `user-agent` to the logs, which has become 426 | necessary to identify some attacks recently. 427 | - `--log-referer` adds the HTTP `referer` to the logs, which is far less useful 428 | than I'd hoped because a lot of programs don't seem to send it anymore. 429 | - `--suppress-log-timestamps` keeps me from getting a double-timestamp in 430 | journald. 431 | - `--max-threads=10` limits the threadpool to 10, which is higher than my number 432 | of CPUs, but since `httpd2` primarily uses threads to execute Unix blocking 433 | filesystem operations, this doesn't cause CPU contention. 434 | - `/public/file/0` is, for historical reasons, where my web content lives. 435 | -------------------------------------------------------------------------------- /doc/vs.md: -------------------------------------------------------------------------------- 1 | # My comparison of `httpd2` with `publicfile` 2 | 3 | `httpd2` is my experimental HTTPS 1/2 static file server. 4 | 5 | [`publicfile`] is an HTTP1 static file server that inspired `httpd2`. 6 | `publicfile` was written by [`djb`]. 7 | 8 | ## About `publicfile` (for our purposes) 9 | 10 | [`publicfile`] is a _pretty good_ example of how to write a secure forking 11 | server on Unix in C. I suggest studying and understanding its source code at 12 | some point. (I used to recommend studying `djb`'s craft more broadly, but his 13 | [prideful handling of a remote code execution vulnerability in 14 | `qmail`][djb-qmail-cve], caused by his own inattention to integer overflows, 15 | has damaged my respect for him.) 16 | 17 | `publicfile` does not, itself, speak TCP/IP -- it relies on `ucspi-tcp` to do 18 | the networky bits. This factoring has a lot of advantages and some drawbacks, 19 | but this is not an analysis of `publicfile` itself. 20 | 21 | I ran the `publicfile`-`ucspi-tcp` stack for many years, but I eventually ran 22 | into some problems: 23 | 24 | 1. HTTP support is limited to HTTP/1.1. 25 | 2. No TLS support. 26 | 3. No IPv6 support. 27 | 4. Maximum concurrency is limited by the use of one process per request. 28 | 5. No `Content-Encoding` support, so files are not sent compressed. 29 | 6. The hardcoded MIME type selection was very mid-90s, and the way to override 30 | it changes the client-visible file extension, confusing Windows. 31 | 7. Fixing any of these things is difficult because of `publicfile`'s license. 32 | 33 | On that last one: `publicfile` is not open source in the modern sense of the 34 | word -- its license does not permit distribution of derivative works. I can't 35 | simply fix things and put up a fork on GitHub. People have distributed sets of 36 | patches against the source code for years, including patches that fix a few of 37 | the issues I've listed here, but these patches often conflict with each other, 38 | and the site that used to archive them recently disappeared from the internet, 39 | leaving me unable to rebuild my server binary. 40 | 41 | My first Rust project was actually a [clone of `publicfile`][httpd1], so faced 42 | with these issues, I decided to do it again. 43 | 44 | ## Ways `httpd2` and `publicfile` are similar 45 | 46 | - Both programs serve parts of the filesystem to the web, and nothing else. 47 | 48 | - Both programs use the same methods for avoiding path traversal, TOCTOU, and 49 | unwanted file disclosure. Specifically, `httpd2` uses a direct gloss of 50 | `publicfile`'s path sanitization and mode checking logic and requires that 51 | files meet the same criteria. 52 | 53 | - Both programs `chroot` and drop privileges. (In both programs, the behavior is 54 | optional and not on by default, meaning both are somewhat insecure in the 55 | default configuration. I am planning on switching this.) 56 | 57 | - Both programs generate error messages that attempt to disclose as little 58 | information as possible. Any file that can't be served is 404 (not found), 59 | even if it exists but is privileged, which would be an information-revealing 60 | 403 (forbidden) on most servers. Neither program will deliver a handy stack 61 | trace or admit an "internal server error" to an attacker. 62 | 63 | - There is very little interesting information to try and extract from either 64 | server. There is no privileged configuration file, no content that requires 65 | authentication, no database handle, and very little mutable state at all. 66 | (`httpd2`'s memory image does contain one significant piece of sensitive data: 67 | its private key. I'll talk more about that in a bit.) 68 | 69 | - Neither program will read or parse a configuration file. There are few 70 | configurable options, and they are all set by command line flags. This means 71 | no parser codebase to target, and no risk of accidentally leaving the 72 | configuration file writable by other users. 73 | 74 | ## Ways `httpd2` is different 75 | 76 | ### `httpd2` supports the modern web 77 | 78 | `publicfile` supports HTTP 0.9 through 1.1, a set of MIME types commonly used on 79 | the academic Internet in the mid-90s, and some useful features like persistent 80 | connections. While it mostly outsources IP to `ucspi-tcp`, it embeds enough 81 | assumptions about IPv4 that it can't support IPv6 without patches. 82 | 83 | People who are browsing with IPv4 disabled, or the HTTPS Everywhere extension, 84 | can't access `publicfile` at all. (And apparently those are the sort of people 85 | who read my blog, so I hear about it regularly.) 86 | 87 | `httpd2` supports HTTPS 1.1 and 2, pipelined multiplexed request streams, TLS 88 | encryption, and IPv4/6. I have no intent to support unencrypted HTTP, which is 89 | effectively deprecated here in 2020. 90 | 91 | `httpd2` still uses a `publicfile`-style hardcoded set of MIME types, but the 92 | set is more appropriate for a static website in 2020. (I plan to make this 93 | configurable, eventually.) 94 | 95 | ### `httpd2` is an asynchronous, single-process server 96 | 97 | `publicfile` is a traditional one-process-per-connection Unix daemon (a "forking 98 | server"). `httpd2` is a single-process multi-threaded asynchronous server. This 99 | means that `httpd2` interacts with clients exclusively using non-blocking 100 | operations, and tracks the state of each connection and request in a data 101 | structure, instead of in the state of a thread of execution. A small pool of 102 | threads serves all outstanding connections. 103 | 104 | Pros: 105 | 106 | - Because an incoming connection no longer requires a `fork`, and an in-progress 107 | connection no longer requires a separate process, `httpd2` can handle a lot 108 | more concurrent connections with a lot fewer resources. For example, if 109 | permitted by `ulimit` to have enough file descriptors, a single server has no 110 | problem handling 10,000 concurrent connections on a 2018-vintage laptop. 111 | 112 | - Because connections are less costly, the server is less vulnerable to 113 | SlowLoris-style DoS attacks. (Still vulnerable, but less so.) 114 | 115 | - The asynchronous architecture makes implementing HTTP/2 _much easier,_ and 116 | HTTP/2 gives a better user experience with lower server resources. HTTP/2 117 | extends the concept of HTTP pipelining by internally multiplexing each 118 | connection into a number of concurrent request streams. This model means that, 119 | even if the HTTP/2 server forked one process per connection, each such process 120 | would still need to keep track of multiple in-flight operations. This means 121 | you've now got _two_ ways of forking state (`fork` and muxing) and that's one 122 | too many for me. 123 | 124 | As you might expect when comparing software to something from DJB, the "Cons" 125 | are mostly in the security area: 126 | 127 | - A `publicfile` process serves a single connection, and is then destroyed -- 128 | whereas `httpd2` serves the entire website with a single process. This means 129 | that the server has to deal with failure using a tool other than `abort`. 130 | `publicfile`'s willingness to `abort` means that it rarely has to think about 131 | things like memory leaks or "unwinding" error conditions correctly. These are 132 | much harder to do correctly in C than in Rust, and so I am less concerned. 133 | 134 | - Because the `httpd2` process serves multiple connections within the same 135 | address space, it is possible that a bug would allow actions taken on one 136 | connection to affect another by corrupting or revealing state. I've mitigated 137 | this by using a memory-safe language and well-tested (and fuzz-tested) 138 | libraries. 139 | 140 | - Similarly, because the server survives across connections, it's possible that 141 | a bug could deliver a payload into the server address space that would alter 142 | or disclose future connections. This is mitigated in the same way. (The server 143 | is also functionally stateless and fast to restart, so [prophylactic 144 | reboots][candea] are also an option.) 145 | 146 | - `httpd2` does not support the UCSPI-TCP interface, and instead needs to bind 147 | its own socket. This means the server process briefly runs as root before 148 | dropping privileges. However, so does publicfile (because of the need to 149 | `chroot`). 150 | 151 | ### `httpd2` is written in Rust. 152 | 153 | I think it's important to neither overstate, nor understate, the importance of 154 | this one. 155 | 156 | `publicfile` is written in C (circa the C89 standard). 157 | 158 | `httpd2` is written in Rust (2018 edition, if you're curious). 159 | 160 | This has some implications for how I approach software engineering problems. 161 | 162 | - Buffer overrun, use-after-free, accidental memory leaks, and other memory 163 | safety errors are much harder to produce. (You have to go out of your way 164 | using `unsafe`.) 165 | 166 | - Integer overflow is trapped, and is thus a thing I don't need to spend time 167 | thinking about. This gets its own bullet because it's a very common source of 168 | mistakes in C, [including in DJB's code][djb-qmail-cve]. (I am aware that he 169 | is continuing to stomp his feet and insist that the users are holding it 170 | wrong, but the fact remains that that code _could_ have been written correctly 171 | if he had cared, and would have been written that way _by default_ in Rust.) 172 | 173 | - Because the likelihood of state corruption is lower (due to the above 174 | bullets), I am less wedded to the idea of `abort` on recoverable errors, which 175 | means I can use a multithreaded instead of multiprocess server. 176 | 177 | - The type system protects against data races and unguarded sharing of data 178 | across threads, so a large class of concurrency bugs are much harder to 179 | produce -- so I can consider a multithreaded server _without_ reintroducing 180 | opportunities for state corruption. 181 | 182 | - The availability of `async` means that I can write asynchronous, event-driven 183 | code as straight-line code and let the compiler sort it out. This in turn 184 | means that I'm willing to use event-driven state machines in contexts where 185 | writing an explicit state machine by hand would have made the code too 186 | difficult to audit and reason about. 187 | 188 | My use of Rust here has also had some unexpected results (at least, I wasn't 189 | expecting them): 190 | 191 | - `httpd2` is less prone to disclosing server crashes in certain contexts, 192 | because the Rust code is using `Result` error handling and can ensure that any 193 | surprises result in a `404` response rather than termination of the 194 | connection. This is good -- I don't want to tell visitors if they have 195 | successfully tickled a crashing codepath in my server! That information should 196 | go the logs, only. 197 | 198 | - `httpd2` is considerably faster than Publicfile, particularly in high load 199 | situations. This is not because Rust is faster than C intrinsically -- it is 200 | because, by freeing my brain from worrying about C problems, I was able to 201 | build a faster server. 202 | 203 | ### `httpd2` relies on software written by other people. 204 | 205 | `publicfile`, like most of DJB's software from its era, doesn't use third-party 206 | dependencies, and in fact goes to some length to isolate itself from bugs *in 207 | the C library.* I think this is admirable, and I'm not doing it. 208 | 209 | `httpd2` relies on Tokio, Hyper, and Rustls, in addition to parts of the Rust 210 | standard library. These libraries are sure to contain bugs, but they are also 211 | mainstays of the Rust community, in production use by a lot of folks, and are 212 | fuzz-tested -- in addition to being written in Rust in the first place. 213 | 214 | But the reason I'm willing to do this mostly comes down to Rust's build system. 215 | The version of every library in `httpd2`'s transitive build graph is checked 216 | into this repo in `Cargo.lock`, including the cryptographic hashes of their 217 | source code. I know that you will get the same versions I tested against, and 218 | that will not change until I push an update to `Cargo.lock` (after, presumably, 219 | testing). This is _very much_ not the case in C. 220 | 221 | This is not without its drawbacks, of course. 222 | 223 | For one, I am exposed to possible vulnerabilities due to bugs in those 224 | libraries, or any supply chain attacks executed before I pinned the dependency 225 | versions. It's theoretically possible that a subtle logic bomb was inserted into 226 | one of the libraries I'm using, just waiting to go off and scramble my address 227 | space. (I'm less concerned about this because the server chroots and drops 228 | privileges before using library code, so the impact of this would be limited to 229 | web traffic.) 230 | 231 | Another drawback: the server binary is much, much larger than either 232 | `publicfile` or an unpublished version I wrote myself without using Tokio/Hyper. 233 | Specifically, as of this writing, `httpd2` is 3MiB. My build of `publicfile`, 234 | with patches, is 23kiB. This difference is not fatal: in practice, the working 235 | set of either binary will fit entirely into CPU cache in 2020. 236 | 237 | [djb-qmail-cve]: https://www.qualys.com/2020/05/19/cve-2005-1513/remote-code-execution-qmail.txt 238 | [httpd1]: https://github.com/cbiffle/httpd1 239 | [candea]: http://roc.cs.berkeley.edu/talks/Reboot_OSDI2000_WIP.pdf 240 | [`publicfile`]: https://cr.yp.to/publicfile.html 241 | [`djb`]: https://cr.yp.to/ 242 | -------------------------------------------------------------------------------- /localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC8DCCAdigAwIBAgIUTBjMzjhPeWUe/W2hYYLh2FWq17IwDQYJKoZIhvcNAQEL 3 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIwMDEwODE3MzY0M1oXDTIwMDIw 4 | NzE3MzY0M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEAsEVKLIk+gUrC5PHV/xUdkmJcL9KnER3DN/tt1dBPKDzS 6 | WB0db/nVuVwDSycfsZm4/qQMWcIWBXl8B1TNIv9piAScsSaScWsXLb5EUuAh7UEh 7 | dyFwef9NT8qDD0lguHwMIYZd7xGHvPMOvgPxHatcvFk2VVQ6J+6ulWvMBgsL9tCc 8 | eLKw68VQdLa3zboOQWN72FU6XD42QpLn27dDanWbix+m4BmN1VbRLGzGpnl31mgZ 9 | XjTjrnz1QMq2cHoNogYOqP5qQOOzNDaQEmu5ZuQYn5gZ80XwdpGL8a1VkGgRX8Sh 10 | rUh5lmxdrEojg1Y8Bf9xB2a14eqUXM9IUxzOqLlhawIDAQABozowODAUBgNVHREE 11 | DTALgglsb2NhbGhvc3QwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMB 12 | MA0GCSqGSIb3DQEBCwUAA4IBAQCnDVqEMKVC+nh81qSJxJxyalCKEgtwFlNGTGsd 13 | U5zB7MOX7VYDuEeLL3I3miT/3/q6UJjcyhBDhxO9yZFdD9XvJJFPYBav7wpXJwUE 14 | dONWsrI8rk3u69MVOc6v5ZNPAPoAS3XHSmHrQanu1JjHxyd1V38zfzy5P8du1vwI 15 | g9rnxdw5WYWUEEhO8s4xohDYFNteLmr026NEaQGcA1TNZu3CdXZRHjCtMkhwzr+p 16 | XL9MLnvHHd9YZ9+ev0m1eoNJszPaLgVy8339ITwa+qdZoRwJOVW8IjJGe2hQd1kG 17 | m0EQxUVREuc9udbQTu4CJKdGfwfBWESj299+se5QEagVLItq 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwRUosiT6BSsLk 3 | 8dX/FR2SYlwv0qcRHcM3+23V0E8oPNJYHR1v+dW5XANLJx+xmbj+pAxZwhYFeXwH 4 | VM0i/2mIBJyxJpJxaxctvkRS4CHtQSF3IXB5/01PyoMPSWC4fAwhhl3vEYe88w6+ 5 | A/Edq1y8WTZVVDon7q6Va8wGCwv20Jx4srDrxVB0trfNug5BY3vYVTpcPjZCkufb 6 | t0NqdZuLH6bgGY3VVtEsbMameXfWaBleNOOufPVAyrZweg2iBg6o/mpA47M0NpAS 7 | a7lm5BifmBnzRfB2kYvxrVWQaBFfxKGtSHmWbF2sSiODVjwF/3EHZrXh6pRcz0hT 8 | HM6ouWFrAgMBAAECggEAfGB35RL2Qr6g5HDsAbBBjH/Q8oGeFsq8a+0CZEM3B3pb 9 | JYdttQxBTShqvoWdrHB+g1b3zAHSDgzZgkbI9G/qY+p1Md64qETbNxCxHxU9ey5g 10 | 0bGLrtmBENMhRREOqT4GRUWNVFo3QBD1DwizAq9eoRwF5ZGn83NMRuyoKn9y8rS2 11 | WvTgJ5goJnWTU6CW4slzdeEsOPsR0ezxoCXzbOQvXgq83ifii3ti/4HYUUD8qJvE 12 | eRRdchZ3oUudtGUHhQJWkqJSzaDrw+puNEMweBgyltlNCj89Gw8h37q6zAt3wj8U 13 | R6BnTx4oqsZHFuDA4y5b+2R4v439N1Hme83jh9z2SQKBgQDVOidowVZBsLXUj5ui 14 | g0KTMOifHow+pWl23c9UeH0SEQv7SFW3UKP9sfuk5CsjxmyNkZ8REyZKVq/ILH0g 15 | DT+8IS4UcG5uH74VUMmOos65u46eWvy5rT6Ee1++RXgIHv1psJBQeLt2MdZN0e3c 16 | pQeBm5xl9qCLhG78SfpuuWudtQKBgQDToU9hzkfA8WwPS/s7PAJZTykk/HlpsC3j 17 | pZfMB9tRFKVqlaElSxBb9ncW7LAMwucj2d3v2GUwp2D19eS8f76L8bqGztOVg1U7 18 | N1e574rvMPGB0d6gwokxh971CkaomiEz+ajki2Z9qJhZzAw0mN6QHuM3vky/mkML 19 | yAHMmg12nwKBgFuRTbs+y7wKFwvhYAS6OazcJAmxJKkCf/f76T1tQMixaWPP/H9s 20 | sgAQnvCAy8XhQFzLXHQItTjXYUWlVVaeWfCAjzlXzxSbrRWaS/RlFkHMucJncICM 21 | VXyvPr6HNrTGGi15FYB5WIe5fz6MGInYlRCjstZWwzsm9EKDwngqSHzBAoGAJ3rW 22 | VkanOCVRpWDlU12Uipir8kxvUfod9XP054knru6NFV8oms5wFNfby5kIFrldaWDB 23 | eHcEGZmACyJ+M3QZVf4YcAGxkxjXE571bKh1YL3er/s47wCbm3PfchMir11hiFKw 24 | 4UHoMtT65vWb6UwDaRt6A/IqWywqCc6cF1E95b0CgYBv95vDqaIfR31aJ2CVO0m3 25 | Bb+qAiyo4i677XK/jFnmQnn/LD70Q3UDnooZpWn56qz5/1mqpFwQYoWdl437Z+L4 26 | w4aNr//2u/iKgK1LAXiVEL/KGEsPW5tckIS6AE8TlKjBjR/HrUcrafamP3YnUZHi 27 | Mx83lkJiWshgi25aH5VmSQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | //! Server argument parsing. 2 | 3 | use std::net::SocketAddr; 4 | use std::path::PathBuf; 5 | use std::time::Duration; 6 | 7 | use clap::{Parser, ValueEnum}; 8 | use nix::unistd::{Gid, Uid}; 9 | 10 | #[derive(Parser)] 11 | pub struct CommonArgs { 12 | /// Specifies that the server should chroot into ROOT. You basically always 13 | /// want ths, unless you're running the server as an unprivileged user. 14 | #[clap(short = 'c', long = "chroot")] 15 | pub should_chroot: bool, 16 | /// Address and port to bind. 17 | #[clap( 18 | short = 'A', 19 | long, 20 | default_value = "[::]:8000", 21 | value_name = "ADDR:PORT" 22 | )] 23 | pub addr: SocketAddr, 24 | /// User to switch to via setuid before serving. Required if the server is 25 | /// started as root. 26 | #[clap( 27 | short = 'U', 28 | long, 29 | value_parser = parse_uid, 30 | value_name = "UID" 31 | )] 32 | pub uid: Option, 33 | /// Group to switch to via setgid before serving. 34 | #[clap( 35 | short = 'G', 36 | long, 37 | value_parser = parse_gid, 38 | value_name = "GID" 39 | )] 40 | pub gid: Option, 41 | /// Selects a logging backend. 42 | #[clap(long, default_value = "stderr", value_name = "NAME")] 43 | pub log: Log, 44 | /// Adds User-Agent header contents, if provided, to request log output. 45 | #[clap(long)] 46 | pub log_user_agent: bool, 47 | /// Adds Referer header contents, if provided, to request log output. 48 | #[clap(long)] 49 | pub log_referer: bool, 50 | /// Don't include timestamps in the log. This may be useful if output is 51 | /// timestamped by an external entity such as journald or syslog. 52 | #[clap(long)] 53 | pub suppress_log_timestamps: bool, 54 | /// How long our resources can be cached elsewhere, in seconds. 55 | #[clap( 56 | long, 57 | default_value = "3600", 58 | value_name = "SECS" 59 | )] 60 | pub default_max_age: usize, 61 | /// Send the HTTP Strict-Transport-Security header, instructing clients not 62 | /// to use unencrypted HTTP to access this site. 63 | #[clap(long)] 64 | pub hsts: bool, 65 | /// Send the upgrade-insecure-requests directive, instructing clients to 66 | /// convert http URLs to https. 67 | #[clap(long)] 68 | pub upgrade: bool, 69 | /// Maximum number of simultaneous connections to allow. 70 | #[clap(long, default_value = "100000", value_name = "COUNT")] 71 | pub max_connections: usize, 72 | /// Maximum number of concurrent streams (HTTP/2) or pipelined requests 73 | /// (HTTP/1.1) to allow per connection. 74 | #[clap(long, default_value = "10", value_name = "COUNT")] 75 | pub max_streams: u32, 76 | /// Maximum duration of a connection in seconds. This timer elapses whether 77 | /// or not the connection is active. 78 | #[clap( 79 | long, 80 | default_value = "181", 81 | value_parser = seconds, 82 | value_name="SECS" 83 | )] 84 | pub connection_time_limit: Duration, 85 | /// Core worker threads to maintain. These will be started immediately, and 86 | /// kept alive while the server is idle, to respond to requests quickly. If 87 | /// not provided, this will equal the number of CPUs. 88 | #[clap(long)] 89 | pub core_threads: Option, 90 | 91 | /// Path of directory to serve (and, if --chroot is provided, the new root 92 | /// directory). 93 | #[clap(value_name = "ROOT")] 94 | pub root: PathBuf, 95 | } 96 | 97 | pub trait HasCommonArgs { 98 | fn common(&self) -> &CommonArgs; 99 | } 100 | 101 | #[derive(Copy, Clone, Debug, ValueEnum)] 102 | pub enum Log { 103 | Stderr, 104 | #[cfg(feature = "journald")] 105 | Journald, 106 | } 107 | 108 | fn parse_uid(val: &str) -> Result { 109 | val.parse::().map(Uid::from_raw) 110 | } 111 | 112 | fn parse_gid(val: &str) -> Result { 113 | val.parse::().map(Gid::from_raw) 114 | } 115 | 116 | pub fn seconds(val: &str) -> Result { 117 | val.parse::().map(Duration::from_secs_f64) 118 | } 119 | -------------------------------------------------------------------------------- /src/bin/http301d.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use std::sync::{ 3 | atomic::{AtomicU64, Ordering}, 4 | Arc, 5 | }; 6 | 7 | use bytes::Bytes; 8 | use http_body_util::Empty; 9 | use httpd2::log::OptionKV; 10 | use hyper::body::Incoming; 11 | use hyper::http::HeaderValue; 12 | use hyper::http::uri::{Scheme, Authority}; 13 | use hyper::server::conn::http1::Builder as ConnBuilder; 14 | use hyper::service::service_fn; 15 | use hyper::{Request, Response, Method, Uri, StatusCode}; 16 | 17 | use nix::unistd::{Gid, Uid}; 18 | 19 | use tokio::net::TcpStream; 20 | use tokio::time::timeout; 21 | 22 | use clap::Parser; 23 | 24 | use httpd2::args::{CommonArgs, Log, HasCommonArgs}; 25 | use httpd2::err::ServeError; 26 | use httpd2::sync::SharedSemaphore; 27 | 28 | #[cfg(feature = "system_allocator")] 29 | #[global_allocator] 30 | static GLOBAL: std::alloc::System = std::alloc::System; 31 | 32 | #[derive(Parser)] 33 | #[clap(name = "http301d")] 34 | pub struct Args { 35 | #[clap(flatten)] 36 | common: CommonArgs, 37 | 38 | /// Target for redirects. 39 | #[structopt(value_name = "HOST")] 40 | pub default_host: String, 41 | } 42 | 43 | impl HasCommonArgs for Args { 44 | fn common(&self) -> &CommonArgs { 45 | &self.common 46 | } 47 | } 48 | 49 | /// Main server entry point. 50 | fn main() { 51 | use futures::future::FutureExt; 52 | use slog::Drain; 53 | 54 | // Go ahead and parse arguments before dropping privileges, since they 55 | // control whether we drop privileges, among other things. 56 | let args = Args::parse(); 57 | 58 | let log = match args.common.log { 59 | Log::Stderr => { 60 | // Produce boring plain text. 61 | let decorator = slog_term::PlainDecorator::new(std::io::stderr()); 62 | // Pack everything onto one line, with the largest scope at left. 63 | let mut fmt = slog_term::FullFormat::new(decorator) 64 | .use_original_order(); 65 | if args.common.suppress_log_timestamps { 66 | fmt = fmt.use_custom_timestamp(|_| Ok(())); 67 | } 68 | let drain = fmt.build().fuse(); 69 | // Don't block the server until a bunch of records have built up. 70 | let drain = 71 | slog_async::Async::new(drain).chan_size(1024).build().fuse(); 72 | slog::Logger::root(drain, slog::o!()) 73 | } 74 | #[cfg(feature = "journald")] 75 | Log::Journald => { 76 | let drain = slog_journald::JournaldDrain.ignore_res(); 77 | // Don't block the server until a bunch of records have built up. 78 | let drain = 79 | slog_async::Async::new(drain).chan_size(1024).build().fuse(); 80 | slog::Logger::root(drain, slog::o!()) 81 | } 82 | }; 83 | 84 | let mut builder = tokio::runtime::Builder::new_multi_thread(); 85 | builder 86 | .worker_threads(args.common.core_threads.unwrap_or_else(num_cpus::get)) 87 | .enable_all() 88 | .build() 89 | .unwrap() 90 | .block_on(start(args, log).map(Result::unwrap)) 91 | } 92 | 93 | /// Starts up a server. 94 | async fn start(args: Args, log: slog::Logger) -> Result<(), ServeError> { 95 | // Sanity check configuration. 96 | let root = Uid::from_raw(0); 97 | if Uid::current() == root { 98 | if !args.common.should_chroot { 99 | eprintln!("Running as root without chroot?!"); 100 | std::process::exit(1); 101 | } 102 | if args.common.uid.is_none() || args.common.uid == Some(root) { 103 | eprintln!("Provide a lower privileged user ID with -U "); 104 | std::process::exit(1); 105 | } 106 | } 107 | 108 | // Things that need to get done while root: 109 | // - Binding to privileged ports. 110 | // - Chrooting. 111 | 112 | let listener = tokio::net::TcpListener::bind(&args.common.addr).await?; 113 | 114 | // Dropping privileges here... 115 | drop_privs(&log, args.common())?; 116 | 117 | let http = configure_server_bits(&args)?; 118 | let args = Arc::new(args); 119 | 120 | slog::info!(log, "serving"; "addr" => args.common.addr); 121 | 122 | // Accept loop: 123 | let connection_counter = AtomicU64::new(0); 124 | let connection_permits = SharedSemaphore::new(args.common.max_connections); 125 | loop { 126 | let permit = connection_permits.acquire().await; 127 | if let Ok((socket, peer)) = listener.accept().await { 128 | // New connection received. Add metadata to the logger. 129 | let log = log.new(slog::o!( 130 | "cid" => connection_counter.fetch_add(1, Ordering::Relaxed), 131 | )); 132 | slog::info!( 133 | log, 134 | "connect"; 135 | "peer" => peer, 136 | ); 137 | // Clone the acceptor handle and HTTP config so they can be moved 138 | // into the connection future below. 139 | let http = http.clone(); 140 | let args = args.clone(); 141 | // Spawn the connection future. 142 | tokio::spawn(async move { 143 | let _permit = permit; 144 | // Now that we're in the connection-specific task, do the actual 145 | // connection setup process. 146 | serve_connection(args, log, http, socket).await 147 | }); 148 | } else { 149 | // Taking the next incoming connection from the socket failed. In 150 | // practice, this means that the server is out of file descriptors. 151 | slog::warn!(log, "error accepting"); 152 | } 153 | } 154 | } 155 | 156 | /// Connection handler. Returns a future that processes requests on `stream`. 157 | async fn serve_connection( 158 | args: Arc, 159 | log: slog::Logger, 160 | http: ConnBuilder, 161 | stream: TcpStream, 162 | ) { 163 | // Begin handling requests. The request_counter tracks 164 | // request IDs within this connection. 165 | let request_counter = AtomicU64::new(0); 166 | let connection_server = http.serve_connection( 167 | hyper_util::rt::tokio::TokioIo::new(stream), 168 | service_fn(|x| handle_request(args.clone(), &log, &request_counter, x)), 169 | ); 170 | match timeout(args.common.connection_time_limit, connection_server).await { 171 | Err(_) => { 172 | slog::info!(log, "closed"; "cause" => "timeout"); 173 | } 174 | Ok(conn_result) => match conn_result { 175 | Ok(_) => slog::info!(log, "closed"), 176 | Err(e) => { 177 | slog::info!(log, "closed"; "cause" => "error"); 178 | slog::debug!(log, "error"; "msg" => %e); 179 | } 180 | }, 181 | } 182 | } 183 | 184 | async fn handle_request( 185 | args: Arc, 186 | log: &slog::Logger, 187 | request_counter: &AtomicU64, 188 | req: Request, 189 | ) -> Result>, ServeError> { 190 | let log = log.new(slog::o!( 191 | "rid" => request_counter.fetch_add(1, Ordering::Relaxed), 192 | )); 193 | // We log all requests, whether or not they will be served. 194 | let method = req.method(); 195 | let uri = req.uri(); 196 | let ua = req.headers().get(hyper::header::USER_AGENT).map(|v| { 197 | slog::o!("user-agent" => format!("{v:?}")) 198 | }); 199 | let rfr = if args.common().log_referer { 200 | req.headers().get(hyper::header::REFERER).map(|v| { 201 | // Again using HeaderValue's Debug impl. 202 | slog::o!("referrer" => format!("{v:?}")) 203 | }) 204 | } else { 205 | None 206 | }; 207 | slog::info!( 208 | log, 209 | "{}", method; 210 | "uri" => %uri, 211 | "version" => ?req.version(), 212 | OptionKV::from(ua), 213 | OptionKV::from(rfr), 214 | ); 215 | match method { 216 | &Method::GET | &Method::HEAD => { 217 | let mut https_uri_parts = uri.clone().into_parts(); 218 | https_uri_parts.scheme = Some(Scheme::HTTPS); 219 | if https_uri_parts.authority.is_none() { 220 | https_uri_parts.authority = Some(Authority::from_str(&args.default_host).unwrap()); 221 | } 222 | let https_uri = Uri::try_from(https_uri_parts).unwrap(); 223 | let mut response = Response::new(Empty::new()); 224 | *response.status_mut() = StatusCode::MOVED_PERMANENTLY; 225 | response.headers_mut().insert( 226 | hyper::header::LOCATION, 227 | HeaderValue::from_str(&https_uri.to_string()).unwrap(), 228 | ); 229 | 230 | Ok(response) 231 | } 232 | _ => { 233 | Ok(Response::builder() 234 | .status(StatusCode::NOT_IMPLEMENTED) 235 | .body(Empty::new()) 236 | .unwrap()) 237 | } 238 | } 239 | } 240 | 241 | /// Drops the set of privileges requested in `args`. At minimum, this changes 242 | /// the CWD; at most, it chroots and changes to an unprivileged user. 243 | fn drop_privs(log: &slog::Logger, args: &CommonArgs) -> Result<(), ServeError> { 244 | std::env::set_current_dir(&args.root)?; 245 | 246 | if args.should_chroot { 247 | nix::unistd::chroot(&args.root)?; 248 | } 249 | if let Some(gid) = args.gid { 250 | nix::unistd::setgid(gid)?; 251 | nix::unistd::setgroups(&[gid])?; 252 | } 253 | if let Some(uid) = args.uid { 254 | nix::unistd::setuid(uid)?; 255 | } 256 | slog::info!( 257 | log, 258 | "privs"; 259 | "cwd" => %args.root.display(), 260 | "chroot" => args.should_chroot, 261 | "setuid" => args.uid.map(Uid::as_raw), 262 | "setgid" => args.gid.map(Gid::as_raw), 263 | ); 264 | 265 | Ok(()) 266 | } 267 | 268 | /// Configure HTTP options for the server. 269 | fn configure_server_bits( 270 | _args: &Args, 271 | ) -> Result { 272 | // Configure HTTP. 273 | let mut http = ConnBuilder::new(); 274 | http.max_buf_size(16384); 275 | Ok(http) 276 | } 277 | -------------------------------------------------------------------------------- /src/bin/httpd2.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::future::Future; 3 | use std::io; 4 | use std::path::{Path, PathBuf}; 5 | use std::pin::Pin; 6 | use std::sync::{ 7 | atomic::{AtomicU64, Ordering}, 8 | Arc, 9 | }; 10 | use std::time::Duration; 11 | 12 | use bytes::Bytes; 13 | use hyper::body::{Incoming, Body}; 14 | use hyper_util::rt::TokioExecutor; 15 | use hyper_util::server::conn::auto::Builder as ConnBuilder; 16 | use hyper::service::service_fn; 17 | use hyper::{Request, Response}; 18 | 19 | use nix::unistd::{Gid, Uid}; 20 | 21 | use rustls::pki_types::{CertificateDer, PrivateKeyDer}; 22 | use rustls::ServerConfig; 23 | 24 | use tokio::net::TcpStream; 25 | use tokio::time::timeout; 26 | use tokio_rustls::{server::TlsStream, TlsAcceptor}; 27 | 28 | use clap::Parser; 29 | 30 | use httpd2::args::{CommonArgs, Log, HasCommonArgs}; 31 | use httpd2::err::ServeError; 32 | use httpd2::sync::SharedSemaphore; 33 | use httpd2::serve; 34 | 35 | #[cfg(feature = "system_allocator")] 36 | #[global_allocator] 37 | static GLOBAL: std::alloc::System = std::alloc::System; 38 | 39 | #[derive(Parser)] 40 | #[clap(name = "httpd2")] 41 | pub struct Args { 42 | #[clap(flatten)] 43 | common: CommonArgs, 44 | 45 | /// Path to the server private key file. 46 | #[clap( 47 | short, 48 | long, 49 | default_value = "localhost.key", 50 | value_name = "PATH" 51 | )] 52 | pub key_path: PathBuf, 53 | 54 | /// Path to the server certificate file. 55 | #[clap( 56 | short = 'r', 57 | long, 58 | default_value = "localhost.crt", 59 | value_name = "PATH" 60 | )] 61 | pub cert_path: PathBuf, 62 | 63 | /// Maximum number of worker threads to start, to handle blocking filesystem 64 | /// operations. Threads are started in response to load, and shut down when 65 | /// not used. The actual thread count will be above this number, because not 66 | /// all threads are workers. Larger numbers will improve performance for 67 | /// large numbers of concurrent requests, at the expense of RAM. 68 | #[clap(long, default_value = "10")] 69 | pub max_threads: usize, 70 | 71 | /// Maximum time a client can spend setting up TLS. This process tends to be 72 | /// very fast, and only happens once per connection, so we can be more 73 | /// aggressive than the overall connection time limit. 74 | #[clap( 75 | long, 76 | default_value = "10", 77 | value_parser = httpd2::args::seconds, 78 | value_name="SECS" 79 | )] 80 | pub tls_handshake_time_limit: Duration, 81 | } 82 | 83 | impl HasCommonArgs for Args { 84 | fn common(&self) -> &CommonArgs { 85 | &self.common 86 | } 87 | } 88 | 89 | /// Main server entry point. 90 | fn main() { 91 | use futures::future::FutureExt; 92 | use slog::Drain; 93 | 94 | // Go ahead and parse arguments before dropping privileges, since they 95 | // control whether we drop privileges, among other things. 96 | let args = Args::parse(); 97 | 98 | let log = match args.common.log { 99 | Log::Stderr => { 100 | // Produce boring plain text. 101 | let decorator = slog_term::PlainDecorator::new(std::io::stderr()); 102 | // Pack everything onto one line, with the largest scope at left. 103 | let mut fmt = slog_term::FullFormat::new(decorator) 104 | .use_original_order(); 105 | if args.common.suppress_log_timestamps { 106 | fmt = fmt.use_custom_timestamp(|_| Ok(())); 107 | } 108 | let drain = fmt.build().fuse(); 109 | // Don't block the server until a bunch of records have built up. 110 | let drain = 111 | slog_async::Async::new(drain).chan_size(1024).build().fuse(); 112 | slog::Logger::root(drain, slog::o!()) 113 | } 114 | #[cfg(feature = "journald")] 115 | Log::Journald => { 116 | let drain = slog_journald::JournaldDrain.ignore_res(); 117 | // Don't block the server until a bunch of records have built up. 118 | let drain = 119 | slog_async::Async::new(drain).chan_size(1024).build().fuse(); 120 | slog::Logger::root(drain, slog::o!()) 121 | } 122 | }; 123 | 124 | let mut mime_map = httpd2::serve::default_content_type_map(); 125 | for (key, value) in std::env::vars() { 126 | if let Some(ext) = key.strip_prefix("CT_") { 127 | slog::info!(log, "extension {ext} => content-type {value}"); 128 | mime_map.insert( 129 | ext.to_string(), 130 | value.leak(), 131 | ); 132 | } 133 | } 134 | let mime_map = Arc::new(mime_map); 135 | 136 | let mut builder = tokio::runtime::Builder::new_multi_thread(); 137 | builder 138 | .max_blocking_threads(args.max_threads) 139 | .worker_threads(args.common.core_threads.unwrap_or_else(num_cpus::get)) 140 | .enable_all() 141 | .build() 142 | .unwrap() 143 | .block_on(start(args, log, mime_map).map(Result::unwrap)) 144 | } 145 | 146 | /// Starts up a server. 147 | async fn start(args: Args, log: slog::Logger, mime_map: Arc>) -> Result<(), ServeError> { 148 | // Sanity check configuration. 149 | let root = Uid::from_raw(0); 150 | if Uid::current() == root { 151 | if !args.common.should_chroot { 152 | eprintln!("Running as root without chroot?!"); 153 | std::process::exit(1); 154 | } 155 | if args.common.uid.is_none() || args.common.uid == Some(root) { 156 | eprintln!("Provide a lower privileged user ID with -U "); 157 | std::process::exit(1); 158 | } 159 | } 160 | 161 | // Things that need to get done while root: 162 | // - Binding to privileged ports. 163 | // - Reading SSL private key. 164 | // - Chrooting. 165 | 166 | let (key, cert_chain) = load_key_and_cert(&args.key_path, &args.cert_path)?; 167 | 168 | let listener = tokio::net::TcpListener::bind(&args.common.addr).await?; 169 | 170 | // Dropping privileges here... 171 | drop_privs(&log, args.common())?; 172 | 173 | let (tls_acceptor, http) = configure_server_bits(&args, key, cert_chain)?; 174 | let args = Arc::new(args); 175 | 176 | slog::info!(log, "serving"; "addr" => args.common.addr); 177 | 178 | // Accept loop: 179 | let connection_counter = AtomicU64::new(0); 180 | let connection_permits = SharedSemaphore::new(args.common.max_connections); 181 | loop { 182 | let permit = connection_permits.acquire().await; 183 | if let Ok((socket, peer)) = listener.accept().await { 184 | // New connection received. Add metadata to the logger. 185 | let log = log.new(slog::o!( 186 | "cid" => connection_counter.fetch_add(1, Ordering::Relaxed), 187 | )); 188 | slog::info!( 189 | log, 190 | "connect"; 191 | "peer" => peer, 192 | ); 193 | // Clone the acceptor handle and HTTP config so they can be moved 194 | // into the connection future below. 195 | let tls_acceptor = tls_acceptor.clone(); 196 | let http = http.clone(); 197 | let args = Arc::clone(&args); 198 | let mime_map = Arc::clone(&mime_map); 199 | // Spawn the connection future. 200 | tokio::spawn(async move { 201 | let _permit = permit; 202 | // Now that we're in the connection-specific task, do the actual 203 | // TLS accept and connection setup process. 204 | match timeout(args.tls_handshake_time_limit, tls_acceptor.accept(socket)).await { 205 | Ok(Ok(stream)) => { 206 | serve_connection(args, log, mime_map, http, stream).await 207 | } 208 | Ok(Err(e)) => { 209 | // TLS negotiation failed. In my observations so far, 210 | // this mostly happens when a client speaks HTTP (or 211 | // nonsense) to an HTTPS port. 212 | slog::info!(log, "tls-error"; "msg" => e); 213 | } 214 | Err(_) => { 215 | // TLS negotiation timed out. 216 | slog::info!(log, "tls-timeout"); 217 | } 218 | } 219 | }); 220 | } else { 221 | // Taking the next incoming connection from the socket failed. In 222 | // practice, this means that the server is out of file descriptors. 223 | slog::warn!(log, "accept-error"); 224 | } 225 | } 226 | } 227 | 228 | /// Connection handler. Returns a future that processes requests on `stream`. 229 | async fn serve_connection( 230 | args: Arc, 231 | log: slog::Logger, 232 | mime_map: Arc>, 233 | http: ConnBuilder, 234 | stream: TlsStream, 235 | ) { 236 | // Announce the connection and record the parameters we have. 237 | { 238 | let session = stream.get_ref().1; 239 | let alpn = 240 | std::str::from_utf8(session.alpn_protocol().unwrap_or(b"NONE")) 241 | .unwrap_or("BOGUS"); 242 | slog::info!( 243 | log, 244 | "tls-init"; 245 | "alpn" => alpn, 246 | "tls" => ?session.protocol_version().unwrap(), 247 | "cipher" => ?session.negotiated_cipher_suite().unwrap().suite(), 248 | ); 249 | } 250 | 251 | // Begin handling requests. The request_counter tracks 252 | // request IDs within this connection. 253 | let request_counter = AtomicU64::new(0); 254 | let connection_server = http.serve_connection( 255 | hyper_util::rt::tokio::TokioIo::new(stream), 256 | service_fn(|x| { 257 | let mime_map = Arc::clone(&mime_map); 258 | handle_request(args.clone(), &log, mime_map, &request_counter, x) 259 | }), 260 | ); 261 | match timeout(args.common.connection_time_limit, connection_server).await { 262 | Err(_) => { 263 | slog::info!(log, "closed"; "cause" => "timeout"); 264 | } 265 | Ok(conn_result) => match conn_result { 266 | Ok(_) => slog::info!(log, "closed"), 267 | Err(e) => { 268 | slog::info!(log, "closed"; "cause" => "error"); 269 | slog::debug!(log, "error"; "msg" => %e); 270 | } 271 | }, 272 | } 273 | } 274 | 275 | /// Request handler. This mostly defers to the `serve` module right now. 276 | fn handle_request( 277 | args: Arc, 278 | log: &slog::Logger, 279 | mime_map: Arc>, 280 | request_counter: &AtomicU64, 281 | req: Request, 282 | ) -> impl Future + Send>>>, ServeError>> { 283 | // Select a request ID and tag our logger with it. 284 | serve::files( 285 | args, 286 | log.new(slog::o!( 287 | "rid" => request_counter 288 | .fetch_add(1, Ordering::Relaxed), 289 | )), 290 | mime_map, 291 | req, 292 | ) 293 | } 294 | 295 | /// Loads TLS credentials from the filesystem using synchronous operations. 296 | fn load_key_and_cert( 297 | key_path: &Path, 298 | cert_path: &Path, 299 | ) -> io::Result<(PrivateKeyDer<'static>, Vec>)> { 300 | let key = rustls_pemfile::read_one( 301 | &mut io::BufReader::new(std::fs::File::open(key_path)?), 302 | ).map_err(|_| { 303 | io::Error::new( 304 | io::ErrorKind::Other, 305 | "can't load private key (bad file?)", 306 | ) 307 | })?; 308 | let key = key.ok_or_else(|| { 309 | io::Error::new( 310 | io::ErrorKind::Other, 311 | "no keys found in private key file", 312 | ) 313 | })?; 314 | let key = match key { 315 | rustls_pemfile::Item::Pkcs8Key(der) => der.into(), 316 | rustls_pemfile::Item::Sec1Key(der) => der.into(), 317 | _ => return Err(io::Error::new( 318 | io::ErrorKind::Other, 319 | "unsupported private key type", 320 | )), 321 | }; 322 | let cert_chain = rustls_pemfile::certs(&mut io::BufReader::new( 323 | std::fs::File::open(cert_path)?, 324 | )) 325 | .collect::, _>>() 326 | .map_err(|_| { 327 | io::Error::new(io::ErrorKind::Other, "can't load certificate") 328 | })?; 329 | Ok((key, cert_chain)) 330 | } 331 | 332 | /// Drops the set of privileges requested in `args`. At minimum, this changes 333 | /// the CWD; at most, it chroots and changes to an unprivileged user. 334 | fn drop_privs(log: &slog::Logger, args: &CommonArgs) -> Result<(), ServeError> { 335 | std::env::set_current_dir(&args.root)?; 336 | 337 | if args.should_chroot { 338 | nix::unistd::chroot(&args.root)?; 339 | } 340 | if let Some(gid) = args.gid { 341 | nix::unistd::setgid(gid)?; 342 | nix::unistd::setgroups(&[gid])?; 343 | } 344 | if let Some(uid) = args.uid { 345 | nix::unistd::setuid(uid)?; 346 | } 347 | slog::info!( 348 | log, 349 | "privs"; 350 | "cwd" => %args.root.display(), 351 | "chroot" => args.should_chroot, 352 | "setuid" => args.uid.map(Uid::as_raw), 353 | "setgid" => args.gid.map(Gid::as_raw), 354 | ); 355 | 356 | Ok(()) 357 | } 358 | 359 | /// Configure TLS and HTTP options for the server. 360 | fn configure_server_bits( 361 | args: &Args, 362 | private_key: PrivateKeyDer<'static>, 363 | cert_chain: Vec>, 364 | ) -> Result<(TlsAcceptor, ConnBuilder), ServeError> { 365 | // Configure TLS and HTTP. 366 | let tls_acceptor = { 367 | let mut config = ServerConfig::builder() 368 | // Don't require authentication. 369 | .with_no_client_auth() 370 | // We're using only this single identity. 371 | .with_single_cert(cert_chain, private_key)?; 372 | // Prefer HTTP/2 but support 1.1. 373 | config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; 374 | TlsAcceptor::from(Arc::new(config)) 375 | }; 376 | // Configure Hyper. 377 | let mut http = ConnBuilder::new(TokioExecutor::new()); 378 | http.http2() 379 | .max_concurrent_streams(Some(args.common.max_streams)) 380 | .max_frame_size(16384); 381 | http.http1() 382 | .max_buf_size(16384); // down from 400kiB default 383 | 384 | Ok((tls_acceptor, http)) 385 | } 386 | -------------------------------------------------------------------------------- /src/err.rs: -------------------------------------------------------------------------------- 1 | //! Error union type. 2 | 3 | use std::io; 4 | use thiserror::Error; 5 | 6 | /// Error union type for the server. 7 | #[derive(Debug, Error)] 8 | pub enum ServeError { 9 | /// Errors coming from within Hyper. 10 | #[error(transparent)] 11 | Hyper(#[from] hyper::Error), 12 | /// I/O-related errors. 13 | #[error(transparent)] 14 | Io(#[from] io::Error), 15 | /// Errors in the Nix syscall interface. 16 | #[error(transparent)] 17 | Nix(#[from] nix::Error), 18 | /// Errors in the TLS subsystem. 19 | #[error(transparent)] 20 | Tls(#[from] rustls::Error), 21 | /// Errors generated defensively to force a connection to close. 22 | #[error(transparent)] 23 | Defense(#[from] DefenseError), 24 | } 25 | 26 | #[derive(Debug, Error)] 27 | #[error("defense mechanism triggered")] 28 | pub struct DefenseError; 29 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod args; 2 | pub mod err; 3 | pub mod log; 4 | pub mod percent; 5 | pub mod picky; 6 | pub mod serve; 7 | pub mod sync; 8 | pub mod traversal; 9 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | //! Logging support code. 2 | 3 | pub struct OptionKV(Option); 4 | 5 | impl From> for OptionKV { 6 | fn from(o: Option) -> Self { 7 | OptionKV(o) 8 | } 9 | } 10 | 11 | impl slog::KV for OptionKV { 12 | fn serialize( 13 | &self, 14 | record: &slog::Record, 15 | serializer: &mut dyn slog::Serializer, 16 | ) -> slog::Result { 17 | match &self.0 { 18 | None => Ok(()), 19 | Some(kv) => kv.serialize(record, serializer), 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/percent.rs: -------------------------------------------------------------------------------- 1 | //! URL percent-encoding decoder. 2 | //! 3 | //! This decoder interprets the standard somewhat loosely. Correctly encoded 4 | //! paths are decoded just fine; errors, on the other hand, are literally passed 5 | //! into the output. Since percent signs are not significant in paths, this is 6 | //! safe. 7 | //! 8 | //! The decoder is expressed as an `Iterator`. Create one using 9 | //! `decode`. 10 | 11 | pub fn decode(inner: impl Iterator) -> impl Iterator { 12 | PercentDecoder::from(inner) 13 | } 14 | 15 | struct PercentDecoder { 16 | inner: I, 17 | state: PercentState, 18 | } 19 | 20 | impl From for PercentDecoder { 21 | fn from(inner: I) -> Self { 22 | Self { 23 | inner, 24 | state: PercentState::Normal, 25 | } 26 | } 27 | } 28 | 29 | enum PercentState { 30 | /// Haven't seen a percent escape recently. 31 | Normal, 32 | /// A percent escape was found to be invalid on its final character. We have 33 | /// yielded the original '%' and need to yield these additional characters 34 | /// in sequence before touching `inner`. 35 | Unspool2(char, char), 36 | /// A percent escape was found to be invalid. We have yielded some portion 37 | /// of it literally, and still need to yield this char before touching 38 | /// `inner`. 39 | Unspool(char), 40 | } 41 | 42 | impl> Iterator for PercentDecoder { 43 | type Item = char; 44 | 45 | fn next(&mut self) -> Option { 46 | fn hexit(c: char) -> Option { 47 | match c { 48 | '0'..='9' => Some(c as u8 - b'0'), 49 | 'A'..='F' => Some(c as u8 - b'A' + 10), 50 | 'a'..='f' => Some(c as u8 - b'a' + 10), 51 | _ => None, 52 | } 53 | } 54 | 55 | match self.state { 56 | PercentState::Normal => match self.inner.next()? { 57 | '%' => { 58 | if let Some(x) = self.inner.next() { 59 | if let Some(y) = self.inner.next() { 60 | if let (Some(x), Some(y)) = (hexit(x), hexit(y)) { 61 | return Some((x << 4 | y) as char); 62 | } 63 | self.state = PercentState::Unspool2(x, y); 64 | } else { 65 | self.state = PercentState::Unspool(x); 66 | } 67 | } 68 | Some('%') 69 | } 70 | c => Some(c), 71 | }, 72 | PercentState::Unspool2(x, y) => { 73 | self.state = PercentState::Unspool(y); 74 | Some(x) 75 | } 76 | PercentState::Unspool(y) => { 77 | self.state = PercentState::Normal; 78 | Some(y) 79 | } 80 | } 81 | } 82 | 83 | fn size_hint(&self) -> (usize, Option) { 84 | let (min, max) = self.inner.size_hint(); 85 | (min / 3, max) 86 | } 87 | } 88 | 89 | #[cfg(test)] 90 | mod tests { 91 | use super::*; 92 | 93 | fn decode_str(s: &str) -> String { 94 | decode(s.chars()).collect() 95 | } 96 | 97 | #[test] 98 | fn percent_decode() { 99 | assert_eq!(decode_str(""), ""); 100 | assert_eq!(decode_str("%"), "%"); 101 | assert_eq!(decode_str("%%%"), "%%%"); 102 | assert_eq!(decode_str("%4"), "%4"); 103 | assert_eq!(decode_str("%41"), "A"); 104 | assert_eq!(decode_str("%4a"), "J"); 105 | assert_eq!(decode_str("%4A"), "J"); 106 | assert_eq!(decode_str("%4g"), "%4g"); 107 | assert_eq!(decode_str("%2525"), "%25"); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/picky.rs: -------------------------------------------------------------------------------- 1 | //! Picky filesystem APIs for channeling djb. 2 | 3 | use std::io; 4 | use std::os::unix::fs::PermissionsExt; 5 | use std::path::Path; 6 | use std::time::SystemTime; 7 | 8 | use tokio::fs; 9 | 10 | /// Information about an open file, including the file handle. 11 | #[derive(Debug)] 12 | pub struct File { 13 | /// An async handle to the file, open for read. 14 | pub file: fs::File, 15 | /// Length of the file in bytes. 16 | pub len: u64, 17 | /// Inferred content type of file. 18 | pub content_type: &'static str, 19 | /// Modification timestamp. 20 | pub modified: SystemTime, 21 | /// Cache TTL in seconds. 22 | pub ttl: Option, 23 | } 24 | 25 | /// Accesses a path for file serving, if it meets certain narrow criteria. 26 | /// 27 | /// This operation is critical to the correctness of the server. It is careful 28 | /// in several respects: 29 | /// 30 | /// 1. To avoid TOCTOU issues, it opens files first and checks their metadata 31 | /// second. 32 | /// 33 | /// 2. Only files that are user/group/world readable are acknowledged to exist. 34 | /// 35 | /// 3. Files that are world-X but not user-X are rejected, for reasons inherited 36 | /// from publicfile that I don't quite recall. 37 | /// 38 | /// If the path turns out to be a directory, returns `Error::Directory` only if 39 | /// it meets all the above criteria, otherwise you'll get `Error::BadMode`. 40 | pub async fn open( 41 | log: &slog::Logger, 42 | path: &Path, 43 | infer_content_type: impl FnOnce(&Path) -> &'static str, 44 | choose_ttl: impl FnOnce(&Path) -> Option, 45 | ) -> Result { 46 | slog::debug!(log, "picky_open({:?})", path); 47 | 48 | let file = fs::File::open(path).await.map_err(|e| { 49 | slog::debug!(log, "can't open: {}", e); 50 | e 51 | })?; 52 | let meta = file.metadata().await?; 53 | let mode = meta.permissions().mode(); 54 | 55 | if mode & 0o444 != 0o444 || mode & 0o101 == 0o001 { 56 | slog::debug!(log, "mode {:#o} is not OK", mode); 57 | Err(Error::BadMode(mode)) 58 | } else if meta.is_file() { 59 | slog::debug!(log, "opened"); 60 | Ok(File { 61 | file, 62 | len: meta.len(), 63 | modified: meta.modified().unwrap(), 64 | content_type: infer_content_type(path), 65 | ttl: choose_ttl(path), 66 | }) 67 | } else if meta.is_dir() { 68 | slog::debug!(log, "found dir"); 69 | Err(Error::Directory) 70 | } else { 71 | slog::debug!(log, "neither file nor dir"); 72 | Err(Error::SpecialFile) 73 | } 74 | } 75 | 76 | #[derive(Debug)] 77 | pub enum Error { 78 | BadMode(u32), 79 | Directory, 80 | SpecialFile, 81 | Io(io::Error), 82 | } 83 | 84 | impl std::fmt::Display for Error { 85 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 86 | match self { 87 | Self::BadMode(x) => write!(f, "mode {:#o}", x), 88 | Self::Directory => f.write_str("is dir"), 89 | Self::SpecialFile => f.write_str("is special"), 90 | Self::Io(e) => e.fmt(f), 91 | } 92 | } 93 | } 94 | 95 | impl From for Error { 96 | fn from(e: io::Error) -> Self { 97 | Self::Io(e) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/serve.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::sync::Arc; 3 | use std::ffi::OsStr; 4 | use std::path::Path; 5 | use std::pin::Pin; 6 | 7 | use bytes::Bytes; 8 | use enum_map::{Enum, EnumMap}; 9 | use futures::stream::StreamExt; 10 | 11 | use hyper::body::{Body, Frame}; 12 | use hyper::header::HeaderValue; 13 | use hyper::{body::Incoming, Method, Request, Response, StatusCode}; 14 | use http_body_util::{StreamBody, BodyExt}; 15 | 16 | use tokio_util::codec::{self, Decoder}; 17 | 18 | use crate::args::{HasCommonArgs, CommonArgs}; 19 | use crate::err::{ServeError, DefenseError}; 20 | use crate::log::OptionKV; 21 | use crate::picky::{self, File}; 22 | use crate::{percent, traversal}; 23 | 24 | fn empty() -> Pin + Send>> { 25 | Box::pin(http_body_util::Empty::new().map_err(|r| match r {})) 26 | } 27 | 28 | /// Attempts to serve a file in response to `req`. 29 | pub async fn files( 30 | args: Arc, 31 | log: slog::Logger, 32 | mime_map: Arc>, 33 | req: Request, 34 | ) -> Result + Send>>>, ServeError> { 35 | // We log all requests, whether or not they will be served. 36 | let method = req.method(); 37 | let uri = req.uri(); 38 | let ua = if args.common().log_user_agent { 39 | req.headers().get(hyper::header::USER_AGENT).map(|v| { 40 | // Use HeaderValue's Debug impl to safely print attacker-controlled 41 | // data. 42 | slog::o!("user-agent" => format!("{v:?}")) 43 | }) 44 | } else { 45 | None 46 | }; 47 | let rfr = if args.common().log_referer { 48 | req.headers().get(hyper::header::REFERER).map(|v| { 49 | // Again using HeaderValue's Debug impl. 50 | slog::o!("referrer" => format!("{v:?}")) 51 | }) 52 | } else { 53 | None 54 | }; 55 | slog::info!( 56 | log, 57 | "{}", method; 58 | "uri" => %uri, 59 | "version" => ?req.version(), 60 | OptionKV::from(ua), 61 | OptionKV::from(rfr), 62 | ); 63 | 64 | // Request-level defenses: 65 | 66 | // Check the request for a body. Deny if found. 67 | if req.size_hint().lower() != 0 || req.size_hint().upper() != Some(0) { 68 | slog::warn!( 69 | log, 70 | "defense"; 71 | "cause" => "upload", 72 | ); 73 | return Err(DefenseError.into()); 74 | } 75 | 76 | // Other than logging, we defer work to the latest reasonable point, to 77 | // reduce the load of bogus requests on the server. This means that bogus 78 | // requests will incur lower latency than legit ones, but the only 79 | // side-channel that opens should be the ability to probe what public files 80 | // exist on the filesystem ... which is exactly what the HTTP server is for. 81 | 82 | let mut accept_encodings = EnumMap::default(); 83 | let (mut response, mut response_info) = match (method, uri.path()) { 84 | (&Method::GET, path) | (&Method::HEAD, path) => { 85 | // Sanitize the path using a derivative of publicfile's algorithm. 86 | // It appears that Hyper blocks non-ASCII characters. 87 | let mut sanitized = sanitize_path(path); 88 | 89 | // Scan the request headers to see if compressed responses are OK. 90 | // We need to do this before consulting the filesystem, but it's 91 | // fairly quick. 92 | req 93 | .headers() 94 | // The header can technically be specified more than once. 95 | .get_all(hyper::header::ACCEPT_ENCODING) 96 | .iter() 97 | // Ignore any that aren't UTF-8. We need UTF-8 to get trim() 98 | // below, and all our recognized ones are ASCII anyway. 99 | .filter_map(|list| list.to_str().ok()) 100 | // Split them all at commas and merge them together. 101 | .flat_map(|list| list.split(',')) 102 | // Algorithms may have semicolon-delimited attributes; remove 103 | // them. 104 | .map(|name| name.split_once(';').map(|(before, _)| before).unwrap_or(name)) 105 | // Collect the methods we recognize, ignoring leading or 106 | // trailing whitespace. 107 | .for_each(|name| match name.trim() { 108 | "gzip" => accept_encodings[Encoding::Gzip] = true, 109 | "br" => accept_encodings[Encoding::Brotli] = true, 110 | _ => (), 111 | }); 112 | 113 | // Now, see what the path yields. 114 | let open_result = picky_open_with_redirect_and_alt( 115 | &log, 116 | &*mime_map, 117 | &mut sanitized, 118 | accept_encodings, 119 | ) 120 | .await; 121 | 122 | match open_result { 123 | Ok((file, enc)) => { 124 | // Collect the caller's cache date and etag, if present. 125 | // 126 | // Because the date format is fixed as of HTTP/1.1, and 127 | // because caches send the *exact* previous date in 128 | // if-modified-since, we can get away with doing an exact 129 | // bytewise date comparison rather than parsing. 130 | // 131 | // ETag is already defined as an exact comparison. 132 | let if_modified_since = req 133 | .headers() 134 | .get(hyper::header::IF_MODIFIED_SINCE) 135 | .and_then(|value| value.to_str().ok()); 136 | let if_none_match = req 137 | .headers() 138 | .get(hyper::header::IF_NONE_MATCH) 139 | .and_then(|value| value.to_str().ok()); 140 | 141 | let (resp, srv) = serve_file( 142 | args.common(), 143 | file, 144 | enc, 145 | if_modified_since, 146 | if_none_match, 147 | method == Method::GET, 148 | ); 149 | (resp, ResponseInfo::Success(srv)) 150 | } 151 | Err(e) => ( 152 | Response::builder() 153 | .status(StatusCode::NOT_FOUND) 154 | .body(empty()) 155 | .unwrap(), 156 | ResponseInfo::Error(ErrorContext::Error(e), None), 157 | ), 158 | } 159 | } 160 | // Any other request method falls here. 161 | _ => ( 162 | Response::builder() 163 | .status(StatusCode::NOT_IMPLEMENTED) 164 | .body(empty()) 165 | .unwrap(), 166 | ResponseInfo::Error(ErrorContext::Fixed("bad method"), None), 167 | ), 168 | }; 169 | 170 | if let ResponseInfo::Error(_, srv) = &mut response_info { 171 | // Attempt to present the user with an error page. 172 | slog::debug!(log, "searching for error page"); 173 | 174 | let mut redirect = 175 | format!("./errors/{:03}.html", response.status().as_u16()); 176 | // TODO: it would be nice to break the picky combinators out, so I could 177 | // have picky_open_with_alt (no redirect) here. 178 | let err_result = 179 | picky_open_with_redirect_and_alt(&log, &*mime_map, &mut redirect, accept_encodings) 180 | .await; 181 | if let Ok((error_page, enc)) = err_result { 182 | let (mut r, s) = serve_file(args.common(), error_page, enc, None, None, true); 183 | *r.status_mut() = response.status(); 184 | response = r; 185 | *srv = s; 186 | } 187 | } 188 | 189 | let log_kv = slog::o!("status" => response.status().as_u16()); 190 | let srv_kv = match &response_info { 191 | ResponseInfo::Error(_, os) | ResponseInfo::Success(os) => { 192 | os.as_ref().map(|s| { 193 | slog::o!( 194 | "len" => s.len, 195 | "enc" => s.encoding, 196 | ) 197 | }) 198 | } 199 | }; 200 | match response_info { 201 | ResponseInfo::Error(ErrorContext::Fixed(ctx), _) => slog::info!( 202 | log, 203 | "response"; 204 | log_kv, 205 | "err" => ctx, 206 | OptionKV::from(srv_kv), 207 | ), 208 | ResponseInfo::Error(ErrorContext::Error(e), _) => slog::info!( 209 | log, 210 | "response"; 211 | log_kv, 212 | "err" => %e, 213 | OptionKV::from(srv_kv), 214 | ), 215 | ResponseInfo::Success(_) => slog::info!( 216 | log, 217 | "response"; 218 | log_kv, 219 | OptionKV::from(srv_kv), 220 | ), 221 | } 222 | 223 | Ok(response) 224 | } 225 | 226 | enum ErrorContext { 227 | Fixed(&'static str), 228 | Error(picky::Error), 229 | } 230 | 231 | enum ResponseInfo { 232 | Error(ErrorContext, Option), 233 | Success(Option), 234 | } 235 | 236 | struct Served { 237 | len: u64, 238 | encoding: &'static str, 239 | } 240 | 241 | /// Generates a `Response` with common headers initialized, and an empty body. 242 | /// 243 | /// `args` is used to customize generation of some headers. 244 | /// 245 | /// `len`, `content_type`, and `modified` are metadata of the file being served. 246 | /// 247 | /// `enc` gives the content-encoding of the file, if it is not being served 248 | /// plain. 249 | fn start_response( 250 | args: &CommonArgs, 251 | len: u64, 252 | content_type: &'static str, 253 | modified: &str, 254 | etag: &str, 255 | ttl: Option, 256 | enc: Option, 257 | ) -> Response + Send>>> { 258 | let mut response = Response::new(empty()); 259 | 260 | let headers = response.headers_mut(); 261 | 262 | headers.insert(hyper::header::CONTENT_LENGTH, len.into()); 263 | headers.insert( 264 | hyper::header::CONTENT_TYPE, 265 | HeaderValue::from_static(content_type), 266 | ); 267 | headers.insert( 268 | hyper::header::VARY, 269 | HeaderValue::from_name(hyper::header::ACCEPT_ENCODING), 270 | ); 271 | headers.insert(hyper::header::CACHE_CONTROL, 272 | HeaderValue::from_str(&format!("max-age={}", ttl.unwrap_or(args.default_max_age))).unwrap() 273 | ); 274 | headers.insert( 275 | hyper::header::LAST_MODIFIED, 276 | HeaderValue::from_str(modified).unwrap(), 277 | ); 278 | headers.insert( 279 | hyper::header::ETAG, 280 | HeaderValue::from_str(etag).unwrap(), 281 | ); 282 | if let Some(enc) = enc { 283 | headers.insert(hyper::header::CONTENT_ENCODING, enc.into()); 284 | } 285 | if args.hsts { 286 | headers.insert( 287 | hyper::header::STRICT_TRANSPORT_SECURITY, 288 | // TODO: this should be larger, I'm keeping it low 289 | // for testing. 290 | HeaderValue::from_static("max-age=60"), 291 | ); 292 | } 293 | if args.upgrade { 294 | headers.insert( 295 | hyper::header::CONTENT_SECURITY_POLICY, 296 | HeaderValue::from_static("upgrade-insecure-requests;"), 297 | ); 298 | } 299 | response 300 | } 301 | 302 | /// Extends `picky::open` with directory redirect handling. 303 | /// 304 | /// If `path` turns out to be a directory, this routine will retry the 305 | /// `picky_open` to search for an `index.html` file within that directory. If 306 | /// the `index.html` has the appropriate permissions and is a regular file, the 307 | /// open operation succeeds, returning its contents. 308 | async fn picky_open_with_redirect( 309 | log: &slog::Logger, 310 | mime_map: &BTreeMap, 311 | path: &mut String, 312 | ) -> Result { 313 | // Performance optimization: if the path is *syntactically* a directory, 314 | // i.e. it ends in a slash, pre-append the `index.html`. This reduces 315 | // filesystem round trips (and thus the number of blocking operations 316 | // affecting the thread pool) by 1, and improved a particular load benchmark 317 | // by 18% at the time of writing. 318 | let trailing_slash = path.ends_with('/'); 319 | if trailing_slash { 320 | path.push_str("index.html"); 321 | } 322 | 323 | match picky::open(log, Path::new(path), |p| find_content_type(mime_map, p), map_cache_ttl).await { 324 | Err(picky::Error::Directory) if !trailing_slash => { 325 | slog::debug!(log, "--> index.html"); 326 | path.push_str("/index.html"); 327 | picky::open(log, Path::new(path), |p| find_content_type(mime_map, p), map_cache_ttl).await 328 | } 329 | r => r, 330 | } 331 | } 332 | 333 | /// Extends `picky_open_with_redirect` with selection of precompressed 334 | /// alternate files. 335 | /// 336 | /// When `picky_open_with_redirect` finds a readable regular file at `path`, 337 | /// this routine will retry to search for a compressed version of the file with 338 | /// the same name and the `.gz` extension appended. If the compressed version 339 | /// exists, passes `picky_open`'s criteria, *and* has a last-modified date at 340 | /// least as recent as the original file, then it is substituted. 341 | /// 342 | /// Importantly, the content-type judgment for the *original*, non-compressed 343 | /// file, is preserved. 344 | /// 345 | /// Returns the normal `File` result, plus an optional `Content-Encoding` value 346 | /// if an alternate encoding was selected. 347 | async fn picky_open_with_redirect_and_alt( 348 | log: &slog::Logger, 349 | mime_map: &BTreeMap, 350 | path: &mut String, 351 | encodings: EnumMap, 352 | ) -> Result<(File, Option), picky::Error> { 353 | let file = picky_open_with_redirect(log, mime_map, path).await?; 354 | 355 | // If the caller isn't willing to accept any compressed encodings, we're 356 | // done. 357 | if encodings.values().all(|&accept| accept == false) { 358 | return Ok((file, None)); 359 | } 360 | 361 | open_precompressed(log, path, file, encodings).await 362 | } 363 | 364 | async fn open_precompressed( 365 | log: &slog::Logger, 366 | path: &mut String, 367 | file: File, 368 | encodings: EnumMap, 369 | ) -> Result<(File, Option), picky::Error> { 370 | slog::debug!(log, "checking for precompressed alternate"); 371 | let path_orig_len = path.len(); 372 | for (encoding, accepted) in encodings { 373 | if !accepted { continue; } 374 | 375 | path.push_str(encoding.file_extension()); 376 | 377 | // Note that we're "inferring" the old content-type and TTL. 378 | match picky::open(log, Path::new(path), |_| file.content_type, |_| file.ttl).await { 379 | Ok(altfile) if altfile.modified >= file.modified => { 380 | slog::debug!(log, "serving {}", encoding.short_name()); 381 | // Preserve mod date of original content. 382 | return Ok(( 383 | File { 384 | modified: file.modified, 385 | ..altfile 386 | }, 387 | Some(encoding), 388 | )); 389 | } 390 | Ok(_) => { 391 | // We distinguish this case only to improve debug output; if 392 | // debug output is disabled, as is typical in production, it 393 | // collapses with the one below. 394 | slog::debug!(log, "alternate found for encoding {encoding:?} but is modified later than primary"); 395 | } 396 | _ => { 397 | // If the compressed alternative isn't available, or if it 398 | // predates the actual content, ignore it. 399 | slog::debug!(log, "no alternate found for encoding {encoding:?}"); 400 | } 401 | } 402 | path.truncate(path_orig_len); 403 | } 404 | 405 | Ok((file, None)) 406 | } 407 | 408 | /// Produces the default file extension to MIME type mapping. 409 | pub fn default_content_type_map() -> BTreeMap { 410 | [ 411 | ("html", "text/html"), 412 | ("css", "text/css"), 413 | ("js", "text/javascript"), 414 | ("woff2", "font/woff2"), 415 | ("png", "image/png"), 416 | ("jpg", "image/jpeg"), 417 | ("gif", "image/gif"), 418 | ("xml", "application/xml"), 419 | ("wasm", "application/wasm"), 420 | ("bin", "application/octet-stream"), 421 | ("pdf", "application/pdf"), 422 | ].into_iter().map(|(k, v)| (k.to_string(), v)).collect() 423 | } 424 | 425 | /// Guesses the `Content-Type` of a file based on its path. 426 | /// 427 | /// Currently, this is hardcoded based on file extensions, like we're Windows. 428 | fn find_content_type(map: &BTreeMap, path: &Path) -> &'static str { 429 | path.extension().and_then(OsStr::to_str) 430 | .and_then(|ext| map.get(ext)) 431 | .map(|r| *r) 432 | .unwrap_or("text/plain") 433 | } 434 | 435 | /// Optionally suggests a cache TTL for a resource based on its extension. 436 | /// 437 | /// Currently hardcoded. 438 | fn map_cache_ttl(path: &Path) -> Option { 439 | match path.extension().and_then(OsStr::to_str) { 440 | Some("css") | Some("js") | Some("png") | Some("jpg") | Some("wasm") | Some("gif") => Some(86_400), 441 | Some("woff2") => Some(86_400 * 30), 442 | Some("pdf") => Some(86_400), 443 | Some("xml") => Some(86_400), 444 | _ => None, 445 | } 446 | } 447 | 448 | fn sanitize_path(path: &str) -> String { 449 | traversal::sanitize(percent::decode(path.chars())).collect() 450 | } 451 | 452 | /// Content-Encodings we support. The order of variants in this enum determines 453 | /// the order in which they're prioritized, from highest priority to lowest. 454 | #[derive(Copy, Clone, Debug, Enum)] 455 | enum Encoding { 456 | Brotli, 457 | Gzip, 458 | } 459 | 460 | impl Encoding { 461 | fn file_extension(&self) -> &'static str { 462 | match self { 463 | Self::Brotli => ".br", 464 | Self::Gzip => ".gz", 465 | } 466 | } 467 | 468 | /// Short names for the encodings as used in the Accept-Encodings headers. 469 | /// These are also logged. 470 | fn short_name(&self) -> &'static str { 471 | match self { 472 | Self::Brotli => "br", 473 | Self::Gzip => "gzip", 474 | } 475 | } 476 | } 477 | 478 | impl From for HeaderValue { 479 | fn from(e: Encoding) -> Self { 480 | HeaderValue::from_static(e.short_name()) 481 | } 482 | } 483 | 484 | fn serve_file( 485 | args: &CommonArgs, 486 | file: File, 487 | encoding: Option, 488 | if_modified_since: Option<&str>, 489 | if_none_match: Option<&str>, 490 | send_body: bool, 491 | ) -> (Response + Send>>>, Option) { 492 | // Go ahead and format the modification date as a string, since we'll need 493 | // it for the response headers and the if-modified-since check (where 494 | // relevant). We unfortunately need to format this two different ways thanks 495 | // to ETag's requirement for quotes. Since we trust the output to be ASCII, 496 | // we can avoid the extra allocation by slicing the quoted representation as 497 | // follows: 498 | let http_date = httpdate::HttpDate::from(file.modified); 499 | let etag = format!("\"{http_date}\""); 500 | let modified = &etag[1..etag.len() - 1]; 501 | 502 | // Check if-modified-since before handing off the modified string. 503 | let cached = if_modified_since == Some(modified) 504 | || if_none_match == Some(&*etag); 505 | 506 | // Construct the basic response. 507 | let mut response = 508 | start_response(args, file.len, file.content_type, modified, &*etag, file.ttl, encoding); 509 | 510 | // If a last-modified date was provided, and it matches, we want to 511 | // uniformly return a 304 without a body to both GET and HEAD requests. 512 | if cached || !send_body { 513 | if cached { 514 | *response.status_mut() = StatusCode::NOT_MODIFIED; 515 | } 516 | (response, None) 517 | } else { 518 | // !cached && send_body 519 | // A GET request without a matching last-modified. 520 | *response.body_mut() = Box::pin(StreamBody::new( 521 | codec::BytesCodec::new() 522 | .framed(file.file) 523 | .map(|b| b.map(bytes::BytesMut::freeze)) 524 | .map(|b| b.map(Frame::data)) 525 | .map(|r| r.map_err(ServeError::from)) 526 | )); 527 | ( 528 | response, 529 | Some(Served { 530 | len: file.len, 531 | encoding: match encoding { 532 | None => "raw", 533 | Some(e) => e.short_name(), 534 | }, 535 | }), 536 | ) 537 | } 538 | } 539 | 540 | #[cfg(test)] 541 | mod tests { 542 | use super::*; 543 | 544 | #[test] 545 | fn percent_and_sanitize() { 546 | assert_eq!(sanitize_path("%2f"), "./"); 547 | assert_eq!(sanitize_path("%2f%2F"), "./"); 548 | assert_eq!(sanitize_path("%2f%2e%2e"), "./:."); 549 | assert_eq!(sanitize_path("%2f%2e%2e%00"), "./:._"); 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /src/sync.rs: -------------------------------------------------------------------------------- 1 | //! Synchronization primitive add-ons. 2 | 3 | use std::sync::Arc; 4 | 5 | use tokio::sync::Semaphore; 6 | 7 | /// A counting semaphore that can be shared between tasks using reference 8 | /// counting. 9 | /// 10 | /// This is essentially equivalent to `tokio::sync::Semaphore`, but uses 11 | /// reference counting instead of borrowing for permits, so that a permit can be 12 | /// acquired in one task and transferred through `spawn`. 13 | #[derive(Clone)] 14 | pub struct SharedSemaphore { 15 | inner: Arc, 16 | } 17 | 18 | impl SharedSemaphore { 19 | /// Creates a semaphore initialized with the given number of permits. 20 | pub fn new(permits: usize) -> Self { 21 | Self { 22 | inner: Arc::new(Semaphore::new(permits)), 23 | } 24 | } 25 | 26 | /// Acquires one permit, resolving when it's acquired. 27 | pub async fn acquire(&self) -> SharedPermit { 28 | self.inner.acquire().await.unwrap().forget(); 29 | SharedPermit { 30 | inner: Arc::clone(&self.inner), 31 | } 32 | } 33 | } 34 | 35 | /// RAII representation of a single permit from a semaphore. 36 | pub struct SharedPermit { 37 | inner: Arc, 38 | } 39 | 40 | impl Drop for SharedPermit { 41 | fn drop(&mut self) { 42 | self.inner.add_permits(1); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/traversal.rs: -------------------------------------------------------------------------------- 1 | //! Filesystem traversal sanitizer. 2 | //! 3 | //! This module uses a derivative of the sanitization algorithm used by 4 | //! publicfile. A sanitized path... 5 | //! 6 | //! - Is relative (begins with `"./"`). 7 | //! - Contains no NUL characters, as this would confuse the operating system. 8 | //! - Contains no repeated slashes. 9 | //! - Contains no `"/."` sequences, preventing access to parent directories and 10 | //! dotfiles. 11 | //! 12 | //! The sanitizer API is an `Iterator`. Use `sanitize` to get one. 13 | //! 14 | //! Note that path sanitization should be applied *last*, after any other decode 15 | //! steps, immediately before passing the path to the OS. 16 | 17 | /// Adapts `inner` to sanitize path names. 18 | pub fn sanitize( 19 | inner: impl Iterator, 20 | ) -> impl Iterator { 21 | Sanitizer::from(inner) 22 | } 23 | 24 | struct Sanitizer { 25 | inner: I, 26 | state: SanitizerState, 27 | } 28 | 29 | impl From for Sanitizer { 30 | fn from(inner: I) -> Self { 31 | Self { 32 | inner, 33 | state: SanitizerState::EmitDot, 34 | } 35 | } 36 | } 37 | 38 | #[derive(Copy, Clone, Debug)] 39 | enum SanitizerState { 40 | EmitDot, 41 | EmitSlash, 42 | Normal, 43 | Slash, 44 | } 45 | 46 | impl> Iterator for Sanitizer { 47 | type Item = char; 48 | 49 | fn next(&mut self) -> Option { 50 | match self.state { 51 | SanitizerState::EmitDot => { 52 | self.state = SanitizerState::EmitSlash; 53 | return Some('.'); 54 | } 55 | SanitizerState::EmitSlash => { 56 | self.state = SanitizerState::Slash; 57 | return Some('/'); 58 | } 59 | _ => (), 60 | } 61 | 62 | loop { 63 | match (self.state, self.inner.next()?) { 64 | (_, '\0') => { 65 | self.state = SanitizerState::Normal; 66 | break Some('_'); 67 | } 68 | (SanitizerState::Normal, '/') => { 69 | self.state = SanitizerState::Slash; 70 | break Some('/'); 71 | } 72 | (SanitizerState::Slash, '/') => continue, 73 | (SanitizerState::Slash, '.') => { 74 | self.state = SanitizerState::Normal; 75 | break Some(':'); 76 | } 77 | (_, c) => { 78 | self.state = SanitizerState::Normal; 79 | break Some(c); 80 | } 81 | } 82 | } 83 | } 84 | 85 | fn size_hint(&self) -> (usize, Option) { 86 | // We alter the inner size-hint because it's possible that we discard 87 | // all characters. The max length is extended by the initial dot-slash. 88 | (0, self.inner.size_hint().1.map(|x| x + 2)) 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | fn san_str(s: &str) -> String { 95 | super::sanitize(s.chars()).collect() 96 | } 97 | 98 | #[test] 99 | fn sanitize() { 100 | assert_eq!(san_str(""), "./"); 101 | assert_eq!(san_str("///"), "./"); 102 | assert_eq!(san_str("."), "./:"); 103 | assert_eq!(san_str("/."), "./:"); 104 | assert_eq!(san_str(".."), "./:."); 105 | assert_eq!(san_str("\0"), "./_"); 106 | assert_eq!(san_str("/\0"), "./_"); 107 | 108 | assert_eq!(san_str("//.././doc.pdf\0/"), "./:./:/doc.pdf_/"); 109 | } 110 | } 111 | --------------------------------------------------------------------------------