anvilsign in

collin/anvil · f0c2b35b

feat: CI runner + web UI + CD webhook, CSRF hardening, ORM cleanup

Collin Richards · 2026-06-09 21:52 UTC · f0c2b35b5acc0711dabdb54ffdca6cf6297d200a · parent 2b7e7397 · browse files

modifiedCargo.lock+365 −26
⋯ 122 unchanged lines
123123 name = "anvil"
124124 version = "0.0.0"
125125 dependencies = [
126+ "anvil-ci",
126127 "anvil-core",
127128 "anvil-ssh",
128129 "anvil-web",
⋯ 5 unchanged lines
134135 ]
135136
136137 [[package]]
138+name = "anvil-ci"
139+version = "0.0.0"
140+dependencies = [
141+ "anvil-core",
142+ "anvil-git",
143+ "bollard",
144+ "futures-util",
145+ "reqwest",
146+ "serde_json",
147+ "tar",
148+ "tokio",
149+ "tracing",
150+]
151+
152+[[package]]
137153 name = "anvil-core"
138154 version = "0.0.0"
139155 dependencies = [
140156 "argon2 0.5.3",
141157 "gix",
158+ "hmac 0.12.1",
142159 "serde",
143160 "serde_yaml",
161+ "sha2 0.10.9",
144162 "ssh-key",
145163 "tempfile",
146164 "thiserror",
⋯ 217 unchanged lines
364382 dependencies = [
365383 "blowfish",
366384 "pbkdf2",
367- "sha2",
385+ "sha2 0.11.0",
368386 ]
369387
370388 [[package]]
⋯ 101 unchanged lines
472490 ]
473491
474492 [[package]]
493+name = "bollard"
494+version = "0.18.1"
495+source = "registry+https://github.com/rust-lang/crates.io-index"
496+checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30"
497+dependencies = [
498+ "base64",
499+ "bollard-stubs",
500+ "bytes",
501+ "futures-core",
502+ "futures-util",
503+ "hex",
504+ "http",
505+ "http-body-util",
506+ "hyper",
507+ "hyper-named-pipe",
508+ "hyper-util",
509+ "hyperlocal",
510+ "log",
511+ "pin-project-lite",
512+ "serde",
513+ "serde_derive",
514+ "serde_json",
515+ "serde_repr",
516+ "serde_urlencoded",
517+ "thiserror",
518+ "tokio",
519+ "tokio-util",
520+ "tower-service",
521+ "url",
522+ "winapi",
523+]
524+
525+[[package]]
526+name = "bollard-stubs"
527+version = "1.47.1-rc.27.3.1"
528+source = "registry+https://github.com/rust-lang/crates.io-index"
529+checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da"
530+dependencies = [
531+ "serde",
532+ "serde_repr",
533+ "serde_with",
534+]
535+
536+[[package]]
537+name = "bs58"
538+version = "0.5.1"
539+source = "registry+https://github.com/rust-lang/crates.io-index"
540+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
541+dependencies = [
542+ "tinyvec",
543+]
544+
545+[[package]]
475546 name = "bstr"
476547 version = "1.12.1"
477548 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 81 unchanged lines
559630 "iana-time-zone",
560631 "js-sys",
561632 "num-traits",
633+ "serde",
562634 "wasm-bindgen",
563635 "windows-link",
564636 ]
⋯ 359 unchanged lines
924996 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
925997
926998 [[package]]
999+name = "dyn-clone"
1000+version = "1.0.20"
1001+source = "registry+https://github.com/rust-lang/crates.io-index"
1002+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
1003+
1004+[[package]]
9271005 name = "ecdsa"
9281006 version = "0.17.0-rc.18"
9291007 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 28 unchanged lines
9581036 "ed25519",
9591037 "rand_core 0.10.1",
9601038 "serde",
961- "sha2",
1039+ "sha2 0.11.0",
9621040 "signature",
9631041 "subtle",
9641042 "zeroize",
⋯ 1079 unchanged lines
20442122
20452123 [[package]]
20462124 name = "hashbrown"
2125+version = "0.12.3"
2126+source = "registry+https://github.com/rust-lang/crates.io-index"
2127+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
2128+
2129+[[package]]
2130+name = "hashbrown"
20472131 version = "0.14.5"
20482132 source = "registry+https://github.com/rust-lang/crates.io-index"
20492133 checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
⋯ 78 unchanged lines
21282212 source = "registry+https://github.com/rust-lang/crates.io-index"
21292213 checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
21302214 dependencies = [
2131- "hmac",
2215+ "hmac 0.13.0",
2216+]
2217+
2218+[[package]]
2219+name = "hmac"
2220+version = "0.12.1"
2221+source = "registry+https://github.com/rust-lang/crates.io-index"
2222+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
2223+dependencies = [
2224+ "digest 0.10.7",
21322225 ]
21332226
21342227 [[package]]
⋯ 86 unchanged lines
22212314 "pin-project-lite",
22222315 "smallvec",
22232316 "tokio",
2317+ "want",
22242318 ]
22252319
22262320 [[package]]
2321+name = "hyper-named-pipe"
2322+version = "0.1.0"
2323+source = "registry+https://github.com/rust-lang/crates.io-index"
2324+checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278"
2325+dependencies = [
2326+ "hex",
2327+ "hyper",
2328+ "hyper-util",
2329+ "pin-project-lite",
2330+ "tokio",
2331+ "tower-service",
2332+ "winapi",
2333+]
2334+
2335+[[package]]
22272336 name = "hyper-util"
22282337 version = "0.1.20"
22292338 source = "registry+https://github.com/rust-lang/crates.io-index"
22302339 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
22312340 dependencies = [
2341+ "base64",
22322342 "bytes",
2343+ "futures-channel",
2344+ "futures-util",
22332345 "http",
22342346 "http-body",
22352347 "hyper",
2348+ "ipnet",
2349+ "libc",
2350+ "percent-encoding",
22362351 "pin-project-lite",
2352+ "socket2",
22372353 "tokio",
22382354 "tower-service",
2355+ "tracing",
22392356 ]
22402357
22412358 [[package]]
2359+name = "hyperlocal"
2360+version = "0.9.1"
2361+source = "registry+https://github.com/rust-lang/crates.io-index"
2362+checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
2363+dependencies = [
2364+ "hex",
2365+ "http-body-util",
2366+ "hyper",
2367+ "hyper-util",
2368+ "pin-project-lite",
2369+ "tokio",
2370+ "tower-service",
2371+]
2372+
2373+[[package]]
22422374 name = "iana-time-zone"
22432375 version = "0.1.65"
22442376 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 134 unchanged lines
23792511
23802512 [[package]]
23812513 name = "indexmap"
2514+version = "1.9.3"
2515+source = "registry+https://github.com/rust-lang/crates.io-index"
2516+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
2517+dependencies = [
2518+ "autocfg",
2519+ "hashbrown 0.12.3",
2520+ "serde",
2521+]
2522+
2523+[[package]]
2524+name = "indexmap"
23822525 version = "2.14.0"
23832526 source = "registry+https://github.com/rust-lang/crates.io-index"
23842527 checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
⋯ 36 unchanged lines
24212564 ]
24222565
24232566 [[package]]
2567+name = "ipnet"
2568+version = "2.12.0"
2569+source = "registry+https://github.com/rust-lang/crates.io-index"
2570+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
2571+
2572+[[package]]
24242573 name = "is_terminal_polyfill"
24252574 version = "1.70.2"
24262575 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 380 unchanged lines
28072956 "elliptic-curve",
28082957 "primefield",
28092958 "primeorder",
2810- "sha2",
2959+ "sha2 0.11.0",
28112960 ]
28122961
28132962 [[package]]
⋯ 7 unchanged lines
28212970 "fiat-crypto",
28222971 "primefield",
28232972 "primeorder",
2824- "sha2",
2973+ "sha2 0.11.0",
28252974 ]
28262975
28272976 [[package]]
⋯ 7 unchanged lines
28352984 "elliptic-curve",
28362985 "primefield",
28372986 "primeorder",
2838- "sha2",
2987+ "sha2 0.11.0",
28392988 ]
28402989
28412990 [[package]]
⋯ 9 unchanged lines
28513000 "futures",
28523001 "log",
28533002 "rand",
2854- "sha2",
3003+ "sha2 0.11.0",
28553004 "thiserror",
28563005 "tokio",
28573006 "windows",
⋯ 50 unchanged lines
29083057 checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
29093058 dependencies = [
29103059 "digest 0.11.3",
2911- "hmac",
3060+ "hmac 0.13.0",
29123061 ]
29133062
29143063 [[package]]
⋯ 49 unchanged lines
29643113 "pbkdf2",
29653114 "rand_core 0.10.1",
29663115 "scrypt",
2967- "sha2",
3116+ "sha2 0.11.0",
29683117 "spki",
29693118 ]
29703119
⋯ 22 unchanged lines
29933142 checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
29943143 dependencies = [
29953144 "base64",
2996- "indexmap",
3145+ "indexmap 2.14.0",
29973146 "quick-xml",
29983147 "serde",
29993148 "time",
⋯ 184 unchanged lines
31843333 ]
31853334
31863335 [[package]]
3336+name = "ref-cast"
3337+version = "1.0.25"
3338+source = "registry+https://github.com/rust-lang/crates.io-index"
3339+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
3340+dependencies = [
3341+ "ref-cast-impl",
3342+]
3343+
3344+[[package]]
3345+name = "ref-cast-impl"
3346+version = "1.0.25"
3347+source = "registry+https://github.com/rust-lang/crates.io-index"
3348+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
3349+dependencies = [
3350+ "proc-macro2",
3351+ "quote",
3352+ "syn",
3353+]
3354+
3355+[[package]]
31873356 name = "regex"
31883357 version = "1.12.4"
31893358 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 23 unchanged lines
32133382 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
32143383
32153384 [[package]]
3385+name = "reqwest"
3386+version = "0.12.28"
3387+source = "registry+https://github.com/rust-lang/crates.io-index"
3388+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
3389+dependencies = [
3390+ "base64",
3391+ "bytes",
3392+ "futures-core",
3393+ "http",
3394+ "http-body",
3395+ "http-body-util",
3396+ "hyper",
3397+ "hyper-util",
3398+ "js-sys",
3399+ "log",
3400+ "percent-encoding",
3401+ "pin-project-lite",
3402+ "serde",
3403+ "serde_json",
3404+ "serde_urlencoded",
3405+ "sync_wrapper",
3406+ "tokio",
3407+ "tower",
3408+ "tower-http",
3409+ "tower-service",
3410+ "url",
3411+ "wasm-bindgen",
3412+ "wasm-bindgen-futures",
3413+ "web-sys",
3414+]
3415+
3416+[[package]]
32163417 name = "rfc6979"
32173418 version = "0.5.0"
32183419 source = "registry+https://github.com/rust-lang/crates.io-index"
32193420 checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3"
32203421 dependencies = [
3221- "hmac",
3422+ "hmac 0.13.0",
32223423 "subtle",
32233424 ]
32243425
⋯ 24 unchanged lines
32493450 "pkcs1",
32503451 "pkcs8",
32513452 "rand_core 0.10.1",
3252- "sha2",
3453+ "sha2 0.11.0",
32533454 "signature",
32543455 "spki",
32553456 "zeroize",
⋯ 54 unchanged lines
33103511 "getrandom 0.4.2",
33113512 "ghash",
33123513 "hex-literal",
3313- "hmac",
3514+ "hmac 0.13.0",
33143515 "inout",
33153516 "internal-russh-num-bigint",
33163517 "keccak",
⋯ 21 unchanged lines
33383539 "scrypt",
33393540 "sec1",
33403541 "sha1 0.11.0",
3341- "sha2",
3542+ "sha2 0.11.0",
33423543 "sha3",
33433544 "signature",
33443545 "spki",
⋯ 85 unchanged lines
34303631 ]
34313632
34323633 [[package]]
3634+name = "schemars"
3635+version = "0.9.0"
3636+source = "registry+https://github.com/rust-lang/crates.io-index"
3637+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
3638+dependencies = [
3639+ "dyn-clone",
3640+ "ref-cast",
3641+ "serde",
3642+ "serde_json",
3643+]
3644+
3645+[[package]]
3646+name = "schemars"
3647+version = "1.2.1"
3648+source = "registry+https://github.com/rust-lang/crates.io-index"
3649+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
3650+dependencies = [
3651+ "dyn-clone",
3652+ "ref-cast",
3653+ "serde",
3654+ "serde_json",
3655+]
3656+
3657+[[package]]
34333658 name = "scopeguard"
34343659 version = "1.2.0"
34353660 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 8 unchanged lines
34443669 "cfg-if",
34453670 "pbkdf2",
34463671 "salsa20",
3447- "sha2",
3672+ "sha2 0.11.0",
34483673 ]
34493674
34503675 [[package]]
⋯ 71 unchanged lines
35223747 ]
35233748
35243749 [[package]]
3750+name = "serde_repr"
3751+version = "0.1.20"
3752+source = "registry+https://github.com/rust-lang/crates.io-index"
3753+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
3754+dependencies = [
3755+ "proc-macro2",
3756+ "quote",
3757+ "syn",
3758+]
3759+
3760+[[package]]
35253761 name = "serde_spanned"
35263762 version = "0.6.9"
35273763 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 15 unchanged lines
35433779 ]
35443780
35453781 [[package]]
3782+name = "serde_with"
3783+version = "3.21.0"
3784+source = "registry+https://github.com/rust-lang/crates.io-index"
3785+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
3786+dependencies = [
3787+ "base64",
3788+ "bs58",
3789+ "chrono",
3790+ "hex",
3791+ "indexmap 1.9.3",
3792+ "indexmap 2.14.0",
3793+ "schemars 0.9.0",
3794+ "schemars 1.2.1",
3795+ "serde_core",
3796+ "serde_json",
3797+ "time",
3798+]
3799+
3800+[[package]]
35463801 name = "serde_yaml"
35473802 version = "0.9.34+deprecated"
35483803 source = "registry+https://github.com/rust-lang/crates.io-index"
35493804 checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
35503805 dependencies = [
3551- "indexmap",
3806+ "indexmap 2.14.0",
35523807 "itoa",
35533808 "ryu",
35543809 "serde",
⋯ 44 unchanged lines
35993854
36003855 [[package]]
36013856 name = "sha2"
3857+version = "0.10.9"
3858+source = "registry+https://github.com/rust-lang/crates.io-index"
3859+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
3860+dependencies = [
3861+ "cfg-if",
3862+ "cpufeatures 0.2.17",
3863+ "digest 0.10.7",
3864+]
3865+
3866+[[package]]
3867+name = "sha2"
36023868 version = "0.11.0"
36033869 source = "registry+https://github.com/rust-lang/crates.io-index"
36043870 checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
⋯ 156 unchanged lines
37614027 "ctutils",
37624028 "ed25519-dalek",
37634029 "hex",
3764- "hmac",
4030+ "hmac 0.13.0",
37654031 "p256",
37664032 "p384",
37674033 "p521",
⋯ 1 unchanged line
37694035 "rsa",
37704036 "sec1",
37714037 "sha1 0.11.0",
3772- "sha2",
4038+ "sha2 0.11.0",
37734039 "signature",
37744040 "ssh-cipher",
37754041 "ssh-encoding",
⋯ 40 unchanged lines
38164082 version = "1.0.2"
38174083 source = "registry+https://github.com/rust-lang/crates.io-index"
38184084 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
4085+dependencies = [
4086+ "futures-core",
4087+]
38194088
38204089 [[package]]
38214090 name = "synstructure"
⋯ 28 unchanged lines
38504119 ]
38514120
38524121 [[package]]
4122+name = "tar"
4123+version = "0.4.46"
4124+source = "registry+https://github.com/rust-lang/crates.io-index"
4125+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
4126+dependencies = [
4127+ "filetime",
4128+ "libc",
4129+ "xattr",
4130+]
4131+
4132+[[package]]
38534133 name = "tempfile"
38544134 version = "3.27.0"
38554135 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 103 unchanged lines
39594239 "deadpool",
39604240 "hashbrown 0.17.1",
39614241 "index_vec",
3962- "indexmap",
4242+ "indexmap 2.14.0",
39634243 "inventory",
39644244 "toasty-core",
39654245 "toasty-driver-sqlite",
⋯ 15 unchanged lines
39814261 "bit-set 0.10.0",
39824262 "hashbrown 0.17.1",
39834263 "heck",
3984- "indexmap",
4264+ "indexmap 2.14.0",
39854265 "pluralizer",
39864266 "tokio-stream",
39874267 "uuid",
⋯ 120 unchanged lines
41084388 source = "registry+https://github.com/rust-lang/crates.io-index"
41094389 checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
41104390 dependencies = [
4111- "indexmap",
4391+ "indexmap 2.14.0",
41124392 "serde",
41134393 "serde_spanned",
41144394 "toml_datetime",
⋯ 44 unchanged lines
41594439 "pin-project-lite",
41604440 "tokio",
41614441 "tokio-util",
4442+ "tower",
41624443 "tower-layer",
41634444 "tower-service",
41644445 "tracing",
4446+ "url",
41654447 ]
41664448
41674449 [[package]]
⋯ 71 unchanged lines
42394521 ]
42404522
42414523 [[package]]
4524+name = "try-lock"
4525+version = "0.2.5"
4526+source = "registry+https://github.com/rust-lang/crates.io-index"
4527+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
4528+
4529+[[package]]
42424530 name = "typenum"
42434531 version = "1.20.1"
42444532 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 128 unchanged lines
43734661 ]
43744662
43754663 [[package]]
4664+name = "want"
4665+version = "0.3.1"
4666+source = "registry+https://github.com/rust-lang/crates.io-index"
4667+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
4668+dependencies = [
4669+ "try-lock",
4670+]
4671+
4672+[[package]]
43764673 name = "wasi"
43774674 version = "0.11.1+wasi-snapshot-preview1"
43784675 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 89 unchanged lines
44684765 checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
44694766 dependencies = [
44704767 "anyhow",
4471- "indexmap",
4768+ "indexmap 2.14.0",
44724769 "wasm-encoder",
44734770 "wasmparser",
44744771 ]
⋯ 6 unchanged lines
44814778 dependencies = [
44824779 "bitflags",
44834780 "hashbrown 0.15.5",
4484- "indexmap",
4781+ "indexmap 2.14.0",
44854782 "semver",
44864783 ]
44874784
44884785 [[package]]
4786+name = "web-sys"
4787+version = "0.3.100"
4788+source = "registry+https://github.com/rust-lang/crates.io-index"
4789+checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
4790+dependencies = [
4791+ "js-sys",
4792+ "wasm-bindgen",
4793+]
4794+
4795+[[package]]
4796+name = "winapi"
4797+version = "0.3.9"
4798+source = "registry+https://github.com/rust-lang/crates.io-index"
4799+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
4800+dependencies = [
4801+ "winapi-i686-pc-windows-gnu",
4802+ "winapi-x86_64-pc-windows-gnu",
4803+]
4804+
4805+[[package]]
4806+name = "winapi-i686-pc-windows-gnu"
4807+version = "0.4.0"
4808+source = "registry+https://github.com/rust-lang/crates.io-index"
4809+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
4810+
4811+[[package]]
44894812 name = "winapi-util"
44904813 version = "0.1.11"
44914814 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 3 unchanged lines
44954818 ]
44964819
44974820 [[package]]
4821+name = "winapi-x86_64-pc-windows-gnu"
4822+version = "0.4.0"
4823+source = "registry+https://github.com/rust-lang/crates.io-index"
4824+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
4825+
4826+[[package]]
44984827 name = "windows"
44994828 version = "0.62.2"
45004829 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 228 unchanged lines
47295058 dependencies = [
47305059 "anyhow",
47315060 "heck",
4732- "indexmap",
5061+ "indexmap 2.14.0",
47335062 "prettyplease",
47345063 "syn",
47355064 "wasm-metadata",
⋯ 24 unchanged lines
47605089 dependencies = [
47615090 "anyhow",
47625091 "bitflags",
4763- "indexmap",
5092+ "indexmap 2.14.0",
47645093 "log",
47655094 "serde",
47665095 "serde_derive",
⋯ 12 unchanged lines
47795108 dependencies = [
47805109 "anyhow",
47815110 "id-arena",
4782- "indexmap",
5111+ "indexmap 2.14.0",
47835112 "log",
47845113 "semver",
47855114 "serde",
⋯ 10 unchanged lines
47965125 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
47975126
47985127 [[package]]
5128+name = "xattr"
5129+version = "1.6.1"
5130+source = "registry+https://github.com/rust-lang/crates.io-index"
5131+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
5132+dependencies = [
5133+ "libc",
5134+ "rustix",
5135+]
5136+
5137+[[package]]
47995138 name = "yaml-rust"
48005139 version = "0.4.5"
48015140 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 99 unchanged lines
modifiedCargo.toml+11 −0
⋯ 14 unchanged lines
1515 [workspace.dependencies]
1616 # Internal crates
1717 anvil-core = { path = "crates/anvil-core" }
18+anvil-ci = { path = "crates/anvil-ci" }
1819 anvil-git = { path = "crates/anvil-git" }
1920 anvil-web = { path = "crates/anvil-web" }
2021 anvil-ssh = { path = "crates/anvil-ssh" }
⋯ 3 unchanged lines
2425 axum = "0.8"
2526 axum-extra = { version = "0.10", features = ["cookie"] }
2627 base64 = "0.22"
28+bollard = "0.18"
2729 clap = { version = "4", features = ["derive"] }
30+futures-util = "0.3"
31+hmac = "0.12"
2832 gitserver-core = { path = "vendor/gitserver-core" }
2933 gix = { version = "0.84", default-features = false, features = ["sha1", "max-performance-safe", "blob-diff", "revision"] }
3034 gix-pack = { version = "0.71", features = ["sha1"] }
3135 maud = { version = "0.27", features = ["axum"] }
3236 rand = "0.10"
37+# HTTP client for the CD deploy webhook. No TLS feature on purpose: the deploy
38+# receiver is host-local plaintext HTTP, and enabling rustls would drag in
39+# aws-lc-rs and break the musl cross-compile (see the russh note below).
40+reqwest = { version = "0.12", default-features = false, features = ["json"] }
3341 serde = { version = "1", features = ["derive"] }
42+serde_json = "1"
3443 serde_yaml = "0.9"
44+sha2 = "0.10"
3545 similar = "2"
3646 syntect = { version = "5", default-features = false, features = ["default-fancy"] }
47+tar = "0.4"
3748 thiserror = "2"
3849 time = { version = "0.3", features = ["serde", "formatting"] }
3950 toasty = { version = "0.7", features = ["sqlite"] }
⋯ 15 unchanged lines
modifiedDEPLOY.md+48 −0
⋯ 86 unchanged lines
8787 docker run -d --name anvil --network hagrid --restart unless-stopped \
8888 -p 2222:2222 \
8989 -v anvil-data:/data \
90+ -v /var/run/docker.sock:/var/run/docker.sock \
91+ --group-add "$(stat -c '%g' /var/run/docker.sock)" \
9092 anvil:latest
9193 ```
9294
⋯ 2 unchanged lines
9597 - `-v anvil-data:/data` — a named volume holding the SQLite DB, the bare repos,
9698 and the persistent SSH **host key**. Use a named volume (not a host bind
9799 mount) so it's owned by the in-container `anvil` user.
100+- `-v /var/run/docker.sock:/var/run/docker.sock` + `--group-add <sock gid>` —
101+ lets the **CI runner** drive Docker on the host. The non-root `anvil` user
102+ needs the socket's group to open it, hence `--group-add` with the socket's
103+ gid (computed at run time by `run.sh`).
104+
105+> ⚠️ **Security:** mounting the Docker socket grants the container
106+> **root-equivalent** control of the host. This is acceptable here because anvil
107+> is **single-tenant and owner-operated** — CI only ever runs code *you* push.
108+> Do **not** open this instance to untrusted users while the socket is mounted.
109+> If you don't want CI, drop the `-v …docker.sock…` and `--group-add` flags;
110+> the forge runs fine without them (CI runs just error out).
98111
99112 The baked config lives at `/etc/anvil/anvil.toml` (see `deploy/anvil.toml`).
100113 Override it by bind-mounting your own file over that path.
⋯ 38 unchanged lines
139152 also works: `git clone https://anvil.richardscollin.com/collin/anvil.git`
140153 (pushes over HTTPS require your account password as the git password).
141154
155+## 7. CI and the redeploy webhook (CD)
156+
157+Any repo with a `.anvil/ci.yml` runs CI on push (see `[ci]` requires the Docker
158+socket mounted — section 4). A pipeline is just an image plus steps:
159+
160+```yaml
161+image: rust:1.95-bookworm
162+steps:
163+ - name: test
164+ run: cargo test --workspace
165+ - run: cargo build --release
166+```
167+
168+Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the
169+commit list and a full log on each run's page.
170+
171+**Continuous deployment** is deliberately scoped to **one** repository. On a
172+successful run of `deploy_branch` (default `main`) in the repo named by
173+`[ci] deploy_repo`, anvil POSTs JSON to `[ci] deploy_webhook`:
174+
175+```json
176+{ "repo": "collin/anvil", "ref": "main", "commit": "<oid>", "run_id": 42 }
177+```
178+
179+No other repo can trigger this, even with passing CI. The webhook target is a
180+**host-local plaintext** receiver (HTTPS is unsupported, to keep the build
181+TLS-free) — typically a tiny script-runner on hagrid that, on a verified
182+request, runs the actual redeploy. Verify the `X-Anvil-Deploy-Secret` header
183+(set `[ci] deploy_secret`) before doing anything. Because anvil cross-compiles
184+(section 3), "redeploy anvil" usually means: the receiver pulls the freshly
185+built image and re-runs `deploy/run.sh` — it does **not** build in-place.
186+
187+> The deploy receiver runs with whatever privileges you give it — keep it
188+> minimal, secret-gated, and bound to localhost / the Docker host gateway only.
189+
142190 ## Operations
143191
144192 - **Update**: re-run `./deploy/run.sh` (rebuilds the image, recreates the
⋯ 11 unchanged lines
addedcrates/anvil-ci/Cargo.toml+19 −0
1+[package]
2+name = "anvil-ci"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "CI runner for anvil: executes .anvil/ci.yml pipelines in Docker containers via the socket."
9+
10+[dependencies]
11+anvil-core.workspace = true
12+anvil-git.workspace = true
13+bollard.workspace = true
14+futures-util.workspace = true
15+reqwest.workspace = true
16+serde_json.workspace = true
17+tar.workspace = true
18+tokio.workspace = true
19+tracing.workspace = true
addedcrates/anvil-ci/src/lib.rs+289 −0
1+//! CI runner: drains queued [`anvil_core::ci`] runs and executes their
2+//! `.anvil/ci.yml` pipeline in a Docker container (via the socket, using
3+//! bollard).
4+//!
5+//! For each run: resolve the repo, materialize the commit's tree, parse the
6+//! pipeline, then run all steps as one `set -e` shell script inside the
7+//! pipeline's image. The checkout is uploaded into the container as a tar (via
8+//! the Docker API), so it works regardless of where anvil's own filesystem
9+//! lives and never exposes anvil's data volume to CI.
10+
11+use anvil_core::ci::{self, Pipeline};
12+use anvil_core::{App, repos, storage, users};
13+use anvil_git::browse::{self, TreeFile};
14+use bollard::Docker;
15+use bollard::container::{
16+ Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
17+ UploadToContainerOptions, WaitContainerOptions,
18+};
19+use bollard::image::CreateImageOptions;
20+use futures_util::StreamExt;
21+use tokio::sync::mpsc::UnboundedReceiver;
22+
23+const WORKDIR: &str = "/workspace";
24+
25+/// Run the CI worker loop: recover interrupted runs, drain the queue, then
26+/// process run ids as they arrive on `rx`. Runs one job at a time.
27+pub async fn run_worker(app: App, mut rx: UnboundedReceiver<i64>) {
28+ match ci::requeue_interrupted(&app.db).await {
29+ Ok(ids) if !ids.is_empty() => {
30+ tracing::info!("ci: requeued {} interrupted run(s)", ids.len())
31+ }
32+ Ok(_) => {}
33+ Err(e) => tracing::error!("ci: requeue failed: {e}"),
34+ }
35+ match ci::queued_ids(&app.db).await {
36+ Ok(ids) => {
37+ for id in ids {
38+ run_one(&app, id).await;
39+ }
40+ }
41+ Err(e) => tracing::error!("ci: listing queued runs failed: {e}"),
42+ }
43+ tracing::info!("ci runner ready");
44+ while let Some(id) = rx.recv().await {
45+ run_one(&app, id).await;
46+ }
47+}
48+
49+async fn run_one(app: &App, run_id: i64) {
50+ tracing::info!("ci: run {run_id} starting");
51+ if let Err(e) = process(app, run_id).await {
52+ tracing::error!("ci: run {run_id} errored: {e}");
53+ let _ = ci::append_log(&app.db, run_id, &format!("\n[runner error] {e}\n")).await;
54+ let _ = ci::finish(&app.db, run_id, ci::status::ERROR).await;
55+ }
56+}
57+
58+async fn process(app: &App, run_id: i64) -> Result<(), String> {
59+ let run = ci::get(&app.db, run_id)
60+ .await
61+ .map_err(|e| e.to_string())?
62+ .ok_or("run not found")?;
63+ let repo = repos::find_by_id(&app.db, run.repo_id)
64+ .await
65+ .map_err(|e| e.to_string())?
66+ .ok_or("repository not found")?;
67+ let owner = users::find_by_id(&app.db, repo.owner_id)
68+ .await
69+ .map_err(|e| e.to_string())?
70+ .ok_or("owner not found")?;
71+ let repo_path = storage::repo_path(&app.config.repositories_dir(), &owner.username, &repo.name);
72+
73+ let yaml = browse::read_blob(&repo_path, &run.commit, ci::PIPELINE_PATH)
74+ .map_err(|e| e.to_string())?
75+ .ok_or_else(|| format!("{} missing at {}", ci::PIPELINE_PATH, run.commit))?;
76+ let pipeline =
77+ ci::parse_pipeline(&String::from_utf8_lossy(&yaml)).map_err(|e| e.to_string())?;
78+
79+ let files = browse::read_tree_files(&repo_path, &run.commit).map_err(|e| e.to_string())?;
80+ let tar = build_tar(&files);
81+
82+ ci::mark_running(&app.db, run_id).await.ok();
83+
84+ let short = &run.commit[..run.commit.len().min(12)];
85+ let mut log = format!(
86+ "anvil ci · {}/{} · {} @ {short}\nimage: {}\n",
87+ owner.username, repo.name, run.ref_name, pipeline.image
88+ );
89+
90+ let status = match execute(&pipeline, tar, &mut log).await {
91+ Ok(0) => ci::status::SUCCESS,
92+ Ok(code) => {
93+ log.push_str(&format!("\n[exited with status {code}]\n"));
94+ ci::status::FAILURE
95+ }
96+ Err(e) => {
97+ log.push_str(&format!("\n[runner error] {e}\n"));
98+ ci::status::ERROR
99+ }
100+ };
101+
102+ ci::append_log(&app.db, run_id, &log).await.ok();
103+ ci::finish(&app.db, run_id, status).await.ok();
104+ tracing::info!("ci: run {run_id} {status}");
105+
106+ // Continuous deployment: on a green run of the configured deploy repo's
107+ // deploy branch, fire the redeploy webhook. Scoped to one repo by config —
108+ // no other repository can trigger it, even with passing CI.
109+ if status == ci::status::SUCCESS
110+ && app
111+ .config
112+ .ci
113+ .is_deploy_target(&owner.username, &repo.name, &run.ref_name)
114+ {
115+ deploy(app, &owner.username, &repo.name, &run).await;
116+ }
117+ Ok(())
118+}
119+
120+/// POST the configured deploy webhook. Best-effort: logs success/failure but
121+/// never fails the run (CI already passed).
122+async fn deploy(app: &App, owner: &str, name: &str, run: &anvil_core::CiRun) {
123+ let cfg = &app.config.ci;
124+ let body = serde_json::json!({
125+ "repo": format!("{owner}/{name}"),
126+ "ref": run.ref_name,
127+ "commit": run.commit,
128+ "run_id": run.id,
129+ });
130+ let mut req = reqwest::Client::new().post(&cfg.deploy_webhook).json(&body);
131+ if !cfg.deploy_secret.is_empty() {
132+ req = req.header("X-Anvil-Deploy-Secret", &cfg.deploy_secret);
133+ }
134+ match req.send().await {
135+ Ok(resp) if resp.status().is_success() => {
136+ tracing::info!(
137+ "ci: deploy webhook for {owner}/{name} accepted ({})",
138+ resp.status()
139+ )
140+ }
141+ Ok(resp) => tracing::error!(
142+ "ci: deploy webhook for {owner}/{name} returned {}",
143+ resp.status()
144+ ),
145+ Err(e) => tracing::error!("ci: deploy webhook for {owner}/{name} failed: {e}"),
146+ }
147+}
148+
149+/// Execute the pipeline in a container, streaming output into `log`. Returns the
150+/// container's exit code.
151+async fn execute(pipeline: &Pipeline, tar: Vec<u8>, log: &mut String) -> Result<i64, String> {
152+ let docker = Docker::connect_with_socket_defaults()
153+ .map_err(|e| format!("docker unavailable (is the socket mounted?): {e}"))?;
154+
155+ // Pull the image (split name:tag so we don't accidentally pull all tags).
156+ let (from_image, tag) = match pipeline.image.rsplit_once(':') {
157+ Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()),
158+ _ => (pipeline.image.clone(), "latest".to_string()),
159+ };
160+ let mut pull = docker.create_image(
161+ Some(CreateImageOptions {
162+ from_image,
163+ tag,
164+ ..Default::default()
165+ }),
166+ None,
167+ None,
168+ );
169+ while let Some(item) = pull.next().await {
170+ item.map_err(|e| format!("pull {}: {e}", pipeline.image))?;
171+ }
172+
173+ // Build a single `set -e` script from the steps.
174+ let mut script = String::from("set -e\n");
175+ for step in &pipeline.steps {
176+ script.push_str("printf '\\n=== %s ===\\n' ");
177+ script.push_str(&single_quote(step.label()));
178+ script.push('\n');
179+ script.push_str(&step.run);
180+ script.push('\n');
181+ }
182+
183+ let config = Config {
184+ image: Some(pipeline.image.clone()),
185+ cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]),
186+ working_dir: Some(WORKDIR.to_string()),
187+ ..Default::default()
188+ };
189+ let created = docker
190+ .create_container(None::<CreateContainerOptions<String>>, config)
191+ .await
192+ .map_err(|e| format!("create container: {e}"))?;
193+ let id = created.id;
194+
195+ // Upload the checkout (tar entries are under `workspace/`, extracted at `/`).
196+ docker
197+ .upload_to_container(
198+ &id,
199+ Some(UploadToContainerOptions {
200+ path: "/".to_string(),
201+ ..Default::default()
202+ }),
203+ tar.into(),
204+ )
205+ .await
206+ .map_err(|e| format!("upload checkout: {e}"))?;
207+
208+ docker
209+ .start_container(&id, None::<StartContainerOptions<String>>)
210+ .await
211+ .map_err(|e| format!("start container: {e}"))?;
212+
213+ // Stream logs until the container stops.
214+ let mut logs = docker.logs(
215+ &id,
216+ Some(LogsOptions::<String> {
217+ follow: true,
218+ stdout: true,
219+ stderr: true,
220+ ..Default::default()
221+ }),
222+ );
223+ while let Some(item) = logs.next().await {
224+ match item {
225+ Ok(output) => log.push_str(&String::from_utf8_lossy(&output.into_bytes())),
226+ Err(e) => {
227+ log.push_str(&format!("\n[log stream error] {e}\n"));
228+ break;
229+ }
230+ }
231+ }
232+
233+ // Wait for the exit code (non-zero surfaces as a wait error in bollard).
234+ let mut code = 0i64;
235+ let mut wait = docker.wait_container(&id, None::<WaitContainerOptions<String>>);
236+ while let Some(item) = wait.next().await {
237+ match item {
238+ Ok(resp) => code = resp.status_code,
239+ Err(bollard::errors::Error::DockerContainerWaitError { code: c, .. }) => code = c,
240+ Err(e) => {
241+ let _ = docker
242+ .remove_container(
243+ &id,
244+ Some(RemoveContainerOptions {
245+ force: true,
246+ ..Default::default()
247+ }),
248+ )
249+ .await;
250+ return Err(format!("wait: {e}"));
251+ }
252+ }
253+ }
254+
255+ let _ = docker
256+ .remove_container(
257+ &id,
258+ Some(RemoveContainerOptions {
259+ force: true,
260+ ..Default::default()
261+ }),
262+ )
263+ .await;
264+
265+ Ok(code)
266+}
267+
268+/// Build an uncompressed tar of the checkout, rooted at `workspace/` so it
269+/// extracts to `/workspace` when uploaded to the container root.
270+fn build_tar(files: &[TreeFile]) -> Vec<u8> {
271+ let mut builder = tar::Builder::new(Vec::new());
272+ for f in files {
273+ let mut header = tar::Header::new_gnu();
274+ header.set_size(f.content.len() as u64);
275+ header.set_mode(if f.executable { 0o755 } else { 0o644 });
276+ // append_data sets the path and checksum.
277+ let _ = builder.append_data(
278+ &mut header,
279+ format!("workspace/{}", f.path),
280+ f.content.as_slice(),
281+ );
282+ }
283+ builder.into_inner().unwrap_or_default()
284+}
285+
286+/// Single-quote a string for safe interpolation into a shell command.
287+fn single_quote(s: &str) -> String {
288+ format!("'{}'", s.replace('\'', "'\\''"))
289+}
modifiedcrates/anvil-core/Cargo.toml+3 −0
⋯ 10 unchanged lines
1111 gix.workspace = true
1212 toasty.workspace = true
1313 argon2.workspace = true
14+hmac.workspace = true
15+sha2.workspace = true
1416 ssh-key.workspace = true
1517 serde.workspace = true
1618 serde_yaml.workspace = true
1719 toml.workspace = true
1820 thiserror.workspace = true
1921 tracing.workspace = true
22+tokio.workspace = true
2023
2124 [dev-dependencies]
2225 tokio = { workspace = true }
⋯ 1 unchanged line
modifiedcrates/anvil-core/src/ci.rs+46 −16
⋯ 50 unchanged lines
5151 let pipeline: Pipeline = serde_yaml::from_str(yaml)
5252 .map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?;
5353 if pipeline.image.trim().is_empty() {
54- return Err(Error::Invalid(format!("{PIPELINE_PATH}: `image` is required")));
54+ return Err(Error::Invalid(format!(
55+ "{PIPELINE_PATH}: `image` is required"
56+ )));
5557 }
5658 Ok(pipeline)
5759 }
5860
5961 /// Create a queued CI run for a pushed commit.
60-pub async fn enqueue(
61- db: &toasty::Db,
62- repo_id: i64,
63- commit: &str,
64- ref_name: &str,
65-) -> Result<CiRun> {
62+pub async fn enqueue(db: &toasty::Db, repo_id: i64, commit: &str, ref_name: &str) -> Result<CiRun> {
6663 let mut conn = db.clone();
6764 let run = toasty::create!(CiRun {
6865 repo_id: repo_id,
⋯ 22 unchanged lines
9188 /// List a repository's runs, newest first, up to `limit`.
9289 pub async fn list_by_repo(db: &toasty::Db, repo_id: i64, limit: usize) -> Result<Vec<CiRun>> {
9390 let mut conn = db.clone();
94- let mut runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
91+ let runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
92+ .order_by(CiRun::fields().id().desc())
93+ .limit(limit)
9594 .exec(&mut conn)
9695 .await?;
97- runs.sort_by(|a, b| b.id.cmp(&a.id));
98- runs.truncate(limit);
9996 Ok(runs)
10097 }
10198
⋯ 4 unchanged lines
106103 commit: &str,
107104 ) -> Result<Option<CiRun>> {
108105 let mut conn = db.clone();
109- let mut runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
106+ let run = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
107+ .filter(CiRun::fields().commit().eq(commit))
108+ .order_by(CiRun::fields().id().desc())
109+ .first()
110110 .exec(&mut conn)
111111 .await?;
112- runs.retain(|r| r.commit == commit);
113- runs.sort_by(|a, b| b.id.cmp(&a.id));
114- Ok(runs.into_iter().next())
112+ Ok(run)
115113 }
116114
117115 /// Mark a run as started (running).
⋯ 54 unchanged lines
172170 /// List all queued run ids (oldest first) — used on startup to drain the queue.
173171 pub async fn queued_ids(db: &toasty::Db) -> Result<Vec<i64>> {
174172 let mut conn = db.clone();
175- let mut runs = CiRun::filter(CiRun::fields().status().eq(status::QUEUED))
173+ let runs = CiRun::filter(CiRun::fields().status().eq(status::QUEUED))
174+ .order_by(CiRun::fields().id().asc())
176175 .exec(&mut conn)
177176 .await?;
178- runs.sort_by(|a, b| a.id.cmp(&b.id));
179177 Ok(runs.into_iter().map(|r| r.id).collect())
180178 }
181179
⋯ 24 unchanged lines
206204 fn requires_an_image() {
207205 assert!(parse_pipeline("steps: []\n").is_err());
208206 }
207+
208+ // Exercises the run-lifecycle queries against a real SQLite database, to
209+ // confirm the ORM-level `order_by`/`limit`/filter actually work (Toasty is
210+ // pre-1.0, so we don't take that on faith).
211+ #[tokio::test]
212+ async fn ordering_and_limit_run_in_the_database() {
213+ let dir = tempfile::tempdir().unwrap();
214+ let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
215+
216+ for c in ["aaa", "bbb", "ccc"] {
217+ enqueue(&db, 1, c, "main").await.unwrap();
218+ }
219+ enqueue(&db, 2, "zzz", "main").await.unwrap(); // a different repo
220+
221+ // Newest-first, limited to 2 — and scoped to repo 1.
222+ let runs = list_by_repo(&db, 1, 2).await.unwrap();
223+ assert_eq!(runs.len(), 2);
224+ assert!(runs[0].id > runs[1].id, "newest first");
225+ assert_eq!(runs[0].commit, "ccc");
226+ assert!(runs.iter().all(|r| r.repo_id == 1));
227+
228+ // Commit filter pushed into the query (not an in-memory retain).
229+ let latest = latest_for_commit(&db, 1, "bbb").await.unwrap().unwrap();
230+ assert_eq!(latest.commit, "bbb");
231+ assert_eq!(latest.repo_id, 1);
232+ assert!(latest_for_commit(&db, 1, "nope").await.unwrap().is_none());
233+
234+ // All queued, ascending by id.
235+ let queued = queued_ids(&db).await.unwrap();
236+ assert_eq!(queued.len(), 4);
237+ assert!(queued.windows(2).all(|w| w[0] < w[1]), "ascending");
238+ }
209239 }
modifiedcrates/anvil-core/src/config.rs+58 −12
11 //! Server configuration: loaded from a TOML file with sensible defaults.
22
3-use std::path::{
4- Path,
5- PathBuf,
6-};
3+use std::path::{Path, PathBuf};
74
8-use serde::{
9- Deserialize,
10- Serialize,
11-};
5+use serde::{Deserialize, Serialize};
126
13-use crate::error::{
14- Error,
15- Result,
16-};
7+use crate::error::{Error, Result};
178
189 /// Top-level anvil configuration.
1910 ///
⋯ 7 unchanged lines
2718 pub http: HttpConfig,
2819 /// SSH server settings.
2920 pub ssh: SshConfig,
21+ /// Continuous-deployment settings (the single-repo redeploy webhook).
22+ pub ci: CiConfig,
23+}
24+
25+/// Continuous-deployment configuration.
26+///
27+/// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
28+/// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil
29+/// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately
30+/// scoped to **one** repository — no other repo can trigger the deploy, even
31+/// with its own passing CI.
32+#[derive(Debug, Clone, Serialize, Deserialize)]
33+#[serde(default)]
34+pub struct CiConfig {
35+ /// The one repository (`owner/name`) permitted to trigger the deploy
36+ /// webhook. Empty disables deploys entirely.
37+ pub deploy_repo: String,
38+ /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be
39+ /// a host-local plaintext HTTP endpoint (a small deploy-script receiver);
40+ /// HTTPS is intentionally unsupported to keep the build TLS-free.
41+ pub deploy_webhook: String,
42+ /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver
43+ /// can authenticate the call. Empty sends no header.
44+ pub deploy_secret: String,
45+ /// Branch whose successful run triggers a deploy. Defaults to `main`.
46+ pub deploy_branch: String,
3047 }
3148
3249 #[derive(Debug, Clone, Serialize, Deserialize)]
⋯ 29 unchanged lines
6279 data_dir: PathBuf::from("data"),
6380 http: HttpConfig::default(),
6481 ssh: SshConfig::default(),
82+ ci: CiConfig::default(),
83+ }
84+ }
85+}
86+
87+impl Default for CiConfig {
88+ fn default() -> Self {
89+ Self {
90+ deploy_repo: String::new(),
91+ deploy_webhook: String::new(),
92+ deploy_secret: String::new(),
93+ deploy_branch: "main".to_string(),
6594 }
6695 }
6796 }
6897
98+impl CiConfig {
99+ /// Whether `owner/name` on `branch` is the configured deploy target.
100+ pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
101+ !self.deploy_repo.is_empty()
102+ && !self.deploy_webhook.is_empty()
103+ && self.deploy_repo == format!("{owner}/{name}")
104+ && self.deploy_branch == branch
105+ }
106+}
107+
69108 impl Default for HttpConfig {
70109 fn default() -> Self {
71110 Self {
⋯ 44 unchanged lines
116155 self.data_dir.join("repositories")
117156 }
118157
158+ /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
159+ /// Derived from the public base URL's scheme, so local plaintext dev still
160+ /// works while production behind TLS gets `Secure` automatically.
161+ pub fn secure_cookies(&self) -> bool {
162+ self.http.base_url.starts_with("https://")
163+ }
164+
119165 /// The HTTP clone URL for `<owner>/<name>`, e.g.
120166 /// `http://localhost:3000/alice/hello.git`.
121167 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
⋯ 24 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+60 −12
⋯ 17 unchanged lines
1818 pub mod users;
1919
2020 pub use config::Config;
21-pub use error::{
22- Error,
23- Result,
24-};
25-pub use models::{
26- CiRun,
27- Repository,
28- Session,
29- SshKey,
30- User,
31-};
21+pub use error::{Error, Result};
22+pub use models::{CiRun, Repository, Session, SshKey, User};
3223
3324 /// Current Unix time in seconds, for `created_at` columns.
3425 pub(crate) fn now() -> i64 {
⋯ 11 unchanged lines
4637 pub struct App {
4738 pub config: Config,
4839 pub db: toasty::Db,
40+ /// Notifies the CI runner of newly-enqueued run ids. `None` until the runner
41+ /// is started (e.g. CLI commands don't run CI). Use [`App::notify_ci`].
42+ pub ci_tx: Option<tokio::sync::mpsc::UnboundedSender<i64>>,
43+ /// Server-wide secret keying CSRF tokens. Persisted in the data dir so
44+ /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap.
45+ csrf_secret: std::sync::Arc<[u8; 32]>,
4946 }
5047
5148 impl App {
⋯ 4 unchanged lines
5653 std::fs::create_dir_all(config.repositories_dir())?;
5754
5855 let db = db::connect(config.database_path()).await?;
56+ let csrf_secret = std::sync::Arc::new(load_or_create_csrf_secret(&config.data_dir)?);
57+
58+ Ok(Self {
59+ config,
60+ db,
61+ ci_tx: None,
62+ csrf_secret,
63+ })
64+ }
65+
66+ /// Notify the CI runner that `run_id` is queued (no-op if no runner).
67+ pub fn notify_ci(&self, run_id: i64) {
68+ if let Some(tx) = &self.ci_tx {
69+ let _ = tx.send(run_id);
70+ }
71+ }
72+
73+ /// The CSRF token bound to a given session token: `HMAC-SHA256(secret,
74+ /// session)`, hex-encoded. Stable for a session's lifetime, unguessable
75+ /// without the server secret, and requires no extra storage.
76+ pub fn csrf_token(&self, session_token: &str) -> String {
77+ use hmac::{Hmac, Mac};
78+ let mut mac = Hmac::<sha2::Sha256>::new_from_slice(self.csrf_secret.as_slice())
79+ .expect("HMAC accepts any key length");
80+ mac.update(session_token.as_bytes());
81+ mac.finalize()
82+ .into_bytes()
83+ .iter()
84+ .map(|b| format!("{b:02x}"))
85+ .collect()
86+ }
87+}
88+
89+/// Load the persistent CSRF secret, generating and saving it on first run.
90+fn load_or_create_csrf_secret(data_dir: &std::path::Path) -> Result<[u8; 32]> {
91+ use argon2::password_hash::rand_core::{OsRng, RngCore};
5992
60- Ok(Self { config, db })
93+ let path = data_dir.join("csrf_secret");
94+ if path.exists() {
95+ let bytes = std::fs::read(&path)?;
96+ if let Ok(secret) = <[u8; 32]>::try_from(bytes.as_slice()) {
97+ return Ok(secret);
98+ }
99+ // Malformed (truncated/extended) — regenerate rather than run weak.
100+ }
101+ let mut secret = [0u8; 32];
102+ OsRng.fill_bytes(&mut secret);
103+ std::fs::write(&path, secret)?;
104+ #[cfg(unix)]
105+ {
106+ use std::os::unix::fs::PermissionsExt;
107+ let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
61108 }
109+ Ok(secret)
62110 }
modifiedcrates/anvil-git/src/browse.rs+62 −8
⋯ 2 unchanged lines
33 //! These helpers back the web UI. Each opens the bare repo by path; that is
44 //! cheap enough at our scale and keeps the API stateless.
55
6-use std::collections::{
7- BTreeMap,
8- BTreeSet,
9-};
6+use std::collections::{BTreeMap, BTreeSet};
107 use std::fmt::Display;
118 use std::path::Path;
129
13-use crate::error::{
14- Error,
15- Result,
16-};
10+use crate::error::{Error, Result};
1711
1812 /// Summary of a repository's refs and state, for the repo landing page.
1913 pub struct Overview {
⋯ 256 unchanged lines
276270 Ok(())
277271 }
278272
273+/// A file materialized from a tree: path, executable bit, and content.
274+pub struct TreeFile {
275+ pub path: String,
276+ pub executable: bool,
277+ pub content: Vec<u8>,
278+}
279+
280+/// Read every file in the tree at `rev`, recursively — for materializing a
281+/// checkout (e.g. a CI workspace). Loads all contents into memory, which is
282+/// fine for the modest repos this targets.
283+pub fn read_tree_files(repo_path: &Path, rev: &str) -> Result<Vec<TreeFile>> {
284+ let repo = gix::open(repo_path).map_err(read)?;
285+ let tree = repo
286+ .rev_parse_single(rev)
287+ .map_err(read)?
288+ .object()
289+ .map_err(read)?
290+ .peel_to_commit()
291+ .map_err(read)?
292+ .tree()
293+ .map_err(read)?;
294+ let mut out = Vec::new();
295+ collect_files(&repo, &tree, "", &mut out)?;
296+ Ok(out)
297+}
298+
299+fn collect_files(
300+ repo: &gix::Repository,
301+ tree: &gix::Tree,
302+ prefix: &str,
303+ out: &mut Vec<TreeFile>,
304+) -> Result<()> {
305+ for e in tree.iter() {
306+ let e = e.map_err(read)?;
307+ let name = e.filename().to_string();
308+ let path = if prefix.is_empty() {
309+ name
310+ } else {
311+ format!("{prefix}/{name}")
312+ };
313+ let mode = e.mode();
314+ if mode.is_tree() {
315+ let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
316+ collect_files(repo, &sub, &path, out)?;
317+ } else if mode.is_blob() {
318+ let content = repo
319+ .find_object(e.oid().to_owned())
320+ .map_err(read)?
321+ .data
322+ .clone();
323+ out.push(TreeFile {
324+ path,
325+ executable: mode.is_executable(),
326+ content,
327+ });
328+ }
329+ }
330+ Ok(())
331+}
332+
279333 /// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None`
280334 /// if the path does not exist or is not a blob.
281335 pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> {
⋯ 19 unchanged lines
modifiedcrates/anvil-ssh/src/lib.rs+19 −42
⋯ 6 unchanged lines
77 //! The SSH bind address is configurable (`[ssh] listen` in the config).
88
99 use std::net::SocketAddr;
10-use std::path::{
11- Path,
12- PathBuf,
13-};
10+use std::path::{Path, PathBuf};
1411 use std::sync::Arc;
1512 use std::time::Duration;
1613
17-use anvil_core::{
18- App,
19- Error as CoreError,
20- Result as CoreResult,
21- access,
22- repos,
23- users,
24-};
14+use anvil_core::{App, Error as CoreError, Result as CoreResult, access, repos, users};
2515 use anvil_git::ssh as git_ssh;
26-use russh::keys::ssh_key::{
27- HashAlg,
28- LineEnding,
29- PublicKey,
30-};
31-use russh::keys::{
32- Algorithm,
33- PrivateKey,
34-};
35-use russh::server::{
36- self,
37- Auth,
38- Handler,
39- Msg,
40- Server as _,
41- Session,
42-};
43-use russh::{
44- Channel,
45- ChannelId,
46-};
16+use russh::keys::ssh_key::{HashAlg, LineEnding, PublicKey};
17+use russh::keys::{Algorithm, PrivateKey};
18+use russh::server::{self, Auth, Handler, Msg, Server as _, Session};
19+use russh::{Channel, ChannelId};
4720 use tokio::net::TcpListener;
4821
4922 /// Bind to the configured SSH address and serve git over SSH until shutdown.
⋯ 131 unchanged lines
181154 return fail(session, channel_id, "unsupported command");
182155 };
183156 let need_write = service == anvil_git::Service::ReceivePack;
184- let (path, repo_id) = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await
185- {
186- Ok(resolved) => resolved,
187- Err(message) => return fail(session, channel_id, message),
188- };
157+ let (path, repo_id) =
158+ match authorize_repo(&self.app, self.authed_user, &rel, need_write).await {
159+ Ok(resolved) => resolved,
160+ Err(message) => return fail(session, channel_id, message),
161+ };
189162 let Some(channel) = self.channel.take() else {
190163 return fail(session, channel_id, "no session channel");
191164 };
⋯ 6 unchanged lines
198171
199172 let protocol_v2 = self.protocol_v2;
200173 let handle = session.handle();
201- let db = self.app.db.clone();
174+ let app = self.app.clone();
202175 session.channel_success(channel_id)?;
203176
204177 tokio::spawn(async move {
⋯ 11 unchanged lines
216189 }
217190 };
218191
219- if code == 0 {
220- if let Some(before) = before {
221- anvil_git::trigger::enqueue_ci_for_push(&db, repo_id, &path, &before).await;
192+ if code == 0
193+ && let Some(before) = before
194+ {
195+ for run_id in
196+ anvil_git::trigger::enqueue_ci_for_push(&app.db, repo_id, &path, &before).await
197+ {
198+ app.notify_ci(run_id);
222199 }
223200 }
224201
⋯ 65 unchanged lines
modifiedcrates/anvil-web/src/auth.rs+90 −26
⋯ 2 unchanged lines
33
44 use std::convert::Infallible;
55
6-use anvil_core::{
7- App,
8- User,
9- sessions,
10- users,
11-};
6+use anvil_core::{App, User, sessions, users};
127 use axum::{
138 Form,
14- extract::{
15- FromRequestParts,
16- State,
17- },
18- http::request::Parts,
19- response::{
20- IntoResponse,
21- Redirect,
22- Response,
23- },
9+ extract::{FromRequestParts, Request, State},
10+ http::{StatusCode, request::Parts},
11+ middleware::Next,
12+ response::{IntoResponse, Redirect, Response},
2413 };
25-use axum_extra::extract::cookie::{
26- Cookie,
27- CookieJar,
28- SameSite,
29-};
30-use maud::{
31- Markup,
32- html,
33-};
14+use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
15+use maud::{Markup, html};
3416
3517 use crate::ui::layout;
3618
3719 const SESSION_COOKIE: &str = "anvil_session";
3820
21+/// Hidden form field (and header) name carrying the CSRF token.
22+pub const CSRF_FIELD: &str = "csrf";
23+
24+tokio::task_local! {
25+ /// Request-scoped CSRF token, set by [`csrf_context`] for the duration of
26+ /// each request and read by the layout to populate htmx's `hx-headers`
27+ /// (so JS-driven actions carry the token without a hidden field). Empty for
28+ /// unauthenticated requests.
29+ static CSRF_TOKEN: String;
30+}
31+
32+/// The current request's CSRF token, or empty outside a request scope.
33+pub(crate) fn current_csrf() -> String {
34+ CSRF_TOKEN.try_with(|t| t.clone()).unwrap_or_default()
35+}
36+
37+/// Middleware that derives the session's CSRF token and makes it available to
38+/// the layout (via [`current_csrf`]) for the rest of the request.
39+pub async fn csrf_context(State(app): State<App>, req: Request, next: Next) -> Response {
40+ let token = CookieJar::from_headers(req.headers())
41+ .get(SESSION_COOKIE)
42+ .map(|c| app.csrf_token(c.value()))
43+ .unwrap_or_default();
44+ CSRF_TOKEN.scope(token, next.run(req)).await
45+}
46+
3947 /// Extractor yielding the logged-in user, if any, from the session cookie.
4048 /// Never fails — absence of a valid session simply yields `None`.
4149 pub struct CurrentUser(pub Option<User>);
⋯ 14 unchanged lines
5664 }
5765 }
5866
67+/// Extractor yielding the CSRF token bound to the caller's session, or an empty
68+/// string when unauthenticated. Embed it in forms via [`crate::ui::csrf_input`]
69+/// and verify mutating POSTs with [`verify_csrf`].
70+pub struct Csrf(pub String);
71+
72+impl FromRequestParts<App> for Csrf {
73+ type Rejection = Infallible;
74+
75+ async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> {
76+ let jar = CookieJar::from_headers(&parts.headers);
77+ let token = jar
78+ .get(SESSION_COOKIE)
79+ .map(|c| app.csrf_token(c.value()))
80+ .unwrap_or_default();
81+ Ok(Csrf(token))
82+ }
83+}
84+
85+/// Verify a submitted CSRF token against the session-bound expected value.
86+/// Rejects when unauthenticated (empty expected) or on any mismatch. Comparison
87+/// is constant-time to avoid leaking the token byte-by-byte.
88+pub fn verify_csrf(expected: &Csrf, submitted: &str) -> Result<(), Response> {
89+ let ok =
90+ !expected.0.is_empty() && constant_time_eq(expected.0.as_bytes(), submitted.as_bytes());
91+ if ok {
92+ Ok(())
93+ } else {
94+ Err((StatusCode::FORBIDDEN, "invalid or missing CSRF token").into_response())
95+ }
96+}
97+
98+/// Length-independent constant-time byte comparison.
99+fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
100+ if a.len() != b.len() {
101+ return false;
102+ }
103+ let mut diff = 0u8;
104+ for (x, y) in a.iter().zip(b.iter()) {
105+ diff |= x ^ y;
106+ }
107+ diff == 0
108+}
109+
59110 #[derive(serde::Deserialize)]
60111 pub struct LoginForm {
61112 username: String,
62113 password: String,
63114 }
64115
116+/// A form body carrying only a CSRF token — for POST actions (logout, deletes)
117+/// that otherwise need no fields.
118+#[derive(serde::Deserialize)]
119+pub struct CsrfForm {
120+ #[serde(default)]
121+ pub csrf: String,
122+}
123+
65124 /// `GET /login` — show the login form (or bounce home if already signed in).
66125 pub async fn login_form(CurrentUser(user): CurrentUser) -> Response {
67126 if user.is_some() {
⋯ 28 unchanged lines
96155 let cookie = Cookie::build((SESSION_COOKIE, session.token))
97156 .path("/")
98157 .http_only(true)
158+ .secure(app.config.secure_cookies())
99159 .same_site(SameSite::Lax)
100160 .build();
101161 (jar.add(cookie), Redirect::to("/")).into_response()
⋯ 9 unchanged lines
111171 }
112172 }
113173
114-/// `POST /logout` — destroy the session and clear the cookie.
174+/// `POST /logout` — destroy the session and clear the cookie. Not given an
175+/// explicit CSRF token: `SameSite=Lax` already withholds the session cookie
176+/// from cross-site POSTs (so a forced logout can't identify the session), and
177+/// the impact of a forced logout is trivial. The high-value mutating forms
178+/// (SSH keys, repo creation/visibility) do carry tokens via [`verify_csrf`].
115179 pub async fn logout(State(app): State<App>, jar: CookieJar) -> Response {
116180 if let Some(cookie) = jar.get(SESSION_COOKIE) {
117181 let _ = sessions::delete(&app.db, cookie.value()).await;
⋯ 39 unchanged lines
modifiedcrates/anvil-web/src/git_http.rs+12 −35
⋯ 10 unchanged lines
1111 use std::collections::HashMap;
1212 use std::path::PathBuf;
1313
14-use anvil_core::{
15- App,
16- Repository,
17- access,
18- repos,
19- users,
20-};
21-use anvil_git::smart_http::{
22- self,
23- Service,
24- UploadPack,
25-};
14+use anvil_core::{App, Repository, access, repos, users};
15+use anvil_git::smart_http::{self, Service, UploadPack};
2616 use axum::{
2717 Router,
28- body::{
29- Body,
30- Bytes,
31- },
32- extract::{
33- Path,
34- Query,
35- State,
36- },
37- http::{
38- HeaderMap,
39- StatusCode,
40- header,
41- },
42- response::{
43- IntoResponse,
44- Response,
45- },
46- routing::{
47- get,
48- post,
49- },
18+ body::{Body, Bytes},
19+ extract::{Path, Query, State},
20+ http::{HeaderMap, StatusCode, header},
21+ response::{IntoResponse, Response},
22+ routing::{get, post},
5023 };
5124 use tokio_util::io::ReaderStream;
5225
⋯ 165 unchanged lines
218191 let reader = std::io::Cursor::new(body.to_vec());
219192 match smart_http::receive_pack(&path, reader).await {
220193 Ok(b) => {
221- anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await;
194+ for run_id in
195+ anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await
196+ {
197+ app.notify_ci(run_id);
198+ }
222199 rpc_response(Service::ReceivePack.result_content_type(), Body::from(b))
223200 }
224201 Err(e) => internal(e),
⋯ 2 unchanged lines
modifiedcrates/anvil-web/src/lib.rs+10 −9
⋯ 7 unchanged lines
88 // pattern is intentional and the responses are never hot-path allocated en masse.
99 #![allow(clippy::result_large_err)]
1010
11-use anvil_core::{
12- App,
13- Result,
14-};
11+use anvil_core::{App, Result};
1512 use axum::{
1613 Router,
17- routing::{
18- get,
19- post,
20- },
14+ routing::{get, post},
2115 };
2216
2317 pub mod auth;
⋯ 8 unchanged lines
3226 .route("/-/logout", post(auth::logout));
3327 router = ui::routes(router); // web UI, including `/`
3428 router = git_http::routes(router); // smart-HTTP git endpoints
35- router.with_state(app)
29+ router
30+ // Derives the per-request CSRF token so the layout can attach it to
31+ // htmx requests (hx-headers). Runs for all routes; cheap.
32+ .layer(axum::middleware::from_fn_with_state(
33+ app.clone(),
34+ auth::csrf_context,
35+ ))
36+ .with_state(app)
3637 }
3738
3839 /// Bind to the configured HTTP address and serve until shutdown.
⋯ 13 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+189 −62
⋯ 1 unchanged line
22 //! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
33 //! progressive enhancement is a follow-up.
44
5+use std::collections::HashMap;
56 use std::path::PathBuf;
67 use std::sync::OnceLock;
78
8-use anvil_core::{
9- App,
10- Repository,
11- SshKey,
12- User,
13- access,
14- repos,
15- ssh_keys,
16- users,
17-};
18-use anvil_git::browse::{
19- self,
20- ChangeKind,
21- FileChange,
22-};
9+use anvil_core::{App, CiRun, Repository, SshKey, User, access, ci, repos, ssh_keys, users};
10+use anvil_git::browse::{self, ChangeKind, FileChange};
2311 use axum::{
24- Form,
25- Router,
26- extract::{
27- Path,
28- State,
29- },
30- http::{
31- StatusCode,
32- header,
33- },
34- response::{
35- IntoResponse,
36- Redirect,
37- Response,
38- },
39- routing::{
40- get,
41- post,
42- },
43-};
44-use maud::{
45- DOCTYPE,
46- Markup,
47- PreEscaped,
48- html,
49-};
50-use similar::{
51- ChangeTag,
52- TextDiff,
12+ Form, Router,
13+ extract::{Path, State},
14+ http::{StatusCode, header},
15+ response::{IntoResponse, Redirect, Response},
16+ routing::{get, post},
5317 };
18+use maud::{DOCTYPE, Markup, PreEscaped, html};
19+use similar::{ChangeTag, TextDiff};
5420 use syntect::easy::HighlightLines;
55-use syntect::highlighting::{
56- Theme,
57- ThemeSet,
58-};
59-use syntect::html::{
60- IncludeBackground,
61- styled_line_to_highlighted_html,
62-};
21+use syntect::highlighting::{Theme, ThemeSet};
22+use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
6323 use syntect::parsing::SyntaxSet;
6424 use time::OffsetDateTime;
6525
66-use crate::auth::CurrentUser;
26+use crate::auth::{CSRF_FIELD, Csrf, CurrentUser, verify_csrf};
6727
6828 const STYLE: &str = r#"
6929 :root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
⋯ 51 unchanged lines
12181 table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
12282 .badge { font-size:11px; border-radius:3px; padding:1px 6px; }
12383 .badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
84+.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
85+.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
86+.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
87+.log { background:#0d1117; color:#e6edf3; border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; }
12488 footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
12589 "#;
12690
⋯ 50 unchanged lines
177141 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
178142 .route("/{owner}/{repo}/commits/{rev}", get(commits))
179143 .route("/{owner}/{repo}/commit/{id}", get(commit))
144+ .route("/{owner}/{repo}/ci", get(ci_runs))
145+ .route("/{owner}/{repo}/ci/{id}", get(ci_run))
180146 .route("/-/static/htmx.min.js", get(htmx_js))
181147 }
182148
⋯ 10 unchanged lines
193159 }
194160
195161 pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
162+ // Attach the session's CSRF token to every htmx request as a header, so any
163+ // JS-driven action carries it without a hidden field. Omitted (no attribute)
164+ // when unauthenticated. The token is hex, so it needs no JSON escaping.
165+ let csrf = crate::auth::current_csrf();
166+ let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
196167 html! {
197168 (DOCTYPE)
198169 html lang="en" {
⋯ 3 unchanged lines
202173 title { (title) " · anvil" }
203174 style { (PreEscaped(STYLE)) }
204175 }
205- body hx-boost="true" {
176+ body hx-boost="true" hx-headers=[hx_headers] {
206177 header.top { div.container {
207178 a.brand href="/" { "anvil" }
208179 span style="margin-left:auto" {
⋯ 18 unchanged lines
227198 }
228199 }
229200
201+/// Hidden CSRF token field for embedding inside a mutating `<form>`.
202+pub(crate) fn csrf_input(token: &str) -> Markup {
203+ html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
204+}
205+
230206 fn not_found(message: &str) -> Response {
231207 (
232208 StatusCode::NOT_FOUND,
⋯ 141 unchanged lines
374350 #[serde(default)]
375351 title: String,
376352 key: String,
353+ #[serde(default)]
354+ csrf: String,
377355 }
378356
379357 /// `GET /settings` — account settings: profile + SSH keys.
380-async fn account_settings(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response {
358+async fn account_settings(
359+ State(app): State<App>,
360+ CurrentUser(user): CurrentUser,
361+ csrf: Csrf,
362+) -> Response {
381363 let Some(user) = user else {
382364 return Redirect::to("/-/login").into_response();
383365 };
⋯ 1 unchanged line
385367 Ok(keys) => keys,
386368 Err(e) => return server_error(e),
387369 };
388- account_page(&user, &keys, None).into_response()
370+ account_page(&user, &keys, None, &csrf.0).into_response()
389371 }
390372
391373 /// `POST /settings/keys` — register an SSH public key for the current user.
392374 async fn add_ssh_key(
393375 State(app): State<App>,
394376 CurrentUser(user): CurrentUser,
377+ csrf: Csrf,
395378 Form(form): Form<AddKeyForm>,
396379 ) -> Response {
397380 let Some(user) = user else {
398381 return Redirect::to("/-/login").into_response();
399382 };
383+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
384+ return resp;
385+ }
400386 let result = match ssh_keys::parse_public_key(&form.key) {
401387 Ok((fingerprint, content)) => {
402388 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
⋯ 10 unchanged lines
413399 .unwrap_or_default();
414400 (
415401 StatusCode::BAD_REQUEST,
416- account_page(&user, &keys, Some(&e.to_string())),
402+ account_page(&user, &keys, Some(&e.to_string()), &csrf.0),
417403 )
418404 .into_response()
419405 }
⋯ 4 unchanged lines
424410 async fn delete_ssh_key(
425411 State(app): State<App>,
426412 CurrentUser(user): CurrentUser,
413+ csrf: Csrf,
427414 Path(id): Path<i64>,
415+ Form(form): Form<crate::auth::CsrfForm>,
428416 ) -> Response {
429417 let Some(user) = user else {
430418 return Redirect::to("/-/login").into_response();
431419 };
420+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
421+ return resp;
422+ }
432423 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
433424 return server_error(e);
434425 }
435426 Redirect::to("/-/settings").into_response()
436427 }
437428
438-fn account_page(user: &User, keys: &[SshKey], error: Option<&str>) -> Markup {
429+fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup {
439430 layout(
440431 "Account settings",
441432 Some(user),
⋯ 19 unchanged lines
461452 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
462453 }
463454 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
455+ (csrf_input(csrf))
464456 button.linkbtn type="submit" { "delete" }
465457 }
466458 }
⋯ 2 unchanged lines
469461 }
470462
471463 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
464+ (csrf_input(csrf))
472465 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
473466 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
474467 p { button.btn type="submit" { "Add SSH key" } }
⋯ 20 unchanged lines
495488 #[serde(default)]
496489 description: String,
497490 private: Option<String>,
491+ #[serde(default)]
492+ csrf: String,
498493 }
499494
500495 #[derive(serde::Deserialize)]
⋯ 1 unchanged line
502497 #[serde(default)]
503498 description: String,
504499 private: Option<String>,
500+ #[serde(default)]
501+ csrf: String,
505502 }
506503
507504 /// `GET /new` — new-repository form (requires login).
508-async fn new_repo_form(CurrentUser(user): CurrentUser) -> Response {
505+async fn new_repo_form(CurrentUser(user): CurrentUser, csrf: Csrf) -> Response {
509506 let Some(user) = user else {
510507 return Redirect::to("/-/login").into_response();
511508 };
512- new_repo_page(&user, None, "", "", false).into_response()
509+ new_repo_page(&user, None, "", "", false, &csrf.0).into_response()
513510 }
514511
515512 /// `POST /new` — create a repository owned by the current user.
516513 async fn new_repo_submit(
517514 State(app): State<App>,
518515 CurrentUser(user): CurrentUser,
516+ csrf: Csrf,
519517 Form(form): Form<NewRepoForm>,
520518 ) -> Response {
521519 let Some(user) = user else {
522520 return Redirect::to("/-/login").into_response();
523521 };
522+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
523+ return resp;
524+ }
524525 let private = form.private.is_some();
525526 match repos::create(
526527 &app.db,
⋯ 14 unchanged lines
541542 &form.name,
542543 &form.description,
543544 private,
545+ &csrf.0,
544546 ),
545547 )
546548 .into_response(),
⋯ 6 unchanged lines
553555 name: &str,
554556 description: &str,
555557 private: bool,
558+ csrf: &str,
556559 ) -> Markup {
557560 layout(
558561 "New repository",
⋯ 2 unchanged lines
561564 h1 { "New repository" }
562565 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
563566 form.stack method="post" action="/-/new" {
567+ (csrf_input(csrf))
564568 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
565569 p { label { "Description" br; input type="text" name="description" value=(description); } }
566570 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
⋯ 32 unchanged lines
599603 async fn repo_settings(
600604 State(app): State<App>,
601605 CurrentUser(user): CurrentUser,
606+ csrf: Csrf,
602607 Path((owner, repo)): Path<(String, String)>,
603608 ) -> Response {
604609 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
605610 Ok(m) => m,
606611 Err(resp) => return resp,
607612 };
608- settings_page(user.as_ref(), &owner, &repo, &meta, None).into_response()
613+ settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
609614 }
610615
611616 /// `POST /{owner}/{repo}/settings` — update description / visibility.
612617 async fn repo_settings_submit(
613618 State(app): State<App>,
614619 CurrentUser(user): CurrentUser,
620+ csrf: Csrf,
615621 Path((owner, repo)): Path<(String, String)>,
616622 Form(form): Form<SettingsForm>,
617623 ) -> Response {
⋯ 1 unchanged line
619625 Ok(m) => m,
620626 Err(resp) => return resp,
621627 };
628+ if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
629+ return resp;
630+ }
622631 if let Err(e) =
623632 repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await
624633 {
⋯ 8 unchanged lines
633642 repo: &str,
634643 meta: &Repository,
635644 error: Option<&str>,
645+ csrf: &str,
636646 ) -> Markup {
637647 layout(
638648 &format!("{owner}/{repo}: settings"),
⋯ 2 unchanged lines
641651 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
642652 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
643653 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
654+ (csrf_input(csrf))
644655 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
645656 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
646657 p { button.btn type="submit" { "Save changes" } }
⋯ 48 unchanged lines
695706 a href=(format!("/{owner}")) { (owner) } " / " (repo)
696707 @if meta.is_private { " " span.pill { "private" } }
697708 }
709+ a.btn href=(format!("/{owner}/{repo}/ci")) { "CI" }
698710 @if can_write {
699711 a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" }
700712 }
⋯ 191 unchanged lines
892904 CurrentUser(user): CurrentUser,
893905 Path((owner, repo, rev)): Path<(String, String, String)>,
894906 ) -> Result<Markup, Response> {
895- let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
907+ let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
896908 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
909+
910+ // Map each commit oid to its latest run status, for inline badges. One query
911+ // for the repo's recent runs; first match wins (list is newest-first).
912+ let runs = ci::list_by_repo(&app.db, meta.id, 200)
913+ .await
914+ .unwrap_or_default();
915+ let mut status_of: HashMap<&str, &str> = HashMap::new();
916+ for r in &runs {
917+ status_of
918+ .entry(r.commit.as_str())
919+ .or_insert(r.status.as_str());
920+ }
921+
897922 Ok(layout(
898923 &format!("{owner}/{repo}: commits"),
899924 user.as_ref(),
⋯ 3 unchanged lines
903928 @for c in &log {
904929 li {
905930 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
931+ @if let Some(st) = status_of.get(c.id.as_str()) {
932+ a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
933+ }
906934 span { (c.summary) }
907935 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
908936 }
⋯ 34 unchanged lines
943971 ))
944972 }
945973
974+/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
975+async fn ci_runs(
976+ State(app): State<App>,
977+ CurrentUser(user): CurrentUser,
978+ Path((owner, repo)): Path<(String, String)>,
979+) -> Result<Markup, Response> {
980+ let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
981+ let runs = ci::list_by_repo(&app.db, meta.id, 100)
982+ .await
983+ .map_err(server_error)?;
984+ Ok(layout(
985+ &format!("{owner}/{repo}: CI"),
986+ user.as_ref(),
987+ html! {
988+ h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
989+ @if runs.is_empty() {
990+ p.muted {
991+ "No CI runs yet. Add a " code { ".anvil/ci.yml" }
992+ " pipeline and push to trigger one."
993+ }
994+ } @else {
995+ div.box {
996+ @for r in &runs {
997+ div.row {
998+ a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
999+ (status_badge(&r.status))
1000+ span.sha { (short_commit(&r.commit)) }
1001+ span { (r.ref_name) }
1002+ }
1003+ span.muted { (fmt_time(r.created_at)) }
1004+ }
1005+ }
1006+ }
1007+ }
1008+ },
1009+ ))
1010+}
1011+
1012+/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
1013+async fn ci_run(
1014+ State(app): State<App>,
1015+ CurrentUser(user): CurrentUser,
1016+ Path((owner, repo, id)): Path<(String, String, i64)>,
1017+) -> Result<Markup, Response> {
1018+ let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1019+ let run = ci::get(&app.db, id)
1020+ .await
1021+ .map_err(server_error)?
1022+ .filter(|r| r.repo_id == meta.id)
1023+ .ok_or_else(|| not_found("no such CI run"))?;
1024+ Ok(layout(
1025+ &format!("{owner}/{repo}: CI #{}", run.id),
1026+ user.as_ref(),
1027+ html! {
1028+ h1 {
1029+ a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
1030+ " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1031+ " · #" (run.id)
1032+ }
1033+ p {
1034+ (status_badge(&run.status))
1035+ " "
1036+ a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
1037+ " " span.muted { (run.ref_name) }
1038+ }
1039+ p.muted {
1040+ "queued " (fmt_time(run.created_at))
1041+ @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
1042+ @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
1043+ @if let Some(d) = run_duration(&run) { " · took " (d) }
1044+ }
1045+ @if run.log.is_empty() {
1046+ p.muted { "No output yet." }
1047+ } @else {
1048+ pre.log { (run.log) }
1049+ }
1050+ },
1051+ ))
1052+}
1053+
1054+/// A coloured status pill for a CI run status string.
1055+fn status_badge(status: &str) -> Markup {
1056+ html! { span class=(format!("st {status}")) { (status) } }
1057+}
1058+
1059+/// First 8 hex chars of a commit oid (for compact display).
1060+fn short_commit(commit: &str) -> &str {
1061+ &commit[..commit.len().min(8)]
1062+}
1063+
1064+/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
1065+fn run_duration(run: &CiRun) -> Option<String> {
1066+ if run.started_at > 0 && run.finished_at >= run.started_at {
1067+ Some(format!("{}s", run.finished_at - run.started_at))
1068+ } else {
1069+ None
1070+ }
1071+}
1072+
9461073 /// Render one file's diff (added/deleted/modified) as a unified line diff.
9471074 fn render_file_diff(change: &FileChange) -> Markup {
9481075 let (badge_cls, badge) = match change.kind {
⋯ 107 unchanged lines
modifiedcrates/anvil/Cargo.toml+1 −0
⋯ 15 unchanged lines
1616 anvil-core.workspace = true
1717 anvil-web.workspace = true
1818 anvil-ssh.workspace = true
19+anvil-ci.workspace = true
1920 tokio.workspace = true
2021 clap.workspace = true
2122 anyhow.workspace = true
⋯ 2 unchanged lines
modifiedcrates/anvil/src/main.rs+11 −16
11 //! `anvild` — the anvil git forge daemon and admin CLI.
22
3-use anvil_core::{
4- App,
5- Config,
6- repos,
7- ssh_keys,
8- users,
9-};
10-use anyhow::{
11- Context,
12- Result,
13-};
14-use clap::{
15- Parser,
16- Subcommand,
17-};
3+use anvil_core::{App, Config, repos, ssh_keys, users};
4+use anyhow::{Context, Result};
5+use clap::{Parser, Subcommand};
186
197 #[derive(Parser)]
208 #[command(name = "anvild", version, about = "anvil git forge")]
⋯ 92 unchanged lines
113101 }
114102
115103 async fn serve(config: Config) -> Result<()> {
116- let app = App::bootstrap(config).await?;
104+ let mut app = App::bootstrap(config).await?;
105+
106+ // Start the CI runner: it drains queued runs and processes new ones pushed
107+ // through `app.ci_tx` (set here so handlers can notify it).
108+ let (ci_tx, ci_rx) = tokio::sync::mpsc::unbounded_channel();
109+ app.ci_tx = Some(ci_tx);
110+ tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx));
111+
117112 if app.config.ssh.enabled {
118113 // Run the HTTP and SSH servers concurrently; if either exits, stop.
119114 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
⋯ 95 unchanged lines
modifieddeploy/run.sh+11 −1
⋯ 8 unchanged lines
99 IMAGE="${ANVIL_IMAGE:-anvil:latest}"
1010 NETWORK="${ANVIL_NETWORK:-hagrid}"
1111 SSH_PORT="${ANVIL_SSH_PORT:-2222}"
12+DOCKER_SOCK="${ANVIL_DOCKER_SOCK:-/var/run/docker.sock}"
13+
14+# The CI runner drives Docker via the host socket. Mount it in, and add the
15+# socket's group to the non-root `anvil` user so it can actually open it.
16+# NOTE: socket access = root-equivalent on the host. We accept this because
17+# anvil is a single-tenant, owner-operated forge; CI only runs code the owner
18+# pushed. Do not expose this instance to untrusted users.
19+SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK")"
1220
1321 docker rm -f anvil 2>/dev/null || true
1422 docker run -d \
⋯ 2 unchanged lines
1725 --restart unless-stopped \
1826 -p "${SSH_PORT}:2222" \
1927 -v anvil-data:/data \
28+ -v "${DOCKER_SOCK}:/var/run/docker.sock" \
29+ --group-add "$SOCK_GID" \
2030 "$IMAGE"
2131
22-echo "==> anvil (re)started from $IMAGE (web: anvil:3000 via Caddy, ssh: host :${SSH_PORT})"
32+echo "==> anvil (re)started from $IMAGE (web: anvil:3000 via Caddy, ssh: host :${SSH_PORT}, docker.sock gid ${SOCK_GID})"