├── .github ├── readme-screenshot.png └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── app ├── CHANGELOG.md ├── Cargo.toml └── src │ ├── args.rs │ ├── main.rs │ └── server.rs └── lib ├── CHANGELOG.md ├── Cargo.toml ├── build.rs ├── examples └── main.rs ├── package.json ├── src ├── assets │ ├── dir-listing.html │ ├── not-found.html │ └── proxy-error.html ├── browser.ts ├── config.rs ├── generated │ └── browser.js ├── inject.rs ├── lib.rs ├── serve │ ├── fs.rs │ ├── mod.rs │ └── proxy.rs ├── tests.rs ├── util.rs └── ws.rs └── tsconfig.json /.github/readme-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukasKalbertodt/penguin/aa8a4b9b5eb2c58b186d0fee6d1cc0f37173b80b/.github/readme-screenshot.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | RUSTFLAGS: --deny warnings 11 | 12 | jobs: 13 | check: 14 | name: 'Build & test' 15 | runs-on: ubuntu-20.04 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Restore cargo cache 19 | uses: Swatinem/rust-cache@v1.3.0 20 | - name: Install tsc 21 | run: npm install 22 | working-directory: lib 23 | - name: Compile TS file 24 | run: npx tsc --outFile src/generated/check.js 25 | working-directory: lib 26 | - name: Make sure generated JS file is up to date 27 | run: diff -u --color lib/src/generated/browser.js lib/src/generated/check.js 28 | - name: Build 29 | run: cargo build 30 | - name: Run tests 31 | run: cargo test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /lib/node_modules 3 | /lib/package-lock.json 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | - [Changelog for the library](./lib/CHANGELOG.md) 4 | - [Changelog for the CLI app](./app/CHANGELOG.md) 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "adler" 7 | version = "1.0.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 10 | 11 | [[package]] 12 | name = "aho-corasick" 13 | version = "0.7.18" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 16 | dependencies = [ 17 | "memchr", 18 | ] 19 | 20 | [[package]] 21 | name = "alloc-no-stdlib" 22 | version = "2.0.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "35ef4730490ad1c4eae5c4325b2a95f521d023e5c885853ff7aca0a6a1631db3" 25 | 26 | [[package]] 27 | name = "alloc-stdlib" 28 | version = "0.2.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "697ed7edc0f1711de49ce108c541623a0af97c6c60b2f6e2b65229847ac843c2" 31 | dependencies = [ 32 | "alloc-no-stdlib", 33 | ] 34 | 35 | [[package]] 36 | name = "ansi_term" 37 | version = "0.12.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 40 | dependencies = [ 41 | "winapi 0.3.9", 42 | ] 43 | 44 | [[package]] 45 | name = "anyhow" 46 | version = "1.0.57" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" 49 | 50 | [[package]] 51 | name = "atty" 52 | version = "0.2.14" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 55 | dependencies = [ 56 | "hermit-abi", 57 | "libc", 58 | "winapi 0.3.9", 59 | ] 60 | 61 | [[package]] 62 | name = "autocfg" 63 | version = "1.1.0" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 66 | 67 | [[package]] 68 | name = "base64" 69 | version = "0.13.0" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 72 | 73 | [[package]] 74 | name = "bitflags" 75 | version = "1.3.2" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 78 | 79 | [[package]] 80 | name = "block-buffer" 81 | version = "0.10.2" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" 84 | dependencies = [ 85 | "generic-array", 86 | ] 87 | 88 | [[package]] 89 | name = "brotli" 90 | version = "3.3.4" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" 93 | dependencies = [ 94 | "alloc-no-stdlib", 95 | "alloc-stdlib", 96 | "brotli-decompressor", 97 | ] 98 | 99 | [[package]] 100 | name = "brotli-decompressor" 101 | version = "2.3.2" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "59ad2d4653bf5ca36ae797b1f4bb4dbddb60ce49ca4aed8a2ce4829f60425b80" 104 | dependencies = [ 105 | "alloc-no-stdlib", 106 | "alloc-stdlib", 107 | ] 108 | 109 | [[package]] 110 | name = "bunt" 111 | version = "0.2.6" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "192bac6c13e04373feb683e4438cbc156e6bbe432f614a9d6247445108fc5551" 114 | dependencies = [ 115 | "bunt-macros", 116 | "termcolor", 117 | ] 118 | 119 | [[package]] 120 | name = "bunt-macros" 121 | version = "0.2.5" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "181ae31bbb8b46f840a70dc1323ad938f2cd7a504e7adc98d6fe58094900a680" 124 | dependencies = [ 125 | "litrs", 126 | "proc-macro2", 127 | "quote", 128 | "unicode-xid", 129 | ] 130 | 131 | [[package]] 132 | name = "byteorder" 133 | version = "1.4.3" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 136 | 137 | [[package]] 138 | name = "bytes" 139 | version = "1.1.0" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" 142 | 143 | [[package]] 144 | name = "cc" 145 | version = "1.0.73" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" 148 | 149 | [[package]] 150 | name = "cfb" 151 | version = "0.7.3" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" 154 | dependencies = [ 155 | "byteorder", 156 | "fnv", 157 | "uuid", 158 | ] 159 | 160 | [[package]] 161 | name = "cfg-if" 162 | version = "0.1.10" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 165 | 166 | [[package]] 167 | name = "cfg-if" 168 | version = "1.0.0" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 171 | 172 | [[package]] 173 | name = "clap" 174 | version = "2.34.0" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" 177 | dependencies = [ 178 | "ansi_term", 179 | "atty", 180 | "bitflags", 181 | "strsim", 182 | "textwrap", 183 | "unicode-width", 184 | "vec_map", 185 | ] 186 | 187 | [[package]] 188 | name = "core-foundation" 189 | version = "0.9.3" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" 192 | dependencies = [ 193 | "core-foundation-sys", 194 | "libc", 195 | ] 196 | 197 | [[package]] 198 | name = "core-foundation-sys" 199 | version = "0.8.3" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" 202 | 203 | [[package]] 204 | name = "cpufeatures" 205 | version = "0.2.2" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" 208 | dependencies = [ 209 | "libc", 210 | ] 211 | 212 | [[package]] 213 | name = "crc32fast" 214 | version = "1.3.2" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" 217 | dependencies = [ 218 | "cfg-if 1.0.0", 219 | ] 220 | 221 | [[package]] 222 | name = "crypto-common" 223 | version = "0.1.3" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" 226 | dependencies = [ 227 | "generic-array", 228 | "typenum", 229 | ] 230 | 231 | [[package]] 232 | name = "digest" 233 | version = "0.10.3" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" 236 | dependencies = [ 237 | "block-buffer", 238 | "crypto-common", 239 | ] 240 | 241 | [[package]] 242 | name = "env_logger" 243 | version = "0.7.1" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" 246 | dependencies = [ 247 | "atty", 248 | "humantime", 249 | "log", 250 | "regex", 251 | "termcolor", 252 | ] 253 | 254 | [[package]] 255 | name = "fastrand" 256 | version = "1.7.0" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" 259 | dependencies = [ 260 | "instant", 261 | ] 262 | 263 | [[package]] 264 | name = "filetime" 265 | version = "0.2.16" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "c0408e2626025178a6a7f7ffc05a25bc47103229f19c113755de7bf63816290c" 268 | dependencies = [ 269 | "cfg-if 1.0.0", 270 | "libc", 271 | "redox_syscall", 272 | "winapi 0.3.9", 273 | ] 274 | 275 | [[package]] 276 | name = "flate2" 277 | version = "1.0.24" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" 280 | dependencies = [ 281 | "crc32fast", 282 | "miniz_oxide", 283 | ] 284 | 285 | [[package]] 286 | name = "fnv" 287 | version = "1.0.7" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 290 | 291 | [[package]] 292 | name = "foreign-types" 293 | version = "0.3.2" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 296 | dependencies = [ 297 | "foreign-types-shared", 298 | ] 299 | 300 | [[package]] 301 | name = "foreign-types-shared" 302 | version = "0.1.1" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 305 | 306 | [[package]] 307 | name = "form_urlencoded" 308 | version = "1.0.1" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" 311 | dependencies = [ 312 | "matches", 313 | "percent-encoding", 314 | ] 315 | 316 | [[package]] 317 | name = "fsevent" 318 | version = "0.4.0" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" 321 | dependencies = [ 322 | "bitflags", 323 | "fsevent-sys", 324 | ] 325 | 326 | [[package]] 327 | name = "fsevent-sys" 328 | version = "2.0.1" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0" 331 | dependencies = [ 332 | "libc", 333 | ] 334 | 335 | [[package]] 336 | name = "fuchsia-zircon" 337 | version = "0.3.3" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" 340 | dependencies = [ 341 | "bitflags", 342 | "fuchsia-zircon-sys", 343 | ] 344 | 345 | [[package]] 346 | name = "fuchsia-zircon-sys" 347 | version = "0.3.3" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" 350 | 351 | [[package]] 352 | name = "futures" 353 | version = "0.3.21" 354 | source = "registry+https://github.com/rust-lang/crates.io-index" 355 | checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" 356 | dependencies = [ 357 | "futures-channel", 358 | "futures-core", 359 | "futures-executor", 360 | "futures-io", 361 | "futures-sink", 362 | "futures-task", 363 | "futures-util", 364 | ] 365 | 366 | [[package]] 367 | name = "futures-channel" 368 | version = "0.3.21" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" 371 | dependencies = [ 372 | "futures-core", 373 | "futures-sink", 374 | ] 375 | 376 | [[package]] 377 | name = "futures-core" 378 | version = "0.3.21" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" 381 | 382 | [[package]] 383 | name = "futures-executor" 384 | version = "0.3.21" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" 387 | dependencies = [ 388 | "futures-core", 389 | "futures-task", 390 | "futures-util", 391 | ] 392 | 393 | [[package]] 394 | name = "futures-io" 395 | version = "0.3.21" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" 398 | 399 | [[package]] 400 | name = "futures-macro" 401 | version = "0.3.21" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" 404 | dependencies = [ 405 | "proc-macro2", 406 | "quote", 407 | "syn", 408 | ] 409 | 410 | [[package]] 411 | name = "futures-sink" 412 | version = "0.3.21" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" 415 | 416 | [[package]] 417 | name = "futures-task" 418 | version = "0.3.21" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" 421 | 422 | [[package]] 423 | name = "futures-util" 424 | version = "0.3.21" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" 427 | dependencies = [ 428 | "futures-channel", 429 | "futures-core", 430 | "futures-io", 431 | "futures-macro", 432 | "futures-sink", 433 | "futures-task", 434 | "memchr", 435 | "pin-project-lite", 436 | "pin-utils", 437 | "slab", 438 | ] 439 | 440 | [[package]] 441 | name = "generic-array" 442 | version = "0.14.5" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" 445 | dependencies = [ 446 | "typenum", 447 | "version_check", 448 | ] 449 | 450 | [[package]] 451 | name = "getrandom" 452 | version = "0.2.6" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" 455 | dependencies = [ 456 | "cfg-if 1.0.0", 457 | "libc", 458 | "wasi 0.10.2+wasi-snapshot-preview1", 459 | ] 460 | 461 | [[package]] 462 | name = "h2" 463 | version = "0.3.13" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57" 466 | dependencies = [ 467 | "bytes", 468 | "fnv", 469 | "futures-core", 470 | "futures-sink", 471 | "futures-util", 472 | "http", 473 | "indexmap", 474 | "slab", 475 | "tokio", 476 | "tokio-util", 477 | "tracing", 478 | ] 479 | 480 | [[package]] 481 | name = "hashbrown" 482 | version = "0.11.2" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 485 | 486 | [[package]] 487 | name = "heck" 488 | version = "0.3.3" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 491 | dependencies = [ 492 | "unicode-segmentation", 493 | ] 494 | 495 | [[package]] 496 | name = "hermit-abi" 497 | version = "0.1.19" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 500 | dependencies = [ 501 | "libc", 502 | ] 503 | 504 | [[package]] 505 | name = "http" 506 | version = "0.2.8" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" 509 | dependencies = [ 510 | "bytes", 511 | "fnv", 512 | "itoa", 513 | ] 514 | 515 | [[package]] 516 | name = "http-body" 517 | version = "0.4.5" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" 520 | dependencies = [ 521 | "bytes", 522 | "http", 523 | "pin-project-lite", 524 | ] 525 | 526 | [[package]] 527 | name = "http-range" 528 | version = "0.1.5" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 531 | 532 | [[package]] 533 | name = "httparse" 534 | version = "1.7.1" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" 537 | 538 | [[package]] 539 | name = "httpdate" 540 | version = "1.0.2" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" 543 | 544 | [[package]] 545 | name = "humantime" 546 | version = "1.3.0" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" 549 | dependencies = [ 550 | "quick-error", 551 | ] 552 | 553 | [[package]] 554 | name = "hyper" 555 | version = "0.14.19" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "42dc3c131584288d375f2d07f822b0cb012d8c6fb899a5b9fdb3cb7eb9b6004f" 558 | dependencies = [ 559 | "bytes", 560 | "futures-channel", 561 | "futures-core", 562 | "futures-util", 563 | "h2", 564 | "http", 565 | "http-body", 566 | "httparse", 567 | "httpdate", 568 | "itoa", 569 | "pin-project-lite", 570 | "socket2", 571 | "tokio", 572 | "tower-service", 573 | "tracing", 574 | "want", 575 | ] 576 | 577 | [[package]] 578 | name = "hyper-tls" 579 | version = "0.5.0" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" 582 | dependencies = [ 583 | "bytes", 584 | "hyper", 585 | "native-tls", 586 | "tokio", 587 | "tokio-native-tls", 588 | ] 589 | 590 | [[package]] 591 | name = "hyper-tungstenite" 592 | version = "0.8.0" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "b0ea2c1b59596d6b1302fe616266257a58b079f68fee329d6d111f79241cb7fd" 595 | dependencies = [ 596 | "hyper", 597 | "pin-project", 598 | "tokio", 599 | "tokio-tungstenite", 600 | "tungstenite", 601 | ] 602 | 603 | [[package]] 604 | name = "idna" 605 | version = "0.2.3" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" 608 | dependencies = [ 609 | "matches", 610 | "unicode-bidi", 611 | "unicode-normalization", 612 | ] 613 | 614 | [[package]] 615 | name = "indexmap" 616 | version = "1.8.2" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" 619 | dependencies = [ 620 | "autocfg", 621 | "hashbrown", 622 | ] 623 | 624 | [[package]] 625 | name = "infer" 626 | version = "0.15.0" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199" 629 | dependencies = [ 630 | "cfb", 631 | ] 632 | 633 | [[package]] 634 | name = "inotify" 635 | version = "0.7.1" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "4816c66d2c8ae673df83366c18341538f234a26d65a9ecea5c348b453ac1d02f" 638 | dependencies = [ 639 | "bitflags", 640 | "inotify-sys", 641 | "libc", 642 | ] 643 | 644 | [[package]] 645 | name = "inotify-sys" 646 | version = "0.1.5" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" 649 | dependencies = [ 650 | "libc", 651 | ] 652 | 653 | [[package]] 654 | name = "instant" 655 | version = "0.1.12" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 658 | dependencies = [ 659 | "cfg-if 1.0.0", 660 | ] 661 | 662 | [[package]] 663 | name = "iovec" 664 | version = "0.1.4" 665 | source = "registry+https://github.com/rust-lang/crates.io-index" 666 | checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" 667 | dependencies = [ 668 | "libc", 669 | ] 670 | 671 | [[package]] 672 | name = "itoa" 673 | version = "1.0.2" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" 676 | 677 | [[package]] 678 | name = "kernel32-sys" 679 | version = "0.2.2" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" 682 | dependencies = [ 683 | "winapi 0.2.8", 684 | "winapi-build", 685 | ] 686 | 687 | [[package]] 688 | name = "lazy_static" 689 | version = "1.4.0" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 692 | 693 | [[package]] 694 | name = "lazycell" 695 | version = "1.3.0" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" 698 | 699 | [[package]] 700 | name = "libc" 701 | version = "0.2.126" 702 | source = "registry+https://github.com/rust-lang/crates.io-index" 703 | checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" 704 | 705 | [[package]] 706 | name = "litrs" 707 | version = "0.2.3" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "f9275e0933cf8bb20f008924c0cb07a0692fe54d8064996520bf998de9eb79aa" 710 | dependencies = [ 711 | "proc-macro2", 712 | ] 713 | 714 | [[package]] 715 | name = "log" 716 | version = "0.4.17" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 719 | dependencies = [ 720 | "cfg-if 1.0.0", 721 | ] 722 | 723 | [[package]] 724 | name = "matches" 725 | version = "0.1.9" 726 | source = "registry+https://github.com/rust-lang/crates.io-index" 727 | checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" 728 | 729 | [[package]] 730 | name = "memchr" 731 | version = "2.5.0" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 734 | 735 | [[package]] 736 | name = "mime" 737 | version = "0.3.16" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" 740 | 741 | [[package]] 742 | name = "mime_guess" 743 | version = "2.0.4" 744 | source = "registry+https://github.com/rust-lang/crates.io-index" 745 | checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" 746 | dependencies = [ 747 | "mime", 748 | "unicase", 749 | ] 750 | 751 | [[package]] 752 | name = "miniz_oxide" 753 | version = "0.5.3" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" 756 | dependencies = [ 757 | "adler", 758 | ] 759 | 760 | [[package]] 761 | name = "mio" 762 | version = "0.6.23" 763 | source = "registry+https://github.com/rust-lang/crates.io-index" 764 | checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" 765 | dependencies = [ 766 | "cfg-if 0.1.10", 767 | "fuchsia-zircon", 768 | "fuchsia-zircon-sys", 769 | "iovec", 770 | "kernel32-sys", 771 | "libc", 772 | "log", 773 | "miow", 774 | "net2", 775 | "slab", 776 | "winapi 0.2.8", 777 | ] 778 | 779 | [[package]] 780 | name = "mio" 781 | version = "0.8.3" 782 | source = "registry+https://github.com/rust-lang/crates.io-index" 783 | checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799" 784 | dependencies = [ 785 | "libc", 786 | "log", 787 | "wasi 0.11.0+wasi-snapshot-preview1", 788 | "windows-sys", 789 | ] 790 | 791 | [[package]] 792 | name = "mio-extras" 793 | version = "2.0.6" 794 | source = "registry+https://github.com/rust-lang/crates.io-index" 795 | checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" 796 | dependencies = [ 797 | "lazycell", 798 | "log", 799 | "mio 0.6.23", 800 | "slab", 801 | ] 802 | 803 | [[package]] 804 | name = "miow" 805 | version = "0.2.2" 806 | source = "registry+https://github.com/rust-lang/crates.io-index" 807 | checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" 808 | dependencies = [ 809 | "kernel32-sys", 810 | "net2", 811 | "winapi 0.2.8", 812 | "ws2_32-sys", 813 | ] 814 | 815 | [[package]] 816 | name = "native-tls" 817 | version = "0.2.10" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" 820 | dependencies = [ 821 | "lazy_static", 822 | "libc", 823 | "log", 824 | "openssl", 825 | "openssl-probe", 826 | "openssl-sys", 827 | "schannel", 828 | "security-framework", 829 | "security-framework-sys", 830 | "tempfile", 831 | ] 832 | 833 | [[package]] 834 | name = "net2" 835 | version = "0.2.37" 836 | source = "registry+https://github.com/rust-lang/crates.io-index" 837 | checksum = "391630d12b68002ae1e25e8f974306474966550ad82dac6886fb8910c19568ae" 838 | dependencies = [ 839 | "cfg-if 0.1.10", 840 | "libc", 841 | "winapi 0.3.9", 842 | ] 843 | 844 | [[package]] 845 | name = "notify" 846 | version = "4.0.17" 847 | source = "registry+https://github.com/rust-lang/crates.io-index" 848 | checksum = "ae03c8c853dba7bfd23e571ff0cff7bc9dceb40a4cd684cd1681824183f45257" 849 | dependencies = [ 850 | "bitflags", 851 | "filetime", 852 | "fsevent", 853 | "fsevent-sys", 854 | "inotify", 855 | "libc", 856 | "mio 0.6.23", 857 | "mio-extras", 858 | "walkdir", 859 | "winapi 0.3.9", 860 | ] 861 | 862 | [[package]] 863 | name = "num_cpus" 864 | version = "1.13.1" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" 867 | dependencies = [ 868 | "hermit-abi", 869 | "libc", 870 | ] 871 | 872 | [[package]] 873 | name = "once_cell" 874 | version = "1.12.0" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225" 877 | 878 | [[package]] 879 | name = "open" 880 | version = "2.1.3" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "f2423ffbf445b82e58c3b1543655968923dd06f85432f10be2bb4f1b7122f98c" 883 | dependencies = [ 884 | "pathdiff", 885 | "windows-sys", 886 | ] 887 | 888 | [[package]] 889 | name = "openssl" 890 | version = "0.10.40" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "fb81a6430ac911acb25fe5ac8f1d2af1b4ea8a4fdfda0f1ee4292af2e2d8eb0e" 893 | dependencies = [ 894 | "bitflags", 895 | "cfg-if 1.0.0", 896 | "foreign-types", 897 | "libc", 898 | "once_cell", 899 | "openssl-macros", 900 | "openssl-sys", 901 | ] 902 | 903 | [[package]] 904 | name = "openssl-macros" 905 | version = "0.1.0" 906 | source = "registry+https://github.com/rust-lang/crates.io-index" 907 | checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" 908 | dependencies = [ 909 | "proc-macro2", 910 | "quote", 911 | "syn", 912 | ] 913 | 914 | [[package]] 915 | name = "openssl-probe" 916 | version = "0.1.5" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" 919 | 920 | [[package]] 921 | name = "openssl-src" 922 | version = "111.20.0+1.1.1o" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "92892c4f87d56e376e469ace79f1128fdaded07646ddf73aa0be4706ff712dec" 925 | dependencies = [ 926 | "cc", 927 | ] 928 | 929 | [[package]] 930 | name = "openssl-sys" 931 | version = "0.9.74" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "835363342df5fba8354c5b453325b110ffd54044e588c539cf2f20a8014e4cb1" 934 | dependencies = [ 935 | "autocfg", 936 | "cc", 937 | "libc", 938 | "openssl-src", 939 | "pkg-config", 940 | "vcpkg", 941 | ] 942 | 943 | [[package]] 944 | name = "pathdiff" 945 | version = "0.2.1" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" 948 | 949 | [[package]] 950 | name = "penguin" 951 | version = "0.1.8" 952 | dependencies = [ 953 | "brotli", 954 | "flate2", 955 | "futures", 956 | "http-range", 957 | "hyper", 958 | "hyper-tls", 959 | "hyper-tungstenite", 960 | "infer", 961 | "log", 962 | "mime_guess", 963 | "thiserror", 964 | "tokio", 965 | "tokio-util", 966 | ] 967 | 968 | [[package]] 969 | name = "penguin-app" 970 | version = "0.2.6" 971 | dependencies = [ 972 | "anyhow", 973 | "bunt", 974 | "log", 975 | "notify", 976 | "open", 977 | "penguin", 978 | "pretty_env_logger", 979 | "structopt", 980 | "tokio", 981 | ] 982 | 983 | [[package]] 984 | name = "percent-encoding" 985 | version = "2.1.0" 986 | source = "registry+https://github.com/rust-lang/crates.io-index" 987 | checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" 988 | 989 | [[package]] 990 | name = "pin-project" 991 | version = "1.0.10" 992 | source = "registry+https://github.com/rust-lang/crates.io-index" 993 | checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e" 994 | dependencies = [ 995 | "pin-project-internal", 996 | ] 997 | 998 | [[package]] 999 | name = "pin-project-internal" 1000 | version = "1.0.10" 1001 | source = "registry+https://github.com/rust-lang/crates.io-index" 1002 | checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb" 1003 | dependencies = [ 1004 | "proc-macro2", 1005 | "quote", 1006 | "syn", 1007 | ] 1008 | 1009 | [[package]] 1010 | name = "pin-project-lite" 1011 | version = "0.2.9" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" 1014 | 1015 | [[package]] 1016 | name = "pin-utils" 1017 | version = "0.1.0" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1020 | 1021 | [[package]] 1022 | name = "pkg-config" 1023 | version = "0.3.25" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" 1026 | 1027 | [[package]] 1028 | name = "ppv-lite86" 1029 | version = "0.2.16" 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" 1031 | checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" 1032 | 1033 | [[package]] 1034 | name = "pretty_env_logger" 1035 | version = "0.4.0" 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" 1037 | checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" 1038 | dependencies = [ 1039 | "env_logger", 1040 | "log", 1041 | ] 1042 | 1043 | [[package]] 1044 | name = "proc-macro-error" 1045 | version = "1.0.4" 1046 | source = "registry+https://github.com/rust-lang/crates.io-index" 1047 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 1048 | dependencies = [ 1049 | "proc-macro-error-attr", 1050 | "proc-macro2", 1051 | "quote", 1052 | "syn", 1053 | "version_check", 1054 | ] 1055 | 1056 | [[package]] 1057 | name = "proc-macro-error-attr" 1058 | version = "1.0.4" 1059 | source = "registry+https://github.com/rust-lang/crates.io-index" 1060 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 1061 | dependencies = [ 1062 | "proc-macro2", 1063 | "quote", 1064 | "version_check", 1065 | ] 1066 | 1067 | [[package]] 1068 | name = "proc-macro2" 1069 | version = "1.0.39" 1070 | source = "registry+https://github.com/rust-lang/crates.io-index" 1071 | checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" 1072 | dependencies = [ 1073 | "unicode-ident", 1074 | ] 1075 | 1076 | [[package]] 1077 | name = "quick-error" 1078 | version = "1.2.3" 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" 1080 | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 1081 | 1082 | [[package]] 1083 | name = "quote" 1084 | version = "1.0.18" 1085 | source = "registry+https://github.com/rust-lang/crates.io-index" 1086 | checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" 1087 | dependencies = [ 1088 | "proc-macro2", 1089 | ] 1090 | 1091 | [[package]] 1092 | name = "rand" 1093 | version = "0.8.5" 1094 | source = "registry+https://github.com/rust-lang/crates.io-index" 1095 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1096 | dependencies = [ 1097 | "libc", 1098 | "rand_chacha", 1099 | "rand_core", 1100 | ] 1101 | 1102 | [[package]] 1103 | name = "rand_chacha" 1104 | version = "0.3.1" 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" 1106 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1107 | dependencies = [ 1108 | "ppv-lite86", 1109 | "rand_core", 1110 | ] 1111 | 1112 | [[package]] 1113 | name = "rand_core" 1114 | version = "0.6.3" 1115 | source = "registry+https://github.com/rust-lang/crates.io-index" 1116 | checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" 1117 | dependencies = [ 1118 | "getrandom", 1119 | ] 1120 | 1121 | [[package]] 1122 | name = "redox_syscall" 1123 | version = "0.2.13" 1124 | source = "registry+https://github.com/rust-lang/crates.io-index" 1125 | checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" 1126 | dependencies = [ 1127 | "bitflags", 1128 | ] 1129 | 1130 | [[package]] 1131 | name = "regex" 1132 | version = "1.5.6" 1133 | source = "registry+https://github.com/rust-lang/crates.io-index" 1134 | checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" 1135 | dependencies = [ 1136 | "aho-corasick", 1137 | "memchr", 1138 | "regex-syntax", 1139 | ] 1140 | 1141 | [[package]] 1142 | name = "regex-syntax" 1143 | version = "0.6.26" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" 1146 | 1147 | [[package]] 1148 | name = "remove_dir_all" 1149 | version = "0.5.3" 1150 | source = "registry+https://github.com/rust-lang/crates.io-index" 1151 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 1152 | dependencies = [ 1153 | "winapi 0.3.9", 1154 | ] 1155 | 1156 | [[package]] 1157 | name = "same-file" 1158 | version = "1.0.6" 1159 | source = "registry+https://github.com/rust-lang/crates.io-index" 1160 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1161 | dependencies = [ 1162 | "winapi-util", 1163 | ] 1164 | 1165 | [[package]] 1166 | name = "schannel" 1167 | version = "0.1.20" 1168 | source = "registry+https://github.com/rust-lang/crates.io-index" 1169 | checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" 1170 | dependencies = [ 1171 | "lazy_static", 1172 | "windows-sys", 1173 | ] 1174 | 1175 | [[package]] 1176 | name = "security-framework" 1177 | version = "2.6.1" 1178 | source = "registry+https://github.com/rust-lang/crates.io-index" 1179 | checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" 1180 | dependencies = [ 1181 | "bitflags", 1182 | "core-foundation", 1183 | "core-foundation-sys", 1184 | "libc", 1185 | "security-framework-sys", 1186 | ] 1187 | 1188 | [[package]] 1189 | name = "security-framework-sys" 1190 | version = "2.6.1" 1191 | source = "registry+https://github.com/rust-lang/crates.io-index" 1192 | checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" 1193 | dependencies = [ 1194 | "core-foundation-sys", 1195 | "libc", 1196 | ] 1197 | 1198 | [[package]] 1199 | name = "sha-1" 1200 | version = "0.10.0" 1201 | source = "registry+https://github.com/rust-lang/crates.io-index" 1202 | checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" 1203 | dependencies = [ 1204 | "cfg-if 1.0.0", 1205 | "cpufeatures", 1206 | "digest", 1207 | ] 1208 | 1209 | [[package]] 1210 | name = "slab" 1211 | version = "0.4.6" 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" 1213 | checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" 1214 | 1215 | [[package]] 1216 | name = "socket2" 1217 | version = "0.4.4" 1218 | source = "registry+https://github.com/rust-lang/crates.io-index" 1219 | checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" 1220 | dependencies = [ 1221 | "libc", 1222 | "winapi 0.3.9", 1223 | ] 1224 | 1225 | [[package]] 1226 | name = "strsim" 1227 | version = "0.8.0" 1228 | source = "registry+https://github.com/rust-lang/crates.io-index" 1229 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 1230 | 1231 | [[package]] 1232 | name = "structopt" 1233 | version = "0.3.26" 1234 | source = "registry+https://github.com/rust-lang/crates.io-index" 1235 | checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" 1236 | dependencies = [ 1237 | "clap", 1238 | "lazy_static", 1239 | "structopt-derive", 1240 | ] 1241 | 1242 | [[package]] 1243 | name = "structopt-derive" 1244 | version = "0.4.18" 1245 | source = "registry+https://github.com/rust-lang/crates.io-index" 1246 | checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" 1247 | dependencies = [ 1248 | "heck", 1249 | "proc-macro-error", 1250 | "proc-macro2", 1251 | "quote", 1252 | "syn", 1253 | ] 1254 | 1255 | [[package]] 1256 | name = "syn" 1257 | version = "1.0.96" 1258 | source = "registry+https://github.com/rust-lang/crates.io-index" 1259 | checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" 1260 | dependencies = [ 1261 | "proc-macro2", 1262 | "quote", 1263 | "unicode-ident", 1264 | ] 1265 | 1266 | [[package]] 1267 | name = "tempfile" 1268 | version = "3.3.0" 1269 | source = "registry+https://github.com/rust-lang/crates.io-index" 1270 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" 1271 | dependencies = [ 1272 | "cfg-if 1.0.0", 1273 | "fastrand", 1274 | "libc", 1275 | "redox_syscall", 1276 | "remove_dir_all", 1277 | "winapi 0.3.9", 1278 | ] 1279 | 1280 | [[package]] 1281 | name = "termcolor" 1282 | version = "1.1.3" 1283 | source = "registry+https://github.com/rust-lang/crates.io-index" 1284 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 1285 | dependencies = [ 1286 | "winapi-util", 1287 | ] 1288 | 1289 | [[package]] 1290 | name = "textwrap" 1291 | version = "0.11.0" 1292 | source = "registry+https://github.com/rust-lang/crates.io-index" 1293 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 1294 | dependencies = [ 1295 | "unicode-width", 1296 | ] 1297 | 1298 | [[package]] 1299 | name = "thiserror" 1300 | version = "1.0.31" 1301 | source = "registry+https://github.com/rust-lang/crates.io-index" 1302 | checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" 1303 | dependencies = [ 1304 | "thiserror-impl", 1305 | ] 1306 | 1307 | [[package]] 1308 | name = "thiserror-impl" 1309 | version = "1.0.31" 1310 | source = "registry+https://github.com/rust-lang/crates.io-index" 1311 | checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" 1312 | dependencies = [ 1313 | "proc-macro2", 1314 | "quote", 1315 | "syn", 1316 | ] 1317 | 1318 | [[package]] 1319 | name = "tinyvec" 1320 | version = "1.6.0" 1321 | source = "registry+https://github.com/rust-lang/crates.io-index" 1322 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 1323 | dependencies = [ 1324 | "tinyvec_macros", 1325 | ] 1326 | 1327 | [[package]] 1328 | name = "tinyvec_macros" 1329 | version = "0.1.0" 1330 | source = "registry+https://github.com/rust-lang/crates.io-index" 1331 | checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" 1332 | 1333 | [[package]] 1334 | name = "tokio" 1335 | version = "1.19.2" 1336 | source = "registry+https://github.com/rust-lang/crates.io-index" 1337 | checksum = "c51a52ed6686dd62c320f9b89299e9dfb46f730c7a48e635c19f21d116cb1439" 1338 | dependencies = [ 1339 | "bytes", 1340 | "libc", 1341 | "memchr", 1342 | "mio 0.8.3", 1343 | "num_cpus", 1344 | "once_cell", 1345 | "pin-project-lite", 1346 | "socket2", 1347 | "tokio-macros", 1348 | "winapi 0.3.9", 1349 | ] 1350 | 1351 | [[package]] 1352 | name = "tokio-macros" 1353 | version = "1.8.0" 1354 | source = "registry+https://github.com/rust-lang/crates.io-index" 1355 | checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" 1356 | dependencies = [ 1357 | "proc-macro2", 1358 | "quote", 1359 | "syn", 1360 | ] 1361 | 1362 | [[package]] 1363 | name = "tokio-native-tls" 1364 | version = "0.3.0" 1365 | source = "registry+https://github.com/rust-lang/crates.io-index" 1366 | checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" 1367 | dependencies = [ 1368 | "native-tls", 1369 | "tokio", 1370 | ] 1371 | 1372 | [[package]] 1373 | name = "tokio-tungstenite" 1374 | version = "0.17.1" 1375 | source = "registry+https://github.com/rust-lang/crates.io-index" 1376 | checksum = "06cda1232a49558c46f8a504d5b93101d42c0bf7f911f12a105ba48168f821ae" 1377 | dependencies = [ 1378 | "futures-util", 1379 | "log", 1380 | "tokio", 1381 | "tungstenite", 1382 | ] 1383 | 1384 | [[package]] 1385 | name = "tokio-util" 1386 | version = "0.7.3" 1387 | source = "registry+https://github.com/rust-lang/crates.io-index" 1388 | checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" 1389 | dependencies = [ 1390 | "bytes", 1391 | "futures-core", 1392 | "futures-sink", 1393 | "pin-project-lite", 1394 | "tokio", 1395 | "tracing", 1396 | ] 1397 | 1398 | [[package]] 1399 | name = "tower-service" 1400 | version = "0.3.1" 1401 | source = "registry+https://github.com/rust-lang/crates.io-index" 1402 | checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" 1403 | 1404 | [[package]] 1405 | name = "tracing" 1406 | version = "0.1.34" 1407 | source = "registry+https://github.com/rust-lang/crates.io-index" 1408 | checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" 1409 | dependencies = [ 1410 | "cfg-if 1.0.0", 1411 | "pin-project-lite", 1412 | "tracing-core", 1413 | ] 1414 | 1415 | [[package]] 1416 | name = "tracing-core" 1417 | version = "0.1.27" 1418 | source = "registry+https://github.com/rust-lang/crates.io-index" 1419 | checksum = "7709595b8878a4965ce5e87ebf880a7d39c9afc6837721b21a5a816a8117d921" 1420 | dependencies = [ 1421 | "once_cell", 1422 | ] 1423 | 1424 | [[package]] 1425 | name = "try-lock" 1426 | version = "0.2.3" 1427 | source = "registry+https://github.com/rust-lang/crates.io-index" 1428 | checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" 1429 | 1430 | [[package]] 1431 | name = "tungstenite" 1432 | version = "0.17.2" 1433 | source = "registry+https://github.com/rust-lang/crates.io-index" 1434 | checksum = "d96a2dea40e7570482f28eb57afbe42d97551905da6a9400acc5c328d24004f5" 1435 | dependencies = [ 1436 | "base64", 1437 | "byteorder", 1438 | "bytes", 1439 | "http", 1440 | "httparse", 1441 | "log", 1442 | "rand", 1443 | "sha-1", 1444 | "thiserror", 1445 | "url", 1446 | "utf-8", 1447 | ] 1448 | 1449 | [[package]] 1450 | name = "typenum" 1451 | version = "1.15.0" 1452 | source = "registry+https://github.com/rust-lang/crates.io-index" 1453 | checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" 1454 | 1455 | [[package]] 1456 | name = "unicase" 1457 | version = "2.6.0" 1458 | source = "registry+https://github.com/rust-lang/crates.io-index" 1459 | checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" 1460 | dependencies = [ 1461 | "version_check", 1462 | ] 1463 | 1464 | [[package]] 1465 | name = "unicode-bidi" 1466 | version = "0.3.8" 1467 | source = "registry+https://github.com/rust-lang/crates.io-index" 1468 | checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" 1469 | 1470 | [[package]] 1471 | name = "unicode-ident" 1472 | version = "1.0.0" 1473 | source = "registry+https://github.com/rust-lang/crates.io-index" 1474 | checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" 1475 | 1476 | [[package]] 1477 | name = "unicode-normalization" 1478 | version = "0.1.19" 1479 | source = "registry+https://github.com/rust-lang/crates.io-index" 1480 | checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" 1481 | dependencies = [ 1482 | "tinyvec", 1483 | ] 1484 | 1485 | [[package]] 1486 | name = "unicode-segmentation" 1487 | version = "1.9.0" 1488 | source = "registry+https://github.com/rust-lang/crates.io-index" 1489 | checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" 1490 | 1491 | [[package]] 1492 | name = "unicode-width" 1493 | version = "0.1.9" 1494 | source = "registry+https://github.com/rust-lang/crates.io-index" 1495 | checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" 1496 | 1497 | [[package]] 1498 | name = "unicode-xid" 1499 | version = "0.2.3" 1500 | source = "registry+https://github.com/rust-lang/crates.io-index" 1501 | checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" 1502 | 1503 | [[package]] 1504 | name = "url" 1505 | version = "2.2.2" 1506 | source = "registry+https://github.com/rust-lang/crates.io-index" 1507 | checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" 1508 | dependencies = [ 1509 | "form_urlencoded", 1510 | "idna", 1511 | "matches", 1512 | "percent-encoding", 1513 | ] 1514 | 1515 | [[package]] 1516 | name = "utf-8" 1517 | version = "0.7.6" 1518 | source = "registry+https://github.com/rust-lang/crates.io-index" 1519 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 1520 | 1521 | [[package]] 1522 | name = "uuid" 1523 | version = "1.6.1" 1524 | source = "registry+https://github.com/rust-lang/crates.io-index" 1525 | checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" 1526 | 1527 | [[package]] 1528 | name = "vcpkg" 1529 | version = "0.2.15" 1530 | source = "registry+https://github.com/rust-lang/crates.io-index" 1531 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1532 | 1533 | [[package]] 1534 | name = "vec_map" 1535 | version = "0.8.2" 1536 | source = "registry+https://github.com/rust-lang/crates.io-index" 1537 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 1538 | 1539 | [[package]] 1540 | name = "version_check" 1541 | version = "0.9.4" 1542 | source = "registry+https://github.com/rust-lang/crates.io-index" 1543 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 1544 | 1545 | [[package]] 1546 | name = "walkdir" 1547 | version = "2.3.2" 1548 | source = "registry+https://github.com/rust-lang/crates.io-index" 1549 | checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" 1550 | dependencies = [ 1551 | "same-file", 1552 | "winapi 0.3.9", 1553 | "winapi-util", 1554 | ] 1555 | 1556 | [[package]] 1557 | name = "want" 1558 | version = "0.3.0" 1559 | source = "registry+https://github.com/rust-lang/crates.io-index" 1560 | checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" 1561 | dependencies = [ 1562 | "log", 1563 | "try-lock", 1564 | ] 1565 | 1566 | [[package]] 1567 | name = "wasi" 1568 | version = "0.10.2+wasi-snapshot-preview1" 1569 | source = "registry+https://github.com/rust-lang/crates.io-index" 1570 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 1571 | 1572 | [[package]] 1573 | name = "wasi" 1574 | version = "0.11.0+wasi-snapshot-preview1" 1575 | source = "registry+https://github.com/rust-lang/crates.io-index" 1576 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1577 | 1578 | [[package]] 1579 | name = "winapi" 1580 | version = "0.2.8" 1581 | source = "registry+https://github.com/rust-lang/crates.io-index" 1582 | checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" 1583 | 1584 | [[package]] 1585 | name = "winapi" 1586 | version = "0.3.9" 1587 | source = "registry+https://github.com/rust-lang/crates.io-index" 1588 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1589 | dependencies = [ 1590 | "winapi-i686-pc-windows-gnu", 1591 | "winapi-x86_64-pc-windows-gnu", 1592 | ] 1593 | 1594 | [[package]] 1595 | name = "winapi-build" 1596 | version = "0.1.1" 1597 | source = "registry+https://github.com/rust-lang/crates.io-index" 1598 | checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" 1599 | 1600 | [[package]] 1601 | name = "winapi-i686-pc-windows-gnu" 1602 | version = "0.4.0" 1603 | source = "registry+https://github.com/rust-lang/crates.io-index" 1604 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1605 | 1606 | [[package]] 1607 | name = "winapi-util" 1608 | version = "0.1.5" 1609 | source = "registry+https://github.com/rust-lang/crates.io-index" 1610 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 1611 | dependencies = [ 1612 | "winapi 0.3.9", 1613 | ] 1614 | 1615 | [[package]] 1616 | name = "winapi-x86_64-pc-windows-gnu" 1617 | version = "0.4.0" 1618 | source = "registry+https://github.com/rust-lang/crates.io-index" 1619 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1620 | 1621 | [[package]] 1622 | name = "windows-sys" 1623 | version = "0.36.1" 1624 | source = "registry+https://github.com/rust-lang/crates.io-index" 1625 | checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" 1626 | dependencies = [ 1627 | "windows_aarch64_msvc", 1628 | "windows_i686_gnu", 1629 | "windows_i686_msvc", 1630 | "windows_x86_64_gnu", 1631 | "windows_x86_64_msvc", 1632 | ] 1633 | 1634 | [[package]] 1635 | name = "windows_aarch64_msvc" 1636 | version = "0.36.1" 1637 | source = "registry+https://github.com/rust-lang/crates.io-index" 1638 | checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" 1639 | 1640 | [[package]] 1641 | name = "windows_i686_gnu" 1642 | version = "0.36.1" 1643 | source = "registry+https://github.com/rust-lang/crates.io-index" 1644 | checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" 1645 | 1646 | [[package]] 1647 | name = "windows_i686_msvc" 1648 | version = "0.36.1" 1649 | source = "registry+https://github.com/rust-lang/crates.io-index" 1650 | checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" 1651 | 1652 | [[package]] 1653 | name = "windows_x86_64_gnu" 1654 | version = "0.36.1" 1655 | source = "registry+https://github.com/rust-lang/crates.io-index" 1656 | checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" 1657 | 1658 | [[package]] 1659 | name = "windows_x86_64_msvc" 1660 | version = "0.36.1" 1661 | source = "registry+https://github.com/rust-lang/crates.io-index" 1662 | checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" 1663 | 1664 | [[package]] 1665 | name = "ws2_32-sys" 1666 | version = "0.2.1" 1667 | source = "registry+https://github.com/rust-lang/crates.io-index" 1668 | checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" 1669 | dependencies = [ 1670 | "winapi 0.2.8", 1671 | "winapi-build", 1672 | ] 1673 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["app", "lib"] 3 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Project Developers 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Penguin: language and framework agnostic dev server 2 | 3 | 4 | [CI status of main](https://github.com/LukasKalbertodt/penguin/actions/workflows/ci.yml) 5 | [App version on crates.io](https://crates.io/crates/penguin-app) 6 | [Library version on crates.io](https://crates.io/crates/penguin) 7 | [docs.rs](https://docs.rs/penguin) 8 | 9 | Penguin is a dev server featuring live-reloading, a file server, proxy support, and more. 10 | It is language and framework agnostic, so it works for basically any web project. 11 | Browser sessions can reload themselves (e.g. when a file changes) or show an overlay with a custom message (e.g. the compiler error). 12 | 13 | Penguin is available both as a command line application (`penguin-app` on crates.io) and as a library. For for more information on the library, see [its documentation](https://docs.rs/penguin). The rest of this document will mainly talk about the CLI app. 14 | 15 | 16 | ## Example 17 | 18 |

19 | 20 |

21 | 22 | - `penguin serve .` serves the current directory as file server 23 | - `penguin proxy localhost:3000` forwards all requests to `http://localhost:3000`. 24 | - `-m uri_path:fs_path` allows you to mount additional directories in the router. 25 | - `penguin reload` reloads all active browser sessions. 26 | 27 | 28 | ## Installation 29 | 30 | For now, you have to compile the app yourself. It's easiest to install it from 31 | crates.io: 32 | 33 | ``` 34 | cargo install penguin-app 35 | ``` 36 | 37 | Don't worry about the `-app` suffix: the installed binary is called `penguin`. 38 | 39 | 40 | ## CLI Usage 41 | 42 | There are two main "entry points": `penguin proxy ` and `penguin serve `. 43 | The `proxy` subcommand is useful if you have some (backend) webserver on your own, e.g. to provide an API. 44 | The `serve` subcommand is useful if you only have static files that need to be served, e.g. for static site generators or backend-less single page applications. 45 | 46 | In either case, you can *mount* additional directories at an URL path with `-m/--mount`. 47 | The syntax is `-m :`, for example `-m fonts:frontend/static`. 48 | An HTTP request for `/fonts/foo.woff2` would be answered with the file `frontend/static/foo.woff2` or with 404 if said file does not exist. 49 | 50 | All paths that are served by Penguin are automatically watched by default. 51 | This means that any file change in any of those directories will lead to all browser sessions reloading automatically. 52 | You can watch additional paths (that are not mounted/served) with `-w/--watch`. 53 | 54 | Reloading all active browser sessions can also be done manually via `penguin reload`. 55 | This is intended to be used at the end of your build scripts. 56 | Note that Penguin is not a build system or task executor! 57 | So you are mostly expected to combine it with other tools, like [`watchexec`](https://github.com/watchexec/watchexec), [`cargo watch`](https://github.com/passcod/cargo-watch) or others. 58 | I am also working on [`floof`](https://github.com/LukasKalbertodt/floof/), which is a WIP file-watcher and task-runner/build-system that uses Penguin under the hood to provide a dev server. 59 | 60 | Penguins output can be modified with `-v/-vv` and the log level (set via `-l` or `RUST_LOG`). 61 | 62 | For the full CLI documentation run `penguin --help` or `penguin --help`. 63 | 64 | 65 | ## Project status and "using in production" 66 | 67 | This project is fairly young and not well tested. 68 | However, it already serves as a useful development tool for me. 69 | I'm interested in making it useful for as many people as possible without increasing the project's scope too much. 70 | 71 | I am looking for **Community Feedback**: please speak your mind in [this issue](https://github.com/LukasKalbertodt/penguin/issues/6). 72 | Especially if you have a use case that is not yet well served by Penguin, I'd like to know about that! 73 | 74 | "Can I use Penguin in production?". **No, absolutely not!** This is a 75 | development tool only and you should not open up a Penguin server to the public. 76 | There are probably a gazillion attack vectors. 77 | 78 | 79 | ## Versioning and stability guarantees 80 | 81 | The app and library are versioned independently from one another. The project 82 | mostly follows the usual semantic versioning guidelines. 83 | 84 | - The required Rust version (MSRV) can be bumped at any time, even with minor 85 | releases. This will change once this project reaches 1.0. 86 | - All UI (HTML/CSS) this app/lib produces is subject to change even with minor 87 | version bumps. For example, you cannot rely on a specific "directory listing" 88 | of the file server. 89 | - HTTP headers in server replies might be added (or potentially even removed) 90 | even in minor version bumps. 91 | 92 | 93 |
94 | 95 | --- 96 | 97 | ## License 98 | 99 | Licensed under either of Apache License, Version 100 | 2.0 or MIT license at your option. 101 | Unless you explicitly state otherwise, any contribution intentionally submitted 102 | for inclusion in this project by you, as defined in the Apache-2.0 license, 103 | shall be dual licensed as above, without any additional terms or conditions. 104 | -------------------------------------------------------------------------------- /app/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the penguin **CLI app** will be documented here. 4 | 5 | 6 | ## [Unreleased] 7 | 8 | 9 | ## [0.2.6] - 2022-11-26 10 | - Improve `cargo doc` workflow by treating `remove` file system events as less important in watcher. Adds `--removal-debounce` flag. See [385b63](https://github.com/LukasKalbertodt/penguin/commit/385b6395142aff28fa5063162a8023e1392b0cf1). 11 | - Updated the library to v0.1.8 ⇒ [check its changelog](../lib/CHANGELOG.md#017---2022-06-22). 12 | - Add basic HTTP range request support for the file server. With this, video files served by Penguin can be played by Safari. 13 | - Add body sniffing to detect HTML content (and insert reload script) more often (see #11) 14 | 15 | ## [0.2.5] - 2022-06-22 16 | 17 | - Updated the library to v0.1.7 ⇒ [check its changelog](../lib/CHANGELOG.md#017---2022-06-22). 18 | 19 | ## [0.2.4] - 2022-06-08 20 | 21 | - Updated the library to v0.1.6 ⇒ [check its changelog](../lib/CHANGELOG.md#016---2022-06-08). 22 | - Updated other dependencies. 23 | 24 | ## [0.2.3] - 2021-10-02 25 | 26 | - Add feature `vendored-openssl` to compile `openssl` from source 27 | [PR #10](https://github.com/LukasKalbertodt/penguin/pull/10) (Thanks @philipahlberg) 28 | - Updated the library to v0.1.5 ⇒ [check its changelog](../lib/CHANGELOG.md#014---2021-09-02). 29 | - Updated other dependencies. 30 | 31 | ## [0.2.2] - 2021-10-02 32 | 33 | - Updated the library to v0.1.4 ⇒ [check its changelog](../lib/CHANGELOG.md#014---2021-09-02). 34 | - Updated other dependencies. 35 | 36 | 37 | ## [0.2.1] - 2021-07-18 38 | 39 | Updated the library to v0.1.3 ⇒ [check its changelog](../lib/CHANGELOG.md#013---2021-07-18). 40 | 41 | 42 | ## [0.2.0] - 2021-05-11 43 | 44 | Updated the library to v0.1.2 ⇒ [check its changelog](../lib/CHANGELOG.md#012---2021-05-10). 45 | 46 | ### Breaking 47 | - Mounted file system paths are now automatically watched for file changes. The 48 | browser sessions will reload automatically if anything changes. 49 | 50 | ### Added 51 | - Add `--open` flag to open the browser automatically. 52 | - Add `--no-auto-watch` flag to disable auto watch behavior. 53 | - Add `-w/--watch` option to specify additional watched paths. 54 | - Add `--debounce` flag to set the debounce duration for watched paths. 55 | 56 | 57 | ## 0.1.0 - 2021-03-03 58 | ### Added 59 | - Everything 60 | 61 | 62 | [Unreleased]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.6...HEAD 63 | [0.2.6]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.5...app-v0.2.6 64 | [0.2.5]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.4...app-v0.2.5 65 | [0.2.4]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.3...app-v0.2.4 66 | [0.2.3]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.2...app-v0.2.3 67 | [0.2.2]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.1...app-v0.2.2 68 | [0.2.1]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.2.0...app-v0.2.1 69 | [0.2.0]: https://github.com/LukasKalbertodt/penguin/compare/app-v0.1.0...app-v0.2.0 70 | -------------------------------------------------------------------------------- /app/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "penguin-app" 3 | version = "0.2.6" 4 | authors = ["Lukas Kalbertodt "] 5 | edition = "2018" 6 | 7 | description = """ 8 | Dev server with auto-reload, static file server, proxy support, and more. 9 | Language and framework agnostic. This is the CLI app, but Penguin exists 10 | as a library, too. 11 | """ 12 | repository = "https://github.com/LukasKalbertodt/penguin/" 13 | readme = "../README.md" 14 | license = "MIT/Apache-2.0" 15 | 16 | keywords = ["development", "autoreload", "devserver"] 17 | categories = ["development-tools", "command-line-utilities", "web-programming::http-server"] 18 | 19 | 20 | [[bin]] 21 | name = "penguin" 22 | path = "src/main.rs" 23 | 24 | 25 | [dependencies] 26 | anyhow = "1" 27 | bunt = "0.2.4" 28 | log = "0.4" 29 | notify = "4" 30 | open = "2" 31 | penguin = { version = "0.1.8", path = "../lib" } 32 | pretty_env_logger = "0.4" 33 | structopt = "0.3" 34 | tokio = { version = "1", features = ["rt", "macros"]} 35 | 36 | [features] 37 | vendored-openssl = ["penguin/vendored-openssl"] 38 | -------------------------------------------------------------------------------- /app/src/args.rs: -------------------------------------------------------------------------------- 1 | use std::{net::IpAddr, path::{Path, PathBuf}, time::Duration}; 2 | 3 | use log::LevelFilter; 4 | 5 | use structopt::StructOpt; 6 | use penguin::{Mount, ProxyTarget}; 7 | 8 | pub(crate) const DEFAULT_PORT: u16 = 4090; 9 | 10 | #[derive(Debug, StructOpt)] 11 | #[structopt( 12 | name = "Penguin", 13 | about = "Language-agnostic dev server that can serve directories and forward \ 14 | requests to a proxy.", 15 | setting(structopt::clap::AppSettings::VersionlessSubcommands), 16 | )] 17 | pub(crate) struct Args { 18 | /// Port of the Penguin server. 19 | #[structopt(short, long, default_value = "4090", global = true)] 20 | pub(crate) port: u16, 21 | 22 | /// Address to bind to. 23 | /// 24 | /// Mostly useful to set to "0.0.0.0" to let other devices in your network 25 | /// access the server. 26 | #[structopt(long, default_value = "127.0.0.1", global = true)] 27 | pub(crate) bind: IpAddr, 28 | 29 | /// Overrides the default control path '/~~penguin' with a custom path. 30 | /// 31 | /// Only useful you need to use '/~~penguin' in your own application. 32 | #[structopt(long, global = true)] 33 | pub(crate) control_path: Option, 34 | 35 | /// Quiet: `-q` for less output, `-qq` for no output. 36 | #[structopt(short, global = true, parse(from_occurrences))] 37 | pub(crate) quiet: u8, 38 | 39 | /// Sets the log level: trace, debug, info, warn, error or off. 40 | /// 41 | /// This value is only used if `RUST_LOG` is NOT set. If it is, the 42 | /// environment variable controls everything. 43 | #[structopt(short, long, global = true, default_value = "warn")] 44 | pub(crate) log_level: LevelFilter, 45 | 46 | /// Automatically opens the browser with the URL of this server. 47 | #[structopt(long, global = true)] 48 | pub(crate) open: bool, 49 | 50 | #[structopt(subcommand)] 51 | pub(crate) cmd: Command, 52 | } 53 | 54 | #[derive(Debug, StructOpt)] 55 | pub(crate) enum Command { 56 | /// Serve the specified directory as file server. 57 | /// 58 | /// You can mount more directories via '--mount'. If you don't specify a 59 | /// main directory for this subcommand, you have to mount at least one 60 | /// directory via '--mount'. 61 | /// 62 | /// Like with `--mount`, the directory specified here will be watched for 63 | /// file changes to automatically reload browser sessions. You can disable 64 | /// that with `--no-auto-watch`. 65 | Serve { 66 | #[structopt(parse(from_os_str))] 67 | path: Option, 68 | 69 | #[structopt(flatten)] 70 | options: ServeOptions, 71 | }, 72 | 73 | /// Starts a server forwarding all request to the specified target address. 74 | Proxy { 75 | target: ProxyTarget, 76 | 77 | #[structopt(flatten)] 78 | options: ServeOptions, 79 | }, 80 | 81 | /// Reloads all browser sessions. 82 | /// 83 | /// This sends a reload request to a locally running penguin server. The 84 | /// port and control path can be specified, if they are non-standard. 85 | Reload, 86 | } 87 | 88 | #[derive(Debug, Clone, StructOpt)] 89 | pub(crate) struct ServeOptions { 90 | /// Mount a directory on an URI path: '--mount :'. 91 | /// 92 | /// Example: '--mount assets:/home/peter/images'. Can be specified multiple 93 | /// times. If you only want to mount one directory in the root, rather use 94 | /// the `penguin serve` subcommand. 95 | /// 96 | /// By default, directories specified here will be watched for file changes 97 | /// to automatically reload browser sessions. You can disable that with 98 | /// `--no-auto-watch`. 99 | #[structopt( 100 | short, 101 | long = "--mount", 102 | number_of_values = 1, 103 | parse(try_from_str = parse_mount), 104 | )] 105 | pub(crate) mounts: Vec, 106 | 107 | /// When specified, penguin will not automatically watch the mounted paths. 108 | #[structopt(long)] 109 | pub(crate) no_auto_watch: bool, 110 | 111 | /// Watch a path for file system changes, triggering a reload. 112 | /// 113 | /// Note that mounted paths are already watched by default. 114 | #[structopt(short, long = "--watch", number_of_values = 1)] 115 | pub(crate) watched_paths: Vec, 116 | 117 | /// The debounce duration (in ms) for watching paths. 118 | /// 119 | /// Debouncing means that if a watch-event arrived, we are not immediately 120 | /// triggering a reload. Instead we wait for this duration and see if any 121 | /// other events arrive during this period. Whenever an event arrives, we 122 | /// reset the timer (so we could wait indefinitely). 123 | #[structopt( 124 | long = "--debounce", 125 | default_value = "200", 126 | parse(try_from_str = parse_duration) 127 | )] 128 | pub(crate) debounce_duration: Duration, 129 | 130 | /// The debounce duration (in ms) for when a file in a watched path was 131 | /// removed. 132 | /// 133 | /// Like `--debounce`, but for "remove" file system events. This is treated 134 | /// separately as usually reloading quickly on deletion is not useful: the 135 | /// reload would result in a 404 page. And in many situation (e.g. cargo 136 | /// doc) the watched directory is first wiped by a build process and then 137 | /// populated again after a while. So this default is much higher. 138 | #[structopt( 139 | long = "--removal-debounce", 140 | default_value = "3000", 141 | parse(try_from_str = parse_duration) 142 | )] 143 | pub(crate) removal_debounce_duration: Duration, 144 | } 145 | 146 | fn parse_mount(s: &str) -> Result { 147 | let colon_pos = s.find(':').ok_or("does not contain a colon")?; 148 | let fs_path = Path::new(&s[colon_pos + 1..]).to_owned(); 149 | 150 | let mut uri_path = s[..colon_pos].to_owned(); 151 | if !uri_path.starts_with('/') { 152 | uri_path.insert(0, '/'); 153 | } 154 | if uri_path.ends_with('/') && uri_path.len() > 1 { 155 | uri_path.pop(); 156 | } 157 | 158 | Ok(Mount { uri_path, fs_path}) 159 | } 160 | 161 | fn parse_duration(s: &str) -> Result { 162 | let ms = s.parse::().map_err(|_| "failed to parse as positive integer")?; 163 | Ok(Duration::from_millis(ms)) 164 | } 165 | 166 | impl Args { 167 | pub(crate) fn is_quiet(&self) -> bool { 168 | self.quiet > 0 169 | } 170 | 171 | pub(crate) fn is_muted(&self) -> bool { 172 | self.quiet == 2 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /app/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{env, iter}; 2 | 3 | use anyhow::{Context, Result}; 4 | use log::LevelFilter; 5 | use penguin::{Mount, hyper::{Body, Client, Request}}; 6 | use structopt::StructOpt; 7 | 8 | use crate::args::{Args, Command}; 9 | 10 | mod args; 11 | mod server; 12 | 13 | 14 | // A single thread runtime is plenty enough for a webserver purpose. 15 | #[tokio::main(flavor = "current_thread")] 16 | async fn main() { 17 | if let Err(e) = run().await { 18 | let header = "An error occured :-("; 19 | let line = iter::repeat('━').take(header.len() + 4).collect::(); 20 | 21 | eprintln!(); 22 | bunt::eprintln!(" {$yellow+intense}┏{}┓{/$}", line); 23 | bunt::eprintln!(" {$yellow+intense}┃{/$} {[red+bold]} {$yellow+intense}┃{/$}", header); 24 | bunt::eprintln!(" {$yellow+intense}┗{}┛{/$}", line); 25 | eprintln!(); 26 | 27 | bunt::eprintln!("{[red+intense]}", e); 28 | if e.chain().count() > 1 { 29 | eprintln!(); 30 | eprintln!("Caused by:"); 31 | for cause in e.chain().skip(1) { 32 | bunt::eprintln!(" ‣ {}", cause); 33 | } 34 | } 35 | } 36 | } 37 | 38 | async fn run() -> Result<()> { 39 | if env::args().count() == 1 { 40 | print_welcome_message(); 41 | return Ok(()); 42 | } 43 | 44 | // Parse CLI arguments. 45 | let args = Args::from_args(); 46 | 47 | init_logger(args.log_level); 48 | 49 | match &args.cmd { 50 | Command::Proxy { target, options } => { 51 | server::run(Some(target), &options.mounts, options, &args) 52 | .await 53 | .context("failed to run server")?; 54 | } 55 | Command::Serve { path, options } => { 56 | let root_mount = path.clone().map(|p| Mount { uri_path: "/".into(), fs_path: p }); 57 | let mounts = options.mounts.iter().chain(&root_mount); 58 | server::run(None, mounts, options, &args).await.context("failed to run server")?; 59 | } 60 | Command::Reload => reload(&args).await.context("failed to send reload request")?, 61 | } 62 | 63 | Ok(()) 64 | } 65 | 66 | fn init_logger(level: LevelFilter) { 67 | if env::var("RUST_LOG") == Err(env::VarError::NotPresent) { 68 | env::set_var("RUST_LOG", format!("penguin={}", level)); 69 | } 70 | 71 | pretty_env_logger::init(); 72 | } 73 | 74 | async fn reload(args: &Args) -> Result<()> { 75 | let uri = format!( 76 | "http://{}:{}{}/reload", 77 | args.bind, 78 | args.port, 79 | args.control_path.as_deref().unwrap_or(penguin::DEFAULT_CONTROL_PATH), 80 | ); 81 | 82 | let req = Request::builder() 83 | .method("POST") 84 | .uri(&uri) 85 | .body(Body::empty()) 86 | .expect("bug: failed to build request"); 87 | 88 | if !args.is_quiet() { 89 | bunt::println!("Sending POST request to {[green]}", uri); 90 | } 91 | 92 | let client = Client::new(); 93 | client.request(req).await 94 | .with_context(|| format!("failed to send request to '{}'", uri))?; 95 | 96 | if !args.is_quiet() { 97 | bunt::println!("{$green+bold}✔ done{/$}"); 98 | } 99 | 100 | Ok(()) 101 | } 102 | 103 | fn print_welcome_message() { 104 | bunt::println!("{$blue+bold+intense}Penguin 🐧{/$}"); 105 | println!(); 106 | println!("You have to specify a subcommand. Example usages:"); 107 | bunt::println!(" ‣ Serve a directory: {$yellow}penguin serve ./target{/$}"); 108 | bunt::println!(" ‣ Reload all browser sessions: {$yellow}penguin reload{/$}"); 109 | bunt::println!(" ‣ Forward requests to proxy and serve one directory on a subpath:"); 110 | bunt::println!(" {$yellow}penguin proxy localhost:8000 -m /assets:frontend/dist{/$}"); 111 | 112 | println!(); 113 | bunt::println!("For more information, run {$yellow}penguin -h{/$} for a short CLI overview"); 114 | bunt::println!("or {$yellow}penguin --help{/$} for a detailed description."); 115 | } 116 | -------------------------------------------------------------------------------- /app/src/server.rs: -------------------------------------------------------------------------------- 1 | use std::{env, ops::Deref, path::Path, thread, time::Duration}; 2 | 3 | use anyhow::{Context, Result}; 4 | use log::{debug, info, trace, LevelFilter}; 5 | use notify::Op; 6 | use penguin::{Config, Controller, Mount, ProxyTarget, Server}; 7 | 8 | use crate::args::{Args, DEFAULT_PORT, ServeOptions}; 9 | 10 | 11 | 12 | pub(crate) async fn run( 13 | proxy: Option<&ProxyTarget>, 14 | mounts: impl Clone + IntoIterator, 15 | options: &ServeOptions, 16 | args: &Args, 17 | ) -> Result<()> { 18 | let bind_addr = (args.bind, args.port).into(); 19 | let mut builder = Server::bind(bind_addr); 20 | 21 | for mount in mounts.clone() { 22 | builder = builder.add_mount(&mount.uri_path, &mount.fs_path) 23 | .context("failed to add mount")?; 24 | } 25 | if let Some(control_path) = &args.control_path { 26 | builder = builder.set_control_path(control_path); 27 | } 28 | if let Some(target) = proxy { 29 | builder = builder.proxy(target.clone()) 30 | } 31 | 32 | 33 | let config = builder.validate().context("invalid penguin config")?; 34 | let (server, controller) = Server::build(config.clone()); 35 | 36 | let watched_paths = if !options.no_auto_watch { 37 | mounts.into_iter() 38 | .map(|m| &*m.fs_path) 39 | .chain(options.watched_paths.iter().map(Deref::deref)) 40 | .collect() 41 | } else if !options.watched_paths.is_empty() { 42 | options.watched_paths.iter().map(Deref::deref).collect() 43 | } else { 44 | vec![] 45 | }; 46 | 47 | if !watched_paths.is_empty() { 48 | watch(controller, options, &watched_paths)?; 49 | } 50 | 51 | // Nice output of what is being done 52 | if !args.is_muted() { 53 | bunt::println!( 54 | "{$bold}Penguin started!{/$} Listening on {$yellow+intense+bold}http://{}{/$}", 55 | bind_addr, 56 | ); 57 | 58 | if !args.is_quiet() { 59 | pretty_print_config(&config, args, &watched_paths); 60 | } 61 | } 62 | 63 | if args.open { 64 | // This is a bit hacky but it works and doing it properly is 65 | // surprisingly hard. We want to only open the browser if we were able 66 | // to start the server without problems (where 99% of anticipated 67 | // problems are: port is already in use). `hyper` doesn't quite allow us 68 | // to do that as far as I know. So we simply start a thread and wait a 69 | // bit. If starting the server errors, then the program (including this 70 | // thread) will be stopped quickly and the `open::that` call is never 71 | // executed. 72 | thread::spawn(move || { 73 | thread::sleep(Duration::from_millis(50)); 74 | 75 | let url = format!("http://{}", bind_addr); 76 | match open::that(url) { 77 | Ok(_) => {} 78 | Err(e) => bunt::println!( 79 | "{$yellow}Warning{/$}: couldn't open browser. Error: {}", 80 | e, 81 | ), 82 | } 83 | }); 84 | } 85 | 86 | server.await?; 87 | 88 | Ok(()) 89 | } 90 | 91 | fn watch<'a>( 92 | controller: Controller, 93 | options: &ServeOptions, 94 | paths: &[&Path], 95 | ) -> Result<()> { 96 | use std::sync::mpsc::{channel, RecvTimeoutError}; 97 | use notify::{RawEvent, RecursiveMode, Watcher}; 98 | 99 | /// Helper to format an optional path in a nice way. 100 | fn pretty_path(event: &RawEvent) -> String { 101 | match &event.path { 102 | Some(p) => p.display().to_string(), 103 | None => "???".into(), 104 | } 105 | } 106 | 107 | // Create an configure watcher. 108 | let (tx, rx) = channel(); 109 | let mut watcher = notify::raw_watcher(tx).context("could not create FS watcher")?; 110 | 111 | for path in paths { 112 | watcher.watch(path, RecursiveMode::Recursive) 113 | .context(format!("failed to watch '{}'", path.display()))?; 114 | } 115 | 116 | // We create a new thread that will react to incoming events and trigger a 117 | // page reload. 118 | let options = options.clone(); 119 | thread::spawn(move || { 120 | // Move it to the thread to avoid dropping it early. 121 | let _watcher = watcher; 122 | let debounce_duration_of = |event: &RawEvent| { 123 | if event.op.as_ref().is_ok_and(|&op| op == Op::REMOVE) { 124 | options.removal_debounce_duration 125 | } else { 126 | options.debounce_duration 127 | } 128 | }; 129 | 130 | while let Ok(event) = rx.recv() { 131 | let mut debounce_duration = debounce_duration_of(&event); 132 | 133 | debug!( 134 | "Received watch-event '{:?}' for '{}'. Debouncing now for {:?}.", 135 | event.op, 136 | pretty_path(&event), 137 | debounce_duration, 138 | ); 139 | 140 | // Debounce. We loop forever until no new event arrived for 141 | // `debounce_duration`. 142 | loop { 143 | match rx.recv_timeout(debounce_duration) { 144 | Ok(event) => { 145 | trace!( 146 | "Debounce interrupted by '{:?}' of '{}'", 147 | event.op, 148 | pretty_path(&event), 149 | ); 150 | 151 | // We reset the waiting duration to the minimum of both 152 | // events' durations. So if any non-remove event is 153 | // involved, the shorter duration is used. 154 | debounce_duration = std::cmp::min( 155 | debounce_duration_of(&event), 156 | debounce_duration, 157 | ); 158 | }, 159 | Err(RecvTimeoutError::Timeout) => break, 160 | Err(RecvTimeoutError::Disconnected) => return, 161 | } 162 | } 163 | 164 | // Finally, send a reload command 165 | info!("Reloading browser sessions due to file changes in watched directories"); 166 | controller.reload(); 167 | } 168 | }); 169 | 170 | Ok(()) 171 | } 172 | 173 | fn pretty_print_config(config: &Config, args: &Args, watched_paths: &[&Path]) { 174 | // Routing description 175 | println!(); 176 | bunt::println!(" {$cyan+bold}▸ Routing:{/$}"); 177 | bunt::println!( 178 | " ├╴ Requests to {[blue+intense]} are handled internally by penguin", 179 | config.control_path(), 180 | ); 181 | 182 | for mount in config.mounts() { 183 | let fs_path = env::current_dir() 184 | .as_deref() 185 | .unwrap_or(Path::new(".")) 186 | .join(&mount.fs_path); 187 | 188 | bunt::println!( 189 | " ├╴ Requests to {[blue+intense]} are served from the directory {[green]}", 190 | mount.uri_path, 191 | fs_path.display(), 192 | ); 193 | } 194 | 195 | if let Some(proxy) = config.proxy() { 196 | bunt::println!(" ╰╴ All remaining requests are forwarded to {[green+intense]}", proxy); 197 | } else { 198 | bunt::println!(" ╰╴ All remaining requests will be responded to with 404"); 199 | } 200 | 201 | if !watched_paths.is_empty() { 202 | println!(); 203 | bunt::println!(" {$cyan+bold}▸ Watching:{/$} {$dimmed}(reloading on file change){/$}"); 204 | for p in watched_paths { 205 | let canonical = p.canonicalize(); 206 | bunt::println!(" • {[green]}", canonical.as_deref().unwrap_or(p).display()); 207 | } 208 | } 209 | 210 | // Random hints 211 | println!(); 212 | bunt::println!(" {$cyan+bold}▸ Hints:{/$}"); 213 | bunt::println!( 214 | " • To reload all browser sessions, run {$yellow}penguin reload{}{}{/$}", 215 | if args.port != DEFAULT_PORT { format!(" -p {}", args.port) } else { "".into() }, 216 | args.control_path.as_ref() 217 | .map(|p| format!(" --control-path {}", p)) 218 | .unwrap_or_default(), 219 | ); 220 | if args.log_level == LevelFilter::Warn { 221 | bunt::println!( 222 | " • For more log output use {$yellow}-l trace{/$} \ 223 | or set the env variable {$yellow}RUST_LOG{/$}", 224 | ); 225 | } 226 | 227 | println!(); 228 | } 229 | -------------------------------------------------------------------------------- /lib/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the penguin **library** will be documented here. 4 | 5 | 6 | ## [Unreleased] 7 | 8 | ## [0.1.8] - 2023-11-26 9 | - Add basic HTTP range request support for the file server. With this, video files served by Penguin can be played by Safari. 10 | - Add body sniffing to detect HTML content (and insert reload script) more often (see #11) 11 | 12 | ## [0.1.7] - 2022-06-22 13 | ### Fixed 14 | - Fix 404, "gateway error" and dir-listing pages that were broken in the previous release. (The JS code wasn't injected correctly, showing up as plain text. Woops.) 15 | 16 | ## [0.1.6] - 2022-06-08 17 | ### Fixed 18 | - `Content-Security-Policy` (CSP) header is now potentially modified in proxy mode if required for penguin's injected script (`'self'` is potentially added to `script-src` and `connect-src`). 19 | 20 | ### Improved 21 | - Update dependencies (this bumps the MSRV to 1.56!) 22 | 23 | ## [0.1.5] - 2022-04-19 24 | ### Added 25 | - Add feature `vendored-openssl` to compile `openssl` from source 26 | [PR #10](https://github.com/LukasKalbertodt/penguin/pull/10) (Thanks @philipahlberg) 27 | 28 | ### Improved 29 | - Updated dependencies 30 | 31 | ## [0.1.4] - 2021-10-02 32 | ### Improved 33 | - Include reload script in 404 response: now the page can still reload itself 34 | after a 404 reply. 35 | - After a getting a gateway error, automatically reload all browser sessions 36 | once the proxy is reachable again. This is done by regularly polling the 37 | proxy from the Penguin server. 38 | 39 | ### Fixed 40 | - When using the proxy, the `host` HTTP-header is adjusted to the proxy target 41 | host (instead of the original `localhost:4090` that the browser sends). 42 | - Correctly handle compression in proxy: gzip and brotli compression is 43 | supported and the HTTP body is decompressed before the reload script is 44 | injected. This was just totally broken before. The `accept-encoding` header 45 | of the request is also adjusted to not list anything but `gzip` and `br`. 46 | - Rewrite `location` header to make HTTP redirects work with proxy. 47 | 48 | ## [0.1.3] - 2021-07-18 49 | ### Fixed 50 | - Fix bug resulting in endless reloading if the proxy is slow 51 | - Ignore one specific WS error that occurs often, is not important and caused 52 | lots of useless warnings 53 | - Correctly handle ping messages (also getting rid of useless warnings) 54 | 55 | ## [0.1.2] - 2021-05-10 56 | ### Added 57 | - All responses (except the ones forwarded from the proxy server) now contain 58 | the "server" HTTP header. 59 | 60 | ### Fixed 61 | - Make Penguin work with non-`127.0.0.1` loopback addresses. 62 | - Fix warning about directory traversal attack incorrectly being emitted. 63 | 64 | ## [0.1.1] - 2021-03-07 65 | ### Added 66 | - `util::wait_for_proxy` 67 | 68 | ### Changed 69 | - If the server cannot bind to the port, an error is returned from the server 70 | future instead of panicking. 71 | 72 | 73 | ## 0.1.0 - 2021-03-03 74 | ### Added 75 | - Everything 76 | 77 | 78 | [Unreleased]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.8...HEAD 79 | [0.1.8]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.7...lib-v0.1.8 80 | [0.1.7]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.6...lib-v0.1.7 81 | [0.1.6]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.5...lib-v0.1.6 82 | [0.1.5]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.4...lib-v0.1.5 83 | [0.1.4]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.3...lib-v0.1.4 84 | [0.1.3]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.2...lib-v0.1.3 85 | [0.1.2]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.1...lib-v0.1.2 86 | [0.1.1]: https://github.com/LukasKalbertodt/penguin/compare/lib-v0.1.0...lib-v0.1.1 87 | -------------------------------------------------------------------------------- /lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "penguin" 3 | version = "0.1.8" 4 | authors = ["Lukas Kalbertodt "] 5 | edition = "2018" 6 | 7 | description = """ 8 | Dev server with auto-reload, static file server, proxy support, and more. 9 | Language and framework agnostic. This is the library crate, but Penguin exists 10 | as a CLI app, too. 11 | """ 12 | documentation = "https://docs.rs/penguin/" 13 | repository = "https://github.com/LukasKalbertodt/penguin/" 14 | readme = "../README.md" 15 | license = "MIT/Apache-2.0" 16 | 17 | keywords = ["development", "autoreload", "devserver"] 18 | categories = ["development-tools", "web-programming::http-server"] 19 | exclude = ["Cargo.lock"] 20 | 21 | 22 | [dependencies] 23 | brotli = "3.2" 24 | flate2 = "1.0.22" 25 | futures = "0.3" 26 | http-range = "0.1.5" 27 | hyper = { version = "0.14", features = ["client", "http1", "http2", "server", "stream", "tcp"] } 28 | hyper-tls = "0.5" 29 | hyper-tungstenite = "0.8" 30 | infer = "0.15.0" 31 | log = "0.4" 32 | mime_guess = "2" 33 | thiserror = "1" 34 | tokio = { version = "1", features = ["fs", "macros"] } 35 | tokio-util = { version = "0.7.3", features = ["codec"] } 36 | 37 | [dev-dependencies] 38 | tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"]} 39 | 40 | [features] 41 | vendored-openssl = ["hyper-tls/vendored"] 42 | -------------------------------------------------------------------------------- /lib/build.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, path::{Path, PathBuf}, process::Command}; 2 | 3 | 4 | // This build script compiles the Typescript code. 5 | fn main() -> Result<(), Box> { 6 | println!("cargo:rerun-if-changed=src/browser.ts"); 7 | 8 | let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); 9 | let infile = manifest_dir.join("src").join("browser.ts"); 10 | let outfile = manifest_dir.join("src").join("generated").join("browser.js"); 11 | 12 | // Cargo already just calls this script when the `.ts` file was changed. 13 | // However, we add this extra check to make sure we don't try to compile it 14 | // again if it's not necessary. This means that devs checking out the repo 15 | // or `cargo install`ing penguin don't need to have `tsc` installed (since 16 | // the generated file is checked into git). 17 | let need_compiling = !outfile.exists() 18 | || infile.metadata()?.modified()? > outfile.metadata()?.modified()?; 19 | 20 | if !need_compiling { 21 | return Ok(()); 22 | } 23 | 24 | // Figure out which `tsc` to use. Prefer a locally installed one but if 25 | // that's not present, try a global `tsc`. 26 | let local_tsc = manifest_dir 27 | .join("node_modules") 28 | .join("typescript") 29 | .join("bin") 30 | .join("tsc"); 31 | 32 | let tsc = if local_tsc.exists() { 33 | &local_tsc 34 | } else { 35 | Path::new("tsc") 36 | }; 37 | 38 | // Run `tsc` and check the status. 39 | let status = Command::new(tsc) 40 | .current_dir(&manifest_dir) 41 | .arg("--pretty") 42 | .status(); 43 | match status { 44 | Err(e) => { 45 | eprintln!("Error executing `tsc`."); 46 | if !local_tsc.exists() { 47 | eprintln!("You might need to run `npm install` in the `lib` folder"); 48 | } 49 | Err(e)?; 50 | } 51 | Ok(status) if !status.success() => { 52 | Err("`tsc` reported errors.")?; 53 | } 54 | Ok(_) => {} 55 | } 56 | 57 | Ok(()) 58 | } 59 | -------------------------------------------------------------------------------- /lib/examples/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use penguin::Server; 4 | 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<(), Box> { 8 | let (server, _controller) = Server::bind(([127, 0, 0, 1], 3001).into()) 9 | .add_mount("/", Path::new("."))? 10 | .build()?; 11 | 12 | // // Dummy code to regularly reload all sessions. 13 | // tokio::spawn(async move { 14 | // let mut interval = tokio::time::interval(std::time::Duration::from_secs(3)); 15 | // loop { 16 | // interval.tick().await; 17 | // controller.reload(); 18 | // } 19 | // }); 20 | 21 | server.await?; 22 | 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "penguin", 3 | "version": "0.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "typescript": "^4.1.5" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/src/assets/dir-listing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Contents of directory {{ uri_path }} 4 | 21 | 22 | 23 |

Contents of directory {{ uri_path }}

24 |
    {{ entries }}
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lib/src/assets/not-found.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Not found 6 | 11 | 12 | 13 |

404 – Not found

14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/src/assets/proxy-error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Failed to connect to the proxy target. 4 | 5 | 6 |

Failed to connect to the proxy target.

7 |
{{ error }}
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/src/browser.ts: -------------------------------------------------------------------------------- 1 | // This code was inserted by the 'penguin' library. It's here to enable features 2 | // like browser auto-reloading or showing messages. It does this by 3 | // communicating with the penguin server via a websocket. 4 | // 5 | 6 | // Configuration dependent values that are passed/interpolated by the penguin 7 | // server. 8 | const control_path = "{{ control_path }}"; 9 | 10 | 11 | // The target URI of the websocket connection. 12 | const wsUri = (() => { 13 | const scheme = window.location.protocol === "https" ? "wss" : "ws"; 14 | const host = window.location.host; 15 | return `${scheme}://${host}${control_path}`; 16 | })(); 17 | 18 | // Open websocket connection and install handlers. 19 | const socket = new WebSocket(wsUri); 20 | socket.addEventListener("close", onConnectionError); 21 | socket.addEventListener("open", () => { 22 | socket.removeEventListener("close", onConnectionError) 23 | 24 | socket.addEventListener("close", () => { 25 | console.log("penguin server closed WS connection: trying to reconnect..."); 26 | tryReconnect(); 27 | }); 28 | socket.addEventListener("message", onMessage); 29 | }); 30 | 31 | 32 | function tryReconnect() { 33 | const DELAY_BETWEEN_RETRIES = 2000; 34 | const RETRY_COUNT_BEFORE_GIVING_UP = 30; 35 | 36 | function connect(unregister: () => void) { 37 | const socket = new WebSocket(wsUri); 38 | socket.addEventListener("open", () => { 39 | console.log("Reestablished connection: reloading..."); 40 | unregister(); 41 | location.reload(); 42 | }); 43 | } 44 | 45 | function retryRegularlyForAWhile() { 46 | let count = 0; 47 | const interval = setInterval(() => { 48 | connect(() => clearInterval(interval)); 49 | 50 | count += 1; 51 | if (count > RETRY_COUNT_BEFORE_GIVING_UP) { 52 | clearInterval(interval); 53 | } 54 | }, DELAY_BETWEEN_RETRIES); 55 | } 56 | 57 | // We immediately start trying to reconnect in a loop, but stop after a 58 | // while to not waste system resources. But we also check for visibility 59 | // changes. Whenever the page visibility changes to "visible", we 60 | // immediately retry and also start the retry loop again. 61 | retryRegularlyForAWhile(); 62 | const onVisibilityChange = () => { 63 | if (document.visibilityState === "visible") { 64 | connect(() => document.removeEventListener("visibilitychange", onVisibilityChange)); 65 | retryRegularlyForAWhile(); 66 | } 67 | }; 68 | document.addEventListener("visibilitychange", onVisibilityChange); 69 | } 70 | 71 | function onConnectionError() { 72 | console.warn(`Could not connect to web socket backend ${wsUri}`); 73 | } 74 | 75 | function onMessage(event: MessageEvent) { 76 | if (typeof event.data !== 'string') { 77 | throw new Error("unexpected WS message from penguin"); 78 | } 79 | 80 | const endLine = event.data.indexOf('\n'); 81 | const command = event.data.slice(0, endLine === -1 ? undefined : endLine); 82 | const payload = endLine === - 1 ? "" : event.data.slice(endLine + 1); 83 | 84 | switch (command) { 85 | case "reload": 86 | console.log("Received reload request from penguin server: reloading page..."); 87 | location.reload(); 88 | break; 89 | 90 | case "message": 91 | showMessage(payload); 92 | break; 93 | 94 | default: 95 | throw new Error("unexpected WS command from penguin"); 96 | } 97 | } 98 | 99 | function showMessage(message: string) { 100 | let overlay = document.createElement("div"); 101 | 102 | // We encode '✖' as escape code to make this work with non-UTF8 HTML. 103 | let closeButton = document.createElement("button"); 104 | closeButton.innerText = "Close \u2716"; 105 | closeButton.style.fontSize = "20px"; 106 | closeButton.style.fontFamily = "sans-serif"; 107 | closeButton.style.display = "inline-block"; 108 | closeButton.style.cursor = "pointer"; 109 | closeButton.addEventListener("click", () => overlay.style.display = "none"); 110 | 111 | let header = document.createElement("div"); 112 | header.style.textAlign = "right"; 113 | header.style.margin = "8px"; 114 | header.appendChild(closeButton); 115 | 116 | let content = document.createElement("div"); 117 | content.innerHTML = message; 118 | content.style.margin = "16px"; 119 | content.style.height = "100%"; 120 | 121 | overlay.appendChild(header); 122 | overlay.appendChild(content); 123 | overlay.style.position= "fixed"; 124 | overlay.style.zIndex = "987654321"; // Arbitrary very large number 125 | overlay.style.height = "100vh"; 126 | overlay.style.width = "100vw"; 127 | overlay.style.top = "0"; 128 | overlay.style.left = "0"; 129 | overlay.style.backgroundColor = "#ebebeb"; 130 | 131 | document.body.prepend(overlay); 132 | } 133 | -------------------------------------------------------------------------------- /lib/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, net::{IpAddr, SocketAddr}, path::PathBuf, str::FromStr}; 2 | 3 | use hyper::{Uri, http::uri}; 4 | 5 | use crate::{Controller, Server}; 6 | 7 | 8 | /// The URI path which is used for penguin internal control functions (e.g. 9 | /// opening WS connections). 10 | /// 11 | /// We need a path that: 12 | /// - is unlikely to clash with real paths of existing web applications, 13 | /// - is still somewhat easy to type and remember (e.g. to send requests via 14 | /// `curl`), and 15 | /// - doesn't use any invalid characters for URLs. 16 | pub const DEFAULT_CONTROL_PATH: &str = "/~~penguin"; 17 | 18 | /// A valid penguin server configuration. 19 | /// 20 | /// To create a configuration, use [`Server::bind`] to obtain a [`Builder`] 21 | /// which can be turned into a `Config`. 22 | #[derive(Debug, Clone)] 23 | pub struct Config { 24 | /// The port/socket address the server should be listening on. 25 | pub(crate) bind_addr: SocketAddr, 26 | 27 | /// Proxy target that HTTP requests should be forwarded to. 28 | pub(crate) proxy: Option, 29 | 30 | /// A list of directories to serve as a file server. As expected from other 31 | /// file servers, this lists the contents of directories and serves files 32 | /// directly. HTML files are injected with the penguin JS code. 33 | pub(crate) mounts: Vec, 34 | 35 | /// HTTP requests to this path are interpreted by this library to perform 36 | /// its function and are not normally served via the reverse proxy or the 37 | /// static file server. 38 | /// 39 | /// Has to start with `/` and *not* include the trailing `/`. 40 | pub(crate) control_path: String, 41 | } 42 | 43 | impl Config { 44 | pub fn proxy(&self) -> Option<&ProxyTarget> { 45 | self.proxy.as_ref() 46 | } 47 | 48 | pub fn mounts(&self) -> &[Mount] { 49 | &self.mounts 50 | } 51 | 52 | pub fn control_path(&self) -> &str { 53 | &self.control_path 54 | } 55 | } 56 | 57 | /// Builder for the configuration of `Server`. 58 | #[derive(Debug, Clone)] 59 | pub struct Builder(Config); 60 | 61 | impl Builder { 62 | /// Creates a new configuration. The `bind_addr` is what the server will 63 | /// listen on. 64 | pub(crate) fn new(bind_addr: SocketAddr) -> Self { 65 | Self(Config { 66 | bind_addr, 67 | proxy: None, 68 | control_path: DEFAULT_CONTROL_PATH.into(), 69 | mounts: Vec::new(), 70 | }) 71 | } 72 | 73 | /// Enables and sets a proxy: incoming requests (that do not match a mount) 74 | /// are forwarded to the given proxy target and its response is forwarded 75 | /// back to the initiator of the request. 76 | /// 77 | /// **Panics** if this method is called more than once on a single 78 | /// `Builder`. 79 | pub fn proxy(mut self, target: ProxyTarget) -> Self { 80 | if let Some(prev) = self.0.proxy { 81 | panic!( 82 | "`Builder::proxy` called a second time: is called with '{}' now \ 83 | but was previously called with '{}'", 84 | target, 85 | prev, 86 | ); 87 | } 88 | 89 | self.0.proxy = Some(target); 90 | self 91 | } 92 | 93 | /// Adds a mount: a directory to be served via file server under `uri_path`. 94 | /// The order in which the serve dirs are added does not matter. When 95 | /// serving a request, the most specific matching entry "wins". 96 | /// 97 | /// This method returns `ConfigError::DuplicateUriPath` if the same 98 | /// `uri_path` was added before. 99 | pub fn add_mount( 100 | mut self, 101 | uri_path: impl Into, 102 | fs_path: impl Into, 103 | ) -> Result { 104 | let mut uri_path = uri_path.into(); 105 | normalize_path(&mut uri_path); 106 | 107 | if self.0.mounts.iter().any(|other| other.uri_path == uri_path) { 108 | return Err(ConfigError::DuplicateUriPath(uri_path)); 109 | } 110 | 111 | self.0.mounts.push(Mount { 112 | uri_path, 113 | fs_path: fs_path.into(), 114 | }); 115 | 116 | Ok(self) 117 | } 118 | 119 | /// Overrides the control path (`/~~penguin` by default) with a custom path. 120 | /// 121 | /// This is only useful if your web application wants to use the route 122 | /// `/~~penguin`. 123 | pub fn set_control_path(mut self, path: impl Into) -> Self { 124 | self.0.control_path = path.into(); 125 | normalize_path(&mut self.0.control_path); 126 | self 127 | } 128 | 129 | /// Validates the configuration and builds the server and controller from 130 | /// it. This is a shortcut for [`Builder::validate`] plus [`Server::build`]. 131 | pub fn build(self) -> Result<(Server, Controller), ConfigError> { 132 | self.validate().map(Server::build) 133 | } 134 | 135 | /// Validates the configuration and returns the finished [`Config`]. 136 | pub fn validate(self) -> Result { 137 | if self.0.proxy.is_none() && self.0.mounts.is_empty() { 138 | return Err(ConfigError::NoProxyOrMount) 139 | } 140 | 141 | if self.0.proxy.is_some() && self.0.mounts.iter().any(|other| other.uri_path == "/") { 142 | return Err(ConfigError::ProxyAndRootMount); 143 | } 144 | 145 | Ok(self.0) 146 | } 147 | } 148 | 149 | fn normalize_path(path: &mut String) { 150 | if path.len() > 1 && path.ends_with('/') { 151 | path.pop(); 152 | } 153 | if !path.starts_with('/') { 154 | path.insert(0, '/'); 155 | } 156 | } 157 | 158 | /// Configuration validation error. 159 | #[derive(Debug, thiserror::Error)] 160 | #[non_exhaustive] 161 | pub enum ConfigError { 162 | #[error("URI path '{0}' was added as mount twice")] 163 | DuplicateUriPath(String), 164 | 165 | #[error("a proxy was configured but a mount on '/' was added as well (in \ 166 | that case, the proxy is would be ignored)")] 167 | ProxyAndRootMount, 168 | 169 | #[error("neither a proxy nor a mount was specified: server would always \ 170 | respond 404 in this case")] 171 | NoProxyOrMount, 172 | } 173 | 174 | /// Defintion of a proxy target consisting of a scheme and authority (≈host). 175 | /// 176 | /// To create this type you can: 177 | /// - use the `FromStr` impl: `"http://localhost:8000".parse()`, or 178 | /// - use the `From<(Scheme, Authority)>` impl. 179 | /// 180 | /// The `FromStr` allows omitting the scheme ('http' or 'https') if the host is 181 | /// `"localhost"` or a loopback address and defaults to 'http' in that case. For 182 | /// all other hosts, the scheme has to be specified. 183 | #[derive(Debug, Clone, PartialEq, Eq)] 184 | pub struct ProxyTarget { 185 | pub(crate) scheme: uri::Scheme, 186 | pub(crate) authority: uri::Authority, 187 | } 188 | 189 | impl From<(uri::Scheme, uri::Authority)> for ProxyTarget { 190 | fn from((scheme, authority): (uri::Scheme, uri::Authority)) -> Self { 191 | Self { scheme, authority } 192 | } 193 | } 194 | 195 | impl fmt::Display for ProxyTarget { 196 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 197 | write!(f, "{}://{}", self.scheme, self.authority) 198 | } 199 | } 200 | 201 | impl FromStr for ProxyTarget { 202 | type Err = ProxyTargetParseError; 203 | fn from_str(src: &str) -> Result { 204 | let parts = src.parse::()?.into_parts(); 205 | let has_real_path = parts.path_and_query.as_ref() 206 | .map_or(false, |pq| !pq.as_str().is_empty() && pq.as_str() != "/"); 207 | if has_real_path { 208 | return Err(ProxyTargetParseError::HasPath); 209 | } 210 | 211 | let authority = parts.authority.ok_or(ProxyTargetParseError::MissingAuthority)?; 212 | let scheme = parts.scheme 213 | .or_else(|| { 214 | // If the authority is a loopback IP or "localhost", we default to HTTP as scheme. 215 | let ip = authority.host().parse::(); 216 | if authority.host() == "localhost" || ip.map_or(false, |ip| ip.is_loopback()) { 217 | Some(uri::Scheme::HTTP) 218 | } else { 219 | None 220 | } 221 | }) 222 | .ok_or(ProxyTargetParseError::MissingScheme)?; 223 | 224 | Ok(Self { scheme, authority }) 225 | } 226 | } 227 | 228 | /// Error that can occur when parsing a `ProxyTarget` from a string. 229 | #[derive(Debug, thiserror::Error)] 230 | #[non_exhaustive] 231 | pub enum ProxyTargetParseError { 232 | /// The string could not be parsed as `http::Uri`. 233 | #[error("invalid URI: {0}")] 234 | InvalidUri(#[from] uri::InvalidUri), 235 | 236 | /// The parsed URL has a path, but a proxy target must not have a path. 237 | #[error("proxy target has path which is not allowed")] 238 | HasPath, 239 | 240 | /// The URI does not have a scheme ('http' or 'https') specified when it 241 | /// should have. 242 | #[error("proxy target has no scheme ('http' or 'https') specified, but a \ 243 | scheme must be specified for non-local targets")] 244 | MissingScheme, 245 | 246 | /// The URI does not have an authority (≈ "host"), but it needs one. 247 | #[error("proxy target has no authority (\"host\") specified")] 248 | MissingAuthority, 249 | } 250 | 251 | /// A mapping from URI path to file system path. 252 | #[derive(Debug, Clone)] 253 | pub struct Mount { 254 | /// Path prefix of the URI that will map to the directory. Has to start with 255 | /// `/` and *not* include the trailing `/`. 256 | pub uri_path: String, 257 | 258 | /// Path to a directory on the file system that is served under the 259 | /// specified URI path. 260 | pub fs_path: PathBuf, 261 | } 262 | -------------------------------------------------------------------------------- /lib/src/generated/browser.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | // This code was inserted by the 'penguin' library. It's here to enable features 3 | // like browser auto-reloading or showing messages. It does this by 4 | // communicating with the penguin server via a websocket. 5 | // 6 | // Configuration dependent values that are passed/interpolated by the penguin 7 | // server. 8 | const control_path = "{{ control_path }}"; 9 | // The target URI of the websocket connection. 10 | const wsUri = (() => { 11 | const scheme = window.location.protocol === "https" ? "wss" : "ws"; 12 | const host = window.location.host; 13 | return `${scheme}://${host}${control_path}`; 14 | })(); 15 | // Open websocket connection and install handlers. 16 | const socket = new WebSocket(wsUri); 17 | socket.addEventListener("close", onConnectionError); 18 | socket.addEventListener("open", () => { 19 | socket.removeEventListener("close", onConnectionError); 20 | socket.addEventListener("close", () => { 21 | console.log("penguin server closed WS connection: trying to reconnect..."); 22 | tryReconnect(); 23 | }); 24 | socket.addEventListener("message", onMessage); 25 | }); 26 | function tryReconnect() { 27 | const DELAY_BETWEEN_RETRIES = 2000; 28 | const RETRY_COUNT_BEFORE_GIVING_UP = 30; 29 | function connect(unregister) { 30 | const socket = new WebSocket(wsUri); 31 | socket.addEventListener("open", () => { 32 | console.log("Reestablished connection: reloading..."); 33 | unregister(); 34 | location.reload(); 35 | }); 36 | } 37 | function retryRegularlyForAWhile() { 38 | let count = 0; 39 | const interval = setInterval(() => { 40 | connect(() => clearInterval(interval)); 41 | count += 1; 42 | if (count > RETRY_COUNT_BEFORE_GIVING_UP) { 43 | clearInterval(interval); 44 | } 45 | }, DELAY_BETWEEN_RETRIES); 46 | } 47 | // We immediately start trying to reconnect in a loop, but stop after a 48 | // while to not waste system resources. But we also check for visibility 49 | // changes. Whenever the page visibility changes to "visible", we 50 | // immediately retry and also start the retry loop again. 51 | retryRegularlyForAWhile(); 52 | const onVisibilityChange = () => { 53 | if (document.visibilityState === "visible") { 54 | connect(() => document.removeEventListener("visibilitychange", onVisibilityChange)); 55 | retryRegularlyForAWhile(); 56 | } 57 | }; 58 | document.addEventListener("visibilitychange", onVisibilityChange); 59 | } 60 | function onConnectionError() { 61 | console.warn(`Could not connect to web socket backend ${wsUri}`); 62 | } 63 | function onMessage(event) { 64 | if (typeof event.data !== 'string') { 65 | throw new Error("unexpected WS message from penguin"); 66 | } 67 | const endLine = event.data.indexOf('\n'); 68 | const command = event.data.slice(0, endLine === -1 ? undefined : endLine); 69 | const payload = endLine === -1 ? "" : event.data.slice(endLine + 1); 70 | switch (command) { 71 | case "reload": 72 | console.log("Received reload request from penguin server: reloading page..."); 73 | location.reload(); 74 | break; 75 | case "message": 76 | showMessage(payload); 77 | break; 78 | default: 79 | throw new Error("unexpected WS command from penguin"); 80 | } 81 | } 82 | function showMessage(message) { 83 | let overlay = document.createElement("div"); 84 | // We encode '✖' as escape code to make this work with non-UTF8 HTML. 85 | let closeButton = document.createElement("button"); 86 | closeButton.innerText = "Close \u2716"; 87 | closeButton.style.fontSize = "20px"; 88 | closeButton.style.fontFamily = "sans-serif"; 89 | closeButton.style.display = "inline-block"; 90 | closeButton.style.cursor = "pointer"; 91 | closeButton.addEventListener("click", () => overlay.style.display = "none"); 92 | let header = document.createElement("div"); 93 | header.style.textAlign = "right"; 94 | header.style.margin = "8px"; 95 | header.appendChild(closeButton); 96 | let content = document.createElement("div"); 97 | content.innerHTML = message; 98 | content.style.margin = "16px"; 99 | content.style.height = "100%"; 100 | overlay.appendChild(header); 101 | overlay.appendChild(content); 102 | overlay.style.position = "fixed"; 103 | overlay.style.zIndex = "987654321"; // Arbitrary very large number 104 | overlay.style.height = "100vh"; 105 | overlay.style.width = "100vw"; 106 | overlay.style.top = "0"; 107 | overlay.style.left = "0"; 108 | overlay.style.backgroundColor = "#ebebeb"; 109 | document.body.prepend(overlay); 110 | } 111 | -------------------------------------------------------------------------------- /lib/src/inject.rs: -------------------------------------------------------------------------------- 1 | use crate::Config; 2 | 3 | 4 | /// Returns the JS code within `"#); 34 | 35 | let mut out = input[..insert_idx].to_vec(); 36 | out.extend_from_slice(script_tag.as_bytes()); 37 | out.extend_from_slice(&input[insert_idx..]); 38 | out 39 | } 40 | -------------------------------------------------------------------------------- /lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Penguin is a dev server with features like auto-reloading, a static file 2 | //! server, and proxy-support. It is available both, as an app and as a library. 3 | //! You are currently reading the library docs. If you are interested in the CLI 4 | //! app, see [the README](https://github.com/LukasKalbertodt/penguin#readme). 5 | //! 6 | //! This library essentially allows you to configure and then start an HTTP 7 | //! server. After starting the server you get a [`Controller`] which allows you 8 | //! to send commands to active browser sessions, like reloading the page or 9 | //! showing a message. 10 | //! 11 | //! 12 | //! # Quick start 13 | //! 14 | //! This should get you started as it shows almost everything this library has 15 | //! to offer: 16 | //! 17 | //! ```no_run 18 | //! use std::{path::Path, time::Duration}; 19 | //! use penguin::Server; 20 | //! 21 | //! #[tokio::main] 22 | //! async fn main() -> Result<(), Box> { 23 | //! // Configure the server. 24 | //! let (server, controller) = Server::bind(([127, 0, 0, 1], 4090).into()) 25 | //! .proxy("localhost:8000".parse()?) 26 | //! .add_mount("/assets", Path::new("./frontend/build"))? 27 | //! .build()?; 28 | //! 29 | //! // In some other task, you can control the browser sessions. This dummy 30 | //! // code just waits 5 seconds and then reloads all sessions. 31 | //! tokio::spawn(async move { 32 | //! tokio::time::sleep(Duration::from_secs(5)).await; 33 | //! controller.reload(); 34 | //! }); 35 | //! 36 | //! server.await?; 37 | //! 38 | //! Ok(()) 39 | //! } 40 | //! ``` 41 | //! 42 | //! # Routing 43 | //! 44 | //! Incoming requests are routed like this (from highest to lowest priority): 45 | //! 46 | //! - Requests to the control path (`/~~penguin` by default) are internally 47 | //! handled. This is used for establishing WS connections and to receive 48 | //! commands. 49 | //! - Requests with a path matching one of the mounts is served from that 50 | //! directory. 51 | //! - The most specific mount (i.e. the one with the longest URI path) is 52 | //! used. Consider there are two mounts: `/cat` -> `./foo` and `/cat/paw` 53 | //! -> `./bar`. Then a request to `/cat/paw/info.json` is replied to with 54 | //! `./bar/info.json` while a request to `/cat/style.css` is replied to 55 | //! with `./foo/style.css` 56 | //! - If a proxy is configured, then all remaining requests are forwarded to it 57 | //! and its reply is forwarded back to the initiator of the request. Otherwise 58 | //! (no proxy configured), all remaining requests are answered with 404. 59 | //! 60 | //! 61 | 62 | #![deny(missing_debug_implementations)] 63 | 64 | use std::{fmt, future::Future, net::SocketAddr, pin::Pin, task}; 65 | 66 | use tokio::sync::broadcast::{self, Sender}; 67 | 68 | mod config; 69 | mod inject; 70 | mod serve; 71 | pub mod util; 72 | mod ws; 73 | 74 | #[cfg(test)] 75 | mod tests; 76 | 77 | /// Reexport of `hyper` dependency (which includes `http`). 78 | pub extern crate hyper; 79 | 80 | pub use config::{ 81 | Builder, Config, ConfigError, DEFAULT_CONTROL_PATH, Mount, ProxyTarget, ProxyTargetParseError 82 | }; 83 | 84 | /// Penguin server: the main type of this library. 85 | /// 86 | /// This type implements `Future`, and can thus be `await`ed. If you do not 87 | /// `await` (or otherwise poll) this, the server will not start serving. 88 | #[must_use = "futures do nothing unless you `.await` or poll them"] 89 | pub struct Server { 90 | // TODO: maybe avoid boxing this if possible? 91 | future: Pin>>>, 92 | } 93 | 94 | impl Server { 95 | /// Returns a builder to configure the server with the bind address of the 96 | /// server being set to `addr`. 97 | pub fn bind(addr: SocketAddr) -> Builder { 98 | Builder::new(addr) 99 | } 100 | 101 | /// Builds a server and a controller from a configuration. Most of the time 102 | /// you can use [`Builder::build`] instead of this method. 103 | pub fn build(config: Config) -> (Self, Controller) { 104 | let (sender, _) = broadcast::channel(ACTION_CHANNEL_SIZE); 105 | let controller = Controller(sender.clone()); 106 | let future = Box::pin(serve::run(config, sender)); 107 | 108 | (Self { future }, controller) 109 | } 110 | } 111 | 112 | impl Future for Server { 113 | type Output = Result<(), hyper::Error>; 114 | 115 | fn poll(mut self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> task::Poll { 116 | self.future.as_mut().poll(cx) 117 | } 118 | } 119 | 120 | impl fmt::Debug for Server { 121 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 122 | f.pad("Server(_)") 123 | } 124 | } 125 | 126 | const ACTION_CHANNEL_SIZE: usize = 64; 127 | 128 | /// A handle to send commands to the server. 129 | #[derive(Debug, Clone)] 130 | pub struct Controller(Sender); 131 | 132 | impl Controller { 133 | /// Reloads all active browser sessions. 134 | pub fn reload(&self) { 135 | let _ = self.0.send(Action::Reload); 136 | } 137 | 138 | /// Shows a message as overlay in all active browser sessions. The given 139 | /// string will be copied into the `innerHTML` of a `
` verbatim. 140 | /// 141 | /// This call will overwrite/hide all previous messages. 142 | pub fn show_message(&self, msg: impl Into) { 143 | let _ = self.0.send(Action::Message(msg.into())); 144 | } 145 | } 146 | 147 | #[derive(Debug, Clone)] 148 | enum Action { 149 | Reload, 150 | Message(String), 151 | } 152 | -------------------------------------------------------------------------------- /lib/src/serve/fs.rs: -------------------------------------------------------------------------------- 1 | use std::{io::{self, ErrorKind}, path::Path}; 2 | 3 | use http_range::{HttpRange, HttpRangeParseError}; 4 | use hyper::{Body, Request, Response, header, StatusCode}; 5 | use tokio::{fs, io::{AsyncSeekExt, AsyncReadExt}}; 6 | use tokio_util::codec::{FramedRead, BytesCodec}; 7 | 8 | use crate::{inject, Config}; 9 | use super::{bad_request, not_found, SERVER_HEADER}; 10 | 11 | 12 | /// Checks if the request matches any `config.mounts` and returns an 13 | /// appropriate response in that case. Otherwise `Ok(None)` is returned. 14 | pub(crate) async fn try_serve( 15 | req: &Request, 16 | config: &Config, 17 | ) -> Option> { 18 | let (subpath, mount) = config.mounts.iter() 19 | .filter_map(|mount| { 20 | req.uri() 21 | .path() 22 | .strip_prefix(&mount.uri_path) 23 | .map(|subpath| { 24 | // Make sure that subpath never starts with `/`. 25 | (subpath.trim_start_matches('/').to_owned(), mount) 26 | }) 27 | }) 28 | 29 | // We want the "most specific" mount, so the longest URI path wins. 30 | .max_by_key(|(_, mount)| mount.uri_path.len())?; 31 | 32 | Some(serve(req, &subpath, &mount.fs_path, config).await) 33 | } 34 | 35 | async fn serve( 36 | req: &Request, 37 | subpath: &str, 38 | fs_root: &Path, 39 | config: &Config, 40 | ) -> Response { 41 | log::trace!("Serving request from file server..."); 42 | 43 | let subpath = Path::new(subpath); 44 | let path = fs_root.join(subpath); 45 | 46 | // Protect against directory traversal attacks. 47 | macro_rules! canonicalize { 48 | ($path:expr) => { 49 | match fs::canonicalize($path).await { 50 | Ok(v) => v, 51 | Err(e) if e.kind() == ErrorKind::NotFound => return not_found(config), 52 | Err(e) => panic!( 53 | "unhandled error: could not canonicalize path '{}': {}", 54 | $path.display(), 55 | e, 56 | ), 57 | } 58 | }; 59 | } 60 | 61 | let canonical_req = canonicalize!(&path); 62 | let canonical_root = canonicalize!(fs_root); 63 | if !canonical_req.starts_with(canonical_root) { 64 | log::warn!( 65 | "Directory traversal attack detected ({:?} {}) -> responding BAD REQUEST", 66 | req.method(), 67 | req.uri().path(), 68 | ); 69 | 70 | return bad_request("Bad request: requested file outside of served directory\n"); 71 | } 72 | 73 | // Dispatch depending on whether it's a file or directory. 74 | if !path.exists() { 75 | not_found(config) 76 | } else if path.is_file() { 77 | log::trace!("Serving requested file"); 78 | serve_file(&path, req, config).await 79 | } else if path.join("index.html").is_file() { 80 | log::trace!("Serving 'index.html' file in requested directory"); 81 | serve_file(&path.join("index.html"), req, config).await 82 | } else { 83 | log::trace!("Listing contents of directory..."); 84 | serve_dir(req.uri().path(), &path, config) 85 | .await 86 | .expect("failed to read directory contents due to IO error") 87 | } 88 | } 89 | 90 | /// Lists the contents of a directory. 91 | async fn serve_dir( 92 | uri_path: &str, 93 | path: &Path, 94 | config: &Config, 95 | ) -> Result, io::Error> { 96 | const DIR_LISTING_HTML: &str = include_str!("../assets/dir-listing.html"); 97 | 98 | // Collect all children of this folder. 99 | let mut folders = Vec::new(); 100 | let mut files = Vec::new(); 101 | let mut it = fs::read_dir(path).await?; 102 | while let Some(entry) = it.next_entry().await? { 103 | let name = entry.file_name().to_string_lossy().into_owned(); 104 | if entry.file_type().await?.is_file() { 105 | files.push((name, false)); 106 | } else { 107 | folders.push((name + "/", false)); 108 | } 109 | } 110 | 111 | // Also collect all mounts that are mounted below this path. 112 | for sd in config.mounts.iter().filter(|sd| sd.fs_path.exists()) { 113 | if let Some(rest) = sd.uri_path.strip_prefix(uri_path) { 114 | if rest.is_empty() { 115 | continue; 116 | } 117 | 118 | let name = rest.find('/') 119 | .map(|pos| &rest[..pos]) 120 | .unwrap_or(rest) 121 | .to_owned(); 122 | if sd.fs_path.is_dir() { 123 | folders.push((name + "/", true)); 124 | } else { 125 | files.push((name, true)); 126 | } 127 | } 128 | } 129 | 130 | folders.sort(); 131 | files.sort(); 132 | 133 | // Build list of children. 134 | let mut entries = String::from("\n"); 135 | for (name, is_mount) in folders.into_iter().chain(files) { 136 | entries.push_str(&format!( 137 | "
  • {0}
  • \n", 138 | name, 139 | if is_mount { "mount" } else { "real" }, 140 | )); 141 | } 142 | 143 | let html = DIR_LISTING_HTML 144 | .replace("{{ uri_path }}", uri_path) 145 | .replace("{{ entries }}", &entries) 146 | .replace("{{ control_path }}", config.control_path()); 147 | 148 | Ok( 149 | Response::builder() 150 | .header("Content-Type", "text/html; charset=utf-8") 151 | .header("Server", SERVER_HEADER) 152 | .body(html.into()) 153 | .expect("bug: invalid response") 154 | ) 155 | } 156 | 157 | /// Serves a single file. If it's a HTML file, our JS code is injected. 158 | async fn serve_file( 159 | path: &Path, 160 | req: &Request, 161 | config: &Config, 162 | ) -> Response { 163 | // TODO: maybe we should return 403 if the file can't be read due to 164 | // permissions? Generally, the `unwrap`s in this function are... meh. 165 | 166 | let mime = mime_guess::from_path(&path).first(); 167 | if mime.as_ref().map_or(false, |mime| mime.as_ref().starts_with("text/html")) { 168 | let raw = fs::read(path).await.expect("failed to read file"); 169 | let html = inject::into(&raw, &config); 170 | 171 | Response::builder() 172 | .header("Content-Type", "text/html") 173 | .header("Content-Length", html.len().to_string()) 174 | .header("Server", SERVER_HEADER) 175 | .body(html.into()) 176 | .expect("bug: invalid response") 177 | } else { 178 | let mut file = fs::File::open(path).await.expect("failed to open file"); 179 | let file_size = file.metadata().await.expect("failed to read file metadata").len(); 180 | 181 | let mut response = Response::builder() 182 | .header("Server", SERVER_HEADER) 183 | .header(header::ACCEPT_RANGES, "bytes"); 184 | if let Some(mime) = mime { 185 | response = response.header("Content-Type", mime.to_string()); 186 | } 187 | 188 | if let Some(range_header) = req.headers().get(header::RANGE) { 189 | let range = match HttpRange::parse_bytes(range_header.as_bytes(), file_size) { 190 | Ok(ranges) if ranges.len() == 1 => ranges[0], 191 | Ok(_) => { 192 | return Response::builder() 193 | .status(StatusCode::BAD_REQUEST) 194 | .header("Server", SERVER_HEADER) 195 | .body("multiple ranges in 'Range' header not supported".into()) 196 | .expect("bug: invalid response") 197 | } 198 | Err(HttpRangeParseError::InvalidRange) => todo!(), 199 | Err(HttpRangeParseError::NoOverlap) => { 200 | return Response::builder() 201 | .status(StatusCode::RANGE_NOT_SATISFIABLE) 202 | .header("Server", SERVER_HEADER) 203 | .body("".into()) 204 | .expect("bug: invalid response"); 205 | } 206 | }; 207 | 208 | file.seek(io::SeekFrom::Start(range.start)).await.unwrap(); 209 | let reader = FramedRead::new(file.take(range.length), BytesCodec::new()); 210 | let body = Body::wrap_stream(reader); 211 | response 212 | .status(StatusCode::PARTIAL_CONTENT) 213 | .header(header::CONTENT_LENGTH, range.length) 214 | .header(header::CONTENT_RANGE, format!( 215 | "bytes {}-{}/{}", 216 | range.start, 217 | range.start + range.length, 218 | file_size, 219 | )) 220 | .body(body) 221 | .expect("bug: invalid response") 222 | } else { 223 | let body = Body::wrap_stream(FramedRead::new(file, BytesCodec::new())); 224 | response 225 | .header(header::CONTENT_LENGTH, file_size) 226 | .body(body) 227 | .expect("bug: invalid response") 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /lib/src/serve/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{convert::Infallible, future::Future, panic::AssertUnwindSafe, sync::Arc}; 2 | 3 | use futures::FutureExt; 4 | use hyper::{ 5 | Body, Method, Request, Response, Server, StatusCode, 6 | http::uri::PathAndQuery, 7 | service::{make_service_fn, service_fn}, 8 | }; 9 | use tokio::sync::broadcast::Sender; 10 | 11 | use crate::serve::proxy::ProxyContext; 12 | use super::{Action, Config}; 13 | 14 | mod fs; 15 | mod proxy; 16 | 17 | 18 | pub(crate) async fn run(config: Config, actions: Sender) -> Result<(), hyper::Error> { 19 | let addr = config.bind_addr; 20 | 21 | let ctx = Arc::new(Context { 22 | config, 23 | proxy: ProxyContext::new(), 24 | }); 25 | let make_service = make_service_fn(move |_| { 26 | let ctx = Arc::clone(&ctx); 27 | let actions = actions.clone(); 28 | 29 | async { 30 | Ok::<_, Infallible>(service_fn(move |req| { 31 | handle_internal_errors( 32 | handle(req, Arc::clone(&ctx), actions.clone()) 33 | ) 34 | })) 35 | } 36 | }); 37 | 38 | log::info!("Creating hyper server"); 39 | let server = Server::try_bind(&addr)?.serve(make_service); 40 | 41 | log::info!("Start listening with hyper server"); 42 | server.await?; 43 | 44 | Ok(()) 45 | } 46 | 47 | async fn handle_internal_errors( 48 | future: impl Future>, 49 | ) -> Result, Infallible> { 50 | fn internal_server_error(msg: &str) -> Response { 51 | let body = format!("Internal server error: this is a bug in Penguin!\n\n{}\n", msg); 52 | Response::builder() 53 | .status(StatusCode::INTERNAL_SERVER_ERROR) 54 | .header("Server", SERVER_HEADER) 55 | .body(body.into()) 56 | .unwrap() 57 | } 58 | 59 | // The `AssertUnwindSafe` is unfortunately necessary. The whole story of 60 | // unwind safety is strange. What we are basically saying here is: "if the 61 | // future panicks, the global/remaining application state is not 'broken'. 62 | // It is safe to continue with the program in case of a panic." 63 | match AssertUnwindSafe(future).catch_unwind().await { 64 | Ok(response) => Ok(response), 65 | Err(panic) => { 66 | // The `panic` information is just an `Any` object representing the 67 | // value the panic was invoked with. For most panics (which use 68 | // `panic!` like `println!`), this is either `&str` or `String`. 69 | let msg = panic.downcast_ref::() 70 | .map(|s| s.as_str()) 71 | .or(panic.downcast_ref::<&str>().map(|s| *s)); 72 | 73 | log::error!("HTTP handler panicked: {}", msg.unwrap_or("-")); 74 | 75 | Ok(internal_server_error(msg.unwrap_or("panic"))) 76 | } 77 | } 78 | } 79 | 80 | pub(crate) struct Context { 81 | config: Config, 82 | proxy: ProxyContext, 83 | } 84 | 85 | /// Handles a single incoming request. 86 | async fn handle( 87 | req: Request, 88 | ctx: Arc, 89 | actions: Sender, 90 | ) -> Response { 91 | log::trace!( 92 | "Incoming request: {:?} {}", 93 | req.method(), 94 | req.uri().path_and_query().unwrap_or(&PathAndQuery::from_static("/")), 95 | ); 96 | 97 | if req.uri().path().starts_with(&ctx.config.control_path) { 98 | handle_control(req, &ctx.config, actions).await 99 | } else if let Some(response) = fs::try_serve(&req, &ctx.config).await { 100 | response 101 | } else if let Some(proxy) = &ctx.config.proxy { 102 | proxy::forward(req, proxy, &ctx, actions).await 103 | } else { 104 | not_found(&ctx.config) 105 | } 106 | } 107 | 108 | /// Handles "control requests", i.e. request to the control path. 109 | async fn handle_control( 110 | req: Request, 111 | config: &Config, 112 | actions: Sender, 113 | ) -> Response { 114 | log::trace!("Handling request to HTTP control API..."); 115 | 116 | if hyper_tungstenite::is_upgrade_request(&req) { 117 | log::trace!("Handling WS upgrade request..."); 118 | match hyper_tungstenite::upgrade(req, None) { 119 | Ok((response, websocket)) => { 120 | // Spawn a task to handle the websocket connection. 121 | let receiver = actions.subscribe(); 122 | tokio::spawn(crate::ws::handle_connection(websocket, receiver)); 123 | 124 | // Return the response so the spawned future can continue. 125 | response 126 | } 127 | Err(_) => { 128 | log::warn!("Invalid WS upgrade request"); 129 | bad_request("Failed to upgrade to WS connection\n") 130 | } 131 | } 132 | } else { 133 | let subpath = req.uri().path().strip_prefix(&config.control_path).unwrap(); 134 | match (req.method(), subpath) { 135 | (&Method::GET, "/client.js") => { 136 | Response::builder() 137 | .header("Content-Type", "application/javascript; charset=UTF-8") 138 | .body(Body::from(crate::inject::script(config))) 139 | .unwrap() 140 | } 141 | 142 | (&Method::POST, "/reload") => { 143 | // We ignore errors here: if there are no receivers, so be it. 144 | // Although we might want to include the number of receivers in 145 | // the event. 146 | log::debug!("Received reload request via HTTP control API"); 147 | let _ = actions.send(Action::Reload); 148 | 149 | Response::new(Body::empty()) 150 | } 151 | 152 | (&Method::POST, "/message") => { 153 | let (_, body) = req.into_parts(); 154 | let body = hyper::body::to_bytes(body) 155 | .await 156 | .expect("failed to download message body"); 157 | 158 | match std::str::from_utf8(&body) { 159 | Err(_) => bad_request("Bad request: request body is not UTF8\n"), 160 | Ok(s) => { 161 | // We ignore errors here: if there are no receivers, so be it. 162 | // Although we might want to include the number of receivers in 163 | // the event. 164 | log::debug!("Received message request via HTTP control API"); 165 | let _ = actions.send(Action::Message(s.into())); 166 | 167 | Response::new(Body::empty()) 168 | } 169 | } 170 | } 171 | 172 | _ => bad_request("Invalid request to libpenguin control path\n"), 173 | } 174 | } 175 | } 176 | 177 | fn bad_request(msg: &'static str) -> Response { 178 | log::debug!("Replying BAD REQUEST: {}", msg); 179 | 180 | Response::builder() 181 | .status(StatusCode::BAD_REQUEST) 182 | .header("Server", SERVER_HEADER) 183 | .body(msg.into()) 184 | .expect("bug: invalid response") 185 | } 186 | 187 | fn not_found(config: &Config) -> Response { 188 | const NOT_FOUND_HTML: &str = include_str!("../assets/not-found.html"); 189 | 190 | log::debug!("Responding with 404 NOT FOUND"); 191 | let html = NOT_FOUND_HTML.replace("{{ control_path }}", config.control_path()); 192 | 193 | Response::builder() 194 | .status(StatusCode::NOT_FOUND) 195 | .header("Content-Type", "text/html") 196 | .header("Content-Length", html.len().to_string()) 197 | .header("Server", SERVER_HEADER) 198 | .body(html.into()) 199 | .expect("bug: invalid response") 200 | } 201 | 202 | const SERVER_HEADER: &str = concat!("Penguin v", env!("CARGO_PKG_VERSION")); 203 | -------------------------------------------------------------------------------- /lib/src/serve/proxy.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::min, 3 | collections::HashSet, 4 | convert::{TryFrom, TryInto}, 5 | io::Read, 6 | sync::{Arc, Mutex, OnceLock, atomic::{AtomicBool, Ordering}}, 7 | time::Duration, 8 | }; 9 | 10 | use futures::StreamExt; 11 | use hyper::{ 12 | Body, Client, Request, Response, StatusCode, Uri, 13 | body::{Bytes, HttpBody}, 14 | header::{self, HeaderValue}, 15 | http::uri::Scheme, 16 | }; 17 | use hyper_tls::HttpsConnector; 18 | use tokio::sync::broadcast::Sender; 19 | 20 | use crate::{Action, Config, ProxyTarget, inject}; 21 | 22 | use super::{Context, SERVER_HEADER}; 23 | 24 | 25 | /// HTML content to reply in case an error occurs when connecting to the proxy. 26 | const PROXY_ERROR_HTML: &str = include_str!("../assets/proxy-error.html"); 27 | 28 | pub(crate) struct ProxyContext { 29 | is_polling_target: Arc, 30 | } 31 | 32 | impl ProxyContext { 33 | pub(crate) fn new() -> Self { 34 | Self { 35 | is_polling_target: Arc::new(AtomicBool::new(false)), 36 | } 37 | } 38 | } 39 | 40 | /// Forwards the given request to the specified proxy target and returns its 41 | /// response. 42 | /// 43 | /// If the proxy target cannot be reached, a 502 Bad Gateway or 504 Gateway 44 | /// Timeout response is returned. 45 | pub(crate) async fn forward( 46 | mut req: Request, 47 | target: &ProxyTarget, 48 | ctx: &Context, 49 | actions: Sender, 50 | ) -> Response { 51 | adjust_request(&mut req, target); 52 | let uri = req.uri().clone(); 53 | 54 | log::trace!("Forwarding request to proxy target {}", uri); 55 | let client = Client::builder().build::<_, hyper::Body>(HttpsConnector::new()); 56 | match client.request(req).await { 57 | Ok(response) => adjust_response(response, ctx, &uri, target, &ctx.config).await, 58 | Err(e) => { 59 | log::warn!("Failed to reach proxy target '{}': {}", uri, e); 60 | let msg = format!("Failed to reach {}\n\n{}", uri, e); 61 | start_polling(&ctx.proxy, target, actions); 62 | gateway_error(&msg, e, &ctx.config) 63 | } 64 | } 65 | } 66 | 67 | fn adjust_request(req: &mut Request, target: &ProxyTarget) { 68 | // Change the URI to the proxy target. 69 | let uri = { 70 | let mut parts = req.uri().clone().into_parts(); 71 | parts.scheme = Some(target.scheme.clone()); 72 | parts.authority = Some(target.authority.clone()); 73 | Uri::from_parts(parts).expect("bug: invalid URI") 74 | }; 75 | *req.uri_mut() = uri.clone(); 76 | 77 | // If the `host` header is set, we need to adjust it, too. 78 | if let Some(host) = req.headers_mut().get_mut(header::HOST) { 79 | // `http::Uri` already does not parse non-ASCII hosts. Unicode hosts 80 | // have to be encoded as punycode. 81 | *host = HeaderValue::from_str(target.authority.as_str()) 82 | .expect("bug: URI authority should be ASCII"); 83 | } 84 | 85 | // Deal with compression. 86 | if let Some(header) = req.headers_mut().get_mut(header::ACCEPT_ENCODING) { 87 | // In a production product, panicking here is not OK. But all encodings 88 | // listed in [1] and the syntax described in [2] only contain ASCII 89 | // bytes. So non-ASCII bytes here are highly unlikely. 90 | // 91 | // [1]: https://www.iana.org/assignments/http-parameters/http-parameters.xml 92 | // [2]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding 93 | let value = header.to_str() 94 | .expect("'accept-encoding' header value contains non-ASCII bytes"); 95 | let new_value = filter_encodings(&value); 96 | 97 | if new_value.is_empty() { 98 | req.headers_mut().remove(header::ACCEPT_ENCODING); 99 | } else { 100 | // It was ASCII before and we do not add any non-ASCII values. 101 | *header = HeaderValue::try_from(new_value) 102 | .expect("bug: non-ASCII values in new 'accept-encoding' header"); 103 | } 104 | } 105 | } 106 | 107 | /// We support only gzip and brotli. But according to this statistics, those two 108 | /// make up the vast majority of requests: 109 | /// https://almanac.httparchive.org/en/2019/compression 110 | const SUPPORTED_COMPRESSIONS: &[&str] = &["gzip", "br", "identity"]; 111 | 112 | fn download_body_error(e: hyper::Error, uri: &Uri, ctx: &Context) -> Response { 113 | log::warn!("Failed to download full response from proxy target"); 114 | let msg = format!("Failed to download response from {}\n\n{}", uri, e); 115 | return gateway_error(&msg, e, &ctx.config); 116 | } 117 | 118 | async fn adjust_response( 119 | mut response: Response, 120 | ctx: &Context, 121 | uri: &Uri, 122 | target: &ProxyTarget, 123 | config: &Config, 124 | ) -> Response { 125 | // Rewrite `location` header if it's present. 126 | if let Some(header) = response.headers_mut().get_mut(header::LOCATION) { 127 | rewrite_location(header, target, config); 128 | } 129 | 130 | // Download the beginning of the body for sniffing. 131 | let (mut parts, mut body) = response.into_parts(); 132 | let mut body_start = vec![]; 133 | while !body.is_end_stream() && body_start.len() < 512 { 134 | match body.data().await { 135 | None => break, 136 | Some(Err(e)) => return download_body_error(e, uri, ctx), 137 | Some(Ok(bytes)) => body_start.extend_from_slice(&bytes), 138 | } 139 | } 140 | 141 | let html_content_type = parts.headers.get(header::CONTENT_TYPE).map(|v| { 142 | v.as_bytes().starts_with(b"text/html") 143 | || v.as_bytes().starts_with(b"application/xhtml+xml") 144 | }); 145 | let looks_like_html = body_start.iter().all(|b| *b != 0) 146 | && infer::text::is_html(&body_start); 147 | 148 | let uri_pq = uri.path_and_query().map(|pq| pq.to_string()).unwrap_or_default(); 149 | macro_rules! warn_once { 150 | ($($t:tt)*) => { 151 | static ALREADY_WARNED: OnceLock>> = OnceLock::new(); 152 | let newly_inserted = ALREADY_WARNED 153 | .get_or_init(|| Mutex::new(HashSet::new())) 154 | .lock() 155 | .unwrap() 156 | .insert(uri_pq.clone()); 157 | if newly_inserted { 158 | log::warn!($($t)*); 159 | } 160 | }; 161 | } 162 | 163 | // Determine if we should treat this as HTML (i.e. inject our script). 164 | let adjust_body = match (html_content_type, looks_like_html) { 165 | (None, true) => { 166 | warn_once!("Proxy response to '{uri_pq}' looks like HTML, but no 'Content-Type' \ 167 | header exists. I will treat it as HTML (injecting reload script), but setting \ 168 | the correct 'Content-Type' header is recommended.", 169 | ); 170 | true 171 | } 172 | (None, false) => false, 173 | (Some(true), true) => true, 174 | (Some(false), true) => { 175 | let header_bytes = parts.headers.get(header::CONTENT_TYPE).unwrap().as_bytes(); 176 | warn_once!("Proxy response to '{uri_pq}' looks like HTML, but the 'Content-Type' \ 177 | header indicates otherwise: '{}'. Not injecting reload script.", 178 | String::from_utf8_lossy(header_bytes), 179 | ); 180 | false 181 | } 182 | (Some(v), false) => v, 183 | }; 184 | 185 | if !adjust_body { 186 | let recombined_body = Body::wrap_stream( 187 | futures::stream::once(async { Ok(Bytes::from(body_start)) }).chain(body) 188 | ); 189 | 190 | return Response::from_parts(parts, recombined_body); 191 | } 192 | 193 | 194 | log::trace!("Response from proxy is HTML: injecting script"); 195 | 196 | // The response is HTML: we need to download it completely and 197 | // inject our script. 198 | while let Some(buf) = body.data().await { 199 | match buf { 200 | Ok(buf) => body_start.extend_from_slice(&buf), 201 | Err(e) => return download_body_error(e, uri, ctx), 202 | } 203 | } 204 | let body = body_start; 205 | 206 | // Uncompress if necessary. All this allocates more than necessary, but I'd 207 | // rather keep easier code in this case, as performance is unlikely to 208 | // matter. 209 | let new_body = match parts.headers.get(header::CONTENT_ENCODING).map(|v| v.as_bytes()) { 210 | None => Bytes::from(inject::into(&body, &ctx.config)), 211 | 212 | Some(b"gzip") => { 213 | let mut decompressed = Vec::new(); 214 | flate2::read::GzDecoder::new(&*body).read_to_end(&mut decompressed) 215 | .expect("unexpected error while decompressing GZIP"); 216 | let injected = inject::into(&decompressed, &ctx.config); 217 | let mut out = Vec::new(); 218 | flate2::read::GzEncoder::new(&*injected, flate2::Compression::best()) 219 | .read_to_end(&mut out) 220 | .expect("unexpected error while compressing GZIP"); 221 | Bytes::from(out) 222 | } 223 | 224 | Some(b"br") => { 225 | let mut decompressed = Vec::new(); 226 | brotli::BrotliDecompress(&mut &*body, &mut decompressed) 227 | .expect("unexpected error while decompressing Brotli"); 228 | let injected = inject::into(&decompressed, &ctx.config); 229 | let mut out = Vec::new(); 230 | brotli::BrotliCompress(&mut &*injected, &mut out, &Default::default()) 231 | .expect("unexpected error while compressing Brotli"); 232 | Bytes::from(out) 233 | } 234 | 235 | Some(other) => { 236 | log::warn!( 237 | "Unsupported content encoding '{}'. Not injecting script!", 238 | String::from_utf8_lossy(other), 239 | ); 240 | Bytes::from(body) 241 | } 242 | }; 243 | 244 | if let Some(content_len) = parts.headers.get_mut(header::CONTENT_LENGTH) { 245 | *content_len = new_body.len().into(); 246 | } 247 | 248 | // We might need to adjust `Content-Security-Policy` to allow including 249 | // scripts from `self`. This is most likely already the case, but we have 250 | // to make sure. If the header appears multiple times, all header values 251 | // need to allow a thing for it to be allowed. Thus we can just modify all 252 | // headers independently from one another. 253 | if let header::Entry::Occupied(mut e) = parts.headers.entry(header::CONTENT_SECURITY_POLICY) { 254 | e.iter_mut().for_each(rewrite_csp); 255 | } 256 | 257 | 258 | Response::from_parts(parts, new_body.into()) 259 | } 260 | 261 | /// We inject our own JS that connects via WS to the penguin server. These two 262 | /// things need to be allowed by the Content-Security-Policy. Usually they are, 263 | /// but in some cases we need to modify that header to allow for it. 264 | /// Unfortunately, it's a bit involved, but also fairly straight forward. 265 | fn rewrite_csp(header: &mut HeaderValue) { 266 | use std::collections::{BTreeMap, btree_map::Entry}; 267 | 268 | // We have to parse the CSP. Compare section "2.2.1. Parse a serialized CSP" 269 | // of TR CSP3: https://www.w3.org/TR/CSP3/#parse-serialized-policy 270 | let mut directives = BTreeMap::new(); 271 | header.as_bytes() 272 | // "strictly splitting on the U+003B SEMICOLON character (;)" 273 | .split(|b| *b == b';') 274 | // "If token is an empty string, or if token is not an ASCII string, continue." 275 | .filter(|part| !part.is_empty()) 276 | .filter_map(|part| std::str::from_utf8(part).ok()) 277 | .for_each(|part| { 278 | // "Strip leading and trailing ASCII whitespace" and then splitting 279 | // by whitespace to separate the directive name and all directive 280 | // values. 281 | let mut split = part.trim().split_whitespace(); 282 | let name = split.next() 283 | .expect("empty split iterator for non-empty string") 284 | .to_ascii_lowercase(); 285 | 286 | match directives.entry(name) { 287 | // "If policy’s directive set contains a directive whose name is 288 | // directive name, continue. Note: In this case, the user 289 | // agent SHOULD notify developers that a duplicate directive 290 | // was ignored. A console warning might be appropriate, for 291 | // example." 292 | Entry::Occupied(entry) => { 293 | log::warn!("CSP malformed, second {} directive ignored", entry.key()); 294 | } 295 | 296 | // "Append directive to policy’s directive set." 297 | Entry::Vacant(entry) => { 298 | entry.insert(split.collect::>()); 299 | } 300 | } 301 | }); 302 | 303 | 304 | // Of course, including the script/connect to self might still be allowed 305 | // via other sources, like `http:`. But it also doesn't hurt to add `self` 306 | // in those cases. 307 | let scripts_from_self_allowed = directives.get("script-src") 308 | .or_else(|| directives.get("default-src")) 309 | .map_or(true, |v| v.contains(&"'self'") || v.contains(&"*")); 310 | 311 | let connect_to_self_allowed = directives.get("connect-src") 312 | .or_else(|| directives.get("default-src")) 313 | .map_or(true, |v| v.contains(&"'self'") || v.contains(&"*")); 314 | 315 | 316 | if scripts_from_self_allowed && connect_to_self_allowed { 317 | log::trace!("CSP header already allows scripts from and connect to 'self', not modifying"); 318 | return; 319 | } 320 | 321 | // Add `self` to `script-src`/`connect-src`. 322 | if !scripts_from_self_allowed { 323 | let script_sources = directives.entry("script-src".to_owned()).or_default(); 324 | script_sources.retain(|src| *src != "'none'"); 325 | script_sources.push("'self'"); 326 | } 327 | if !connect_to_self_allowed { 328 | let script_sources = directives.entry("connect-src".to_owned()).or_default(); 329 | script_sources.retain(|src| *src != "'none'"); 330 | script_sources.push("'self'"); 331 | } 332 | 333 | // Serialize parsed CSP into header value again. 334 | let mut out = String::new(); 335 | for (name, values) in directives { 336 | use std::fmt::Write; 337 | 338 | out.push_str(&name); 339 | values.iter().for_each(|v| write!(out, " {v}").unwrap()); 340 | out.push_str("; "); 341 | } 342 | 343 | // Above, we ignored all non-ASCII entries, so there shouldn't be a way our 344 | // resulting string is non-ASCII. 345 | log::trace!("Modified CSP header \nfrom {header:?} \nto \"{out}\""); 346 | *header = HeaderValue::from_str(&out) 347 | .expect("modified CSP header has non-ASCII chars"); 348 | } 349 | 350 | fn rewrite_location(header: &mut HeaderValue, target: &ProxyTarget, config: &Config) { 351 | let value = match std::str::from_utf8(header.as_bytes()) { 352 | Err(_) => { 353 | log::warn!("Non UTF-8 'location' header: not rewriting"); 354 | return; 355 | } 356 | Ok(v) => v, 357 | }; 358 | 359 | let mut uri = match value.parse::() { 360 | Err(_) => { 361 | log::warn!("Could not parse 'location' header as URI: not rewriting"); 362 | return; 363 | } 364 | Ok(uri) => uri.into_parts(), 365 | }; 366 | 367 | // If the redirect points to the proxy target itself (i.e. an internal 368 | // redirect), we change the `location` header so that the browser changes 369 | // the path & query, but stays on the Penguin host. 370 | if uri.authority.as_ref() == Some(&target.authority) { 371 | // Penguin itself only listens on HTTP 372 | uri.scheme = Some(Scheme::HTTP); 373 | let authority = config.bind_addr.to_string() 374 | .try_into() 375 | .expect("bind addr is not a valid authority"); 376 | uri.authority = Some(authority); 377 | 378 | let uri = Uri::from_parts(uri).expect("bug: failed to build URI"); 379 | *header = HeaderValue::from_bytes(uri.to_string().as_bytes()) 380 | .expect("bug: new 'location' is invalid header value"); 381 | } 382 | } 383 | 384 | fn gateway_error(msg: &str, e: hyper::Error, config: &Config) -> Response { 385 | let html = PROXY_ERROR_HTML 386 | .replace("{{ error }}", msg) 387 | .replace("{{ control_path }}", config.control_path()); 388 | 389 | let status = if e.is_timeout() { 390 | StatusCode::GATEWAY_TIMEOUT 391 | } else { 392 | StatusCode::BAD_GATEWAY 393 | }; 394 | 395 | Response::builder() 396 | .status(status) 397 | .header("Server", SERVER_HEADER) 398 | .header("Content-Type", "text/html") 399 | .body(html.into()) 400 | .unwrap() 401 | } 402 | 403 | /// Regularly polls the proxy target until it is reachable again. Once it is, it 404 | /// sends a reload action and stops. Makes sure (via `ctx`) that just one 405 | /// polling instance exists per penguin server. 406 | fn start_polling(ctx: &ProxyContext, target: &ProxyTarget, actions: Sender) { 407 | // We only need one task polling the target. 408 | let is_polling = Arc::clone(&ctx.is_polling_target); 409 | if is_polling.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { 410 | return; 411 | } 412 | 413 | let client = Client::builder().build::<_, hyper::Body>(HttpsConnector::new()); 414 | let uri = Uri::builder() 415 | .scheme(target.scheme.clone()) 416 | .authority(target.authority.clone()) 417 | .path_and_query("/") 418 | .build() 419 | .unwrap(); 420 | 421 | log::info!("Start regularly polling '{}' until it is available...", uri); 422 | tokio::spawn(async move { 423 | // We start polling quite quickly, but slow down up to this constant. 424 | const MAX_SLEEP_DURATION: Duration = Duration::from_secs(3); 425 | let mut sleep_duration = Duration::from_millis(250); 426 | 427 | loop { 428 | tokio::time::sleep(sleep_duration).await; 429 | sleep_duration = min(sleep_duration.mul_f32(1.5), MAX_SLEEP_DURATION); 430 | 431 | log::trace!("Trying to connect to '{}' again", uri); 432 | if client.get(uri.clone()).await.is_ok() { 433 | log::debug!("Reconnected to proxy target, reloading all active browser sessions"); 434 | let _ = actions.send(Action::Reload); 435 | is_polling.store(false, Ordering::SeqCst); 436 | break; 437 | } 438 | } 439 | }); 440 | } 441 | 442 | /// Filter the "accept-encoding" encodings in the header value `orig` and return 443 | /// a new value only containing the ones we support. 444 | fn filter_encodings(orig: &str) -> String { 445 | let allowed_values = orig.split(',') 446 | .map(|part| part.trim()) 447 | .filter(|part| { 448 | let encoding = part.split_once(';').map(|p| p.0).unwrap_or(part); 449 | SUPPORTED_COMPRESSIONS.contains(&encoding) 450 | }); 451 | 452 | let mut new_value = String::new(); 453 | for (i, part) in allowed_values.enumerate() { 454 | if i != 0 { 455 | new_value.push_str(", "); 456 | } 457 | new_value.push_str(part); 458 | } 459 | new_value 460 | } 461 | 462 | 463 | #[cfg(test)] 464 | mod tests { 465 | #[test] 466 | fn encoding_filter() { 467 | use super::filter_encodings as filter; 468 | 469 | assert_eq!(filter(""), ""); 470 | assert_eq!(filter("gzip"), "gzip"); 471 | assert_eq!(filter("br"), "br"); 472 | assert_eq!(filter("gzip, br"), "gzip, br"); 473 | assert_eq!(filter("gzip, deflate"), "gzip"); 474 | assert_eq!(filter("deflate, gzip"), "gzip"); 475 | assert_eq!(filter("gzip, deflate, br"), "gzip, br"); 476 | assert_eq!(filter("deflate, gzip, br"), "gzip, br"); 477 | assert_eq!(filter("gzip, br, deflate"), "gzip, br"); 478 | assert_eq!(filter("deflate"), ""); 479 | assert_eq!(filter("br;q=1.0, deflate;q=0.5, gzip;q=0.8, *;q=0.1"), "br;q=1.0, gzip;q=0.8"); 480 | } 481 | 482 | #[test] 483 | fn modify_csp() { 484 | #[track_caller] 485 | fn assert_rewritten(original: &str, expected_rewritten: &str) { 486 | let mut header = hyper::header::HeaderValue::from_str(original).unwrap(); 487 | super::rewrite_csp(&mut header); 488 | if header.to_str().unwrap() != expected_rewritten { 489 | panic!( 490 | "unexpected rewritten CSP header:\n\ 491 | original: {}\n\ 492 | expected: {}\n\ 493 | actual: {}\n", 494 | original, 495 | expected_rewritten, 496 | header.to_str().unwrap(), 497 | ); 498 | } 499 | } 500 | 501 | #[track_caller] 502 | fn assert_not_rewritten(original: &str) { 503 | assert_rewritten(original, original); 504 | } 505 | 506 | assert_not_rewritten("default-src *"); 507 | assert_not_rewritten("default-src 'self'"); 508 | assert_not_rewritten("default-src 'self' https://google.com"); 509 | assert_not_rewritten("default-src 'none' https://google.com; \ 510 | script-src 'self'; connect-src *"); 511 | 512 | assert_rewritten( 513 | "default-src 'none'", 514 | "connect-src 'self'; default-src 'none'; script-src 'self'; ", 515 | ); 516 | assert_rewritten( 517 | "default-src 'none'; script-src http:", 518 | "connect-src 'self'; default-src 'none'; script-src http: 'self'; ", 519 | ); 520 | assert_rewritten( 521 | "default-src 'self'; connect-src 'none'", 522 | "connect-src 'self'; default-src 'self'; ", 523 | ); 524 | assert_rewritten( 525 | "default-src 'self'; script-src https:", 526 | "default-src 'self'; script-src https: 'self'; ", 527 | ); 528 | } 529 | } 530 | -------------------------------------------------------------------------------- /lib/src/tests.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use hyper::http::uri::{Authority, Scheme}; 4 | 5 | use super::*; 6 | 7 | /// Asserts that certain traits are implemented for the public types. `Debug` is 8 | /// already covered by `#![deny(missing_debug_implementations)]`. 9 | #[allow(unused_variables, dead_code, unreachable_code)] 10 | mod traits { 11 | use super::*; 12 | 13 | fn controller() -> impl Send + Sync + Clone + Unpin { 14 | let x: Controller = todo!(); 15 | x 16 | } 17 | 18 | fn server() -> impl Send + Unpin { 19 | let x: Server = todo!(); 20 | x 21 | } 22 | } 23 | 24 | 25 | #[test] 26 | fn parse_proxy_target() { 27 | assert_eq!( 28 | ProxyTarget::from_str("localhost").unwrap(), 29 | ProxyTarget::from((Scheme::HTTP, Authority::from_static("localhost"))), 30 | ); 31 | assert_eq!( 32 | ProxyTarget::from_str("localhost:8000").unwrap(), 33 | ProxyTarget::from((Scheme::HTTP, Authority::from_static("localhost:8000"))), 34 | ); 35 | assert_eq!( 36 | ProxyTarget::from_str("https://127.0.0.1:30").unwrap(), 37 | ProxyTarget::from((Scheme::HTTPS, Authority::from_static("127.0.0.1:30"))), 38 | ); 39 | assert_eq!( 40 | ProxyTarget::from_str("127.0.0.1:30").unwrap(), 41 | ProxyTarget::from((Scheme::HTTP, Authority::from_static("127.0.0.1:30"))), 42 | ); 43 | assert_eq!( 44 | ProxyTarget::from_str("127.1.2.3:40").unwrap(), 45 | ProxyTarget::from((Scheme::HTTP, Authority::from_static("127.1.2.3:40"))), 46 | ); 47 | assert_eq!( 48 | ProxyTarget::from_str("http://github.com").unwrap(), 49 | ProxyTarget::from((Scheme::HTTP, Authority::from_static("github.com"))), 50 | ); 51 | assert_eq!( 52 | ProxyTarget::from_str("https://github.com/").unwrap(), 53 | ProxyTarget::from((Scheme::HTTPS, Authority::from_static("github.com"))), 54 | ); 55 | } 56 | 57 | #[test] 58 | fn parse_proxy_target_bad() { 59 | assert!(matches!( 60 | ProxyTarget::from_str("").unwrap_err(), 61 | ProxyTargetParseError::InvalidUri(_), 62 | )); 63 | assert!(matches!( 64 | ProxyTarget::from_str("github.com").unwrap_err(), 65 | ProxyTargetParseError::MissingScheme, 66 | )); 67 | assert!(matches!( 68 | ProxyTarget::from_str("https://").unwrap_err(), 69 | ProxyTargetParseError::InvalidUri(_), 70 | )); 71 | assert!(matches!( 72 | ProxyTarget::from_str("http://github.com/foo").unwrap_err(), 73 | ProxyTargetParseError::HasPath, 74 | )); 75 | } 76 | -------------------------------------------------------------------------------- /lib/src/util.rs: -------------------------------------------------------------------------------- 1 | //! Utility functions. 2 | 3 | use std::time::Duration; 4 | 5 | use hyper::http::uri::Scheme; 6 | use tokio::net::TcpStream; 7 | 8 | use crate::ProxyTarget; 9 | 10 | 11 | /// Repeatedly tries to connect to the given proxy via TCP, returning once a 12 | /// connection was established. 13 | /// 14 | /// After each attempt, this function waits for `poll_period` before trying 15 | /// again. If the proxy target is never reachable, this function's future never 16 | /// resolves. To stop this function after some timeout, use some external 17 | /// functions, e.g. `tokio::time::timeout`. 18 | /// 19 | /// This function can be used just before calling 20 | /// [`Controller::reload`][super::Controller::reload] to make sure the proxy 21 | /// server is ready. This avoids the user seeing "Cannot reach proxy target". 22 | pub async fn wait_for_proxy(target: &ProxyTarget, poll_period: Duration) { 23 | let mut interval = tokio::time::interval(poll_period); 24 | let port = target.authority 25 | .port_u16() 26 | .unwrap_or(if target.scheme == Scheme::HTTP { 80 } else { 443 }); 27 | 28 | loop { 29 | if TcpStream::connect((target.authority.host(), port)).await.is_ok() { 30 | break; 31 | } 32 | interval.tick().await; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/ws.rs: -------------------------------------------------------------------------------- 1 | use futures::{SinkExt, StreamExt}; 2 | use hyper_tungstenite::{HyperWebsocket, tungstenite::{Error, Message, error::ProtocolError}}; 3 | use tokio::sync::broadcast::{Receiver, error::RecvError}; 4 | 5 | use super::Action; 6 | 7 | 8 | /// Function to handle a single websocket (listen for incoming `Action`s and 9 | /// stop if the WS connection is closed). There is one task per WS connection. 10 | pub(crate) async fn handle_connection( 11 | websocket: HyperWebsocket, 12 | mut actions: Receiver, 13 | ) { 14 | let mut websocket = match websocket.await { 15 | Ok(ws) => ws, 16 | Err(e) => { 17 | log::warn!("failed to establish websocket connection: {}", e); 18 | return; 19 | } 20 | }; 21 | 22 | loop { 23 | tokio::select! { 24 | action = actions.recv() => { 25 | let data = match &action { 26 | // When all senders have closed, there is no reason to 27 | // continue keeping this task alive. 28 | Err(RecvError::Closed) => break, 29 | Err(RecvError::Lagged(skipped)) => { 30 | // I really can't imagine this happening: this would 31 | // mean this WS task was never awoken while many actions 32 | // were incoming. 33 | log::warn!( 34 | "Missed {} actions. Did you submit too many actions too quickly? \ 35 | For example, this can happen by watching a directory where lots \ 36 | of files change at the same time.", 37 | skipped, 38 | ); 39 | continue; 40 | } 41 | Ok(Action::Reload) => { 42 | log::trace!("Sending reload WS command"); 43 | "reload".to_string() 44 | } 45 | Ok(Action::Message(msg)) => { 46 | log::trace!("Sending message WS command"); 47 | format!("message\n{}", msg) 48 | } 49 | }; 50 | 51 | if let Err(e) = websocket.send(Message::text(data)).await { 52 | log::warn!("Failed to send WS message for action '{:?}': {}", action, e); 53 | } 54 | } 55 | 56 | message = websocket.next() => { 57 | match message { 58 | // If the WS connection was closed, we can just stop this 59 | // function. 60 | None | Some(Ok(Message::Close(_))) => break, 61 | 62 | // The library tungstenite already handles ping requests 63 | // internally, but we still have to "call into the library" 64 | // for the pong packet to actually get sent. 65 | Some(Ok(Message::Ping(_))) => { 66 | match websocket.flush().await { 67 | // We explicitly ignore a couple of errors related 68 | // to a closed connection. If the connection is 69 | // closed, we do not care that our pong send failed. 70 | Ok(_) 71 | | Err(Error::ConnectionClosed) 72 | | Err(Error::AlreadyClosed) 73 | | Err(Error::Protocol(ProtocolError::ResetWithoutClosingHandshake)) 74 | | Err(Error::Protocol(ProtocolError::SendAfterClosing)) => {} 75 | 76 | Err(e) => log::warn!("Error sending pong WS packet: {}", e), 77 | } 78 | } 79 | 80 | // We catch this particular error since it happens a lot 81 | // when a tab is reloading and the WS connection isn't 82 | // properly closed. This is nothing to worry about and we 83 | // just stop/drop the connection. 84 | Some(Err(Error::Protocol(ProtocolError::ResetWithoutClosingHandshake))) => { 85 | break; 86 | } 87 | 88 | // All other errors get shown as warnings. 89 | Some(Err(e)) => { 90 | log::warn!( 91 | "Error receiving WS message. Shutting down WS connection. Error: {}", 92 | e, 93 | ); 94 | break; 95 | } 96 | 97 | _ => log::warn!("unexpected incoming WS message {:?}", message), 98 | } 99 | } 100 | }; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["src/browser.ts"], 3 | "compilerOptions": { 4 | "outFile": "src/generated/browser.js", 5 | "target": "ES6", 6 | "skipLibCheck": true, 7 | 8 | // I would prefer most of these to be warnings, but errors are better 9 | // than nothing. 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | } 16 | } 17 | --------------------------------------------------------------------------------