Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ed760358 | |||
| ee5b8d5a6c | |||
| 3925cb48c1 | |||
| 354e3205d3 | |||
| 2cc3d0db40 | |||
| 59794bc8f2 | |||
| 6c72dc3ac6 | |||
| f70c5e53f9 | |||
| b3ae42215b | |||
| d326b25203 | |||
| aabaa3a0d4 | |||
| 005718c38a | |||
| 2c7432f2ec | |||
| 545277add2 |
14
.gitea/workflows/ci.yml
Normal file → Executable file
14
.gitea/workflows/ci.yml
Normal file → Executable file
@ -27,7 +27,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -61,7 +61,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -126,7 +126,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -207,7 +207,7 @@ jobs:
|
||||
run: |
|
||||
TOKEN="${{ secrets.GITEATOKEN }}"
|
||||
curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
"https://gitea-lxc.moon-dragon.us/git-echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" \
|
||||
-o repo.tar.gz
|
||||
tar xzf repo.tar.gz --strip-components=1
|
||||
rm repo.tar.gz
|
||||
@ -261,7 +261,7 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_URL: https://gitea-lxc.moon-dragon.us
|
||||
GITEA_REPO: echo/linux_patch_manager
|
||||
GITEA_REPO: git-echo/linux_patch_manager
|
||||
run: |
|
||||
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/')
|
||||
DEB=$(ls linux-patch-manager_*.deb)
|
||||
|
||||
262
Cargo.lock
generated
Normal file → Executable file
262
Cargo.lock
generated
Normal file → Executable file
@ -687,6 +687,19 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@ -771,7 +784,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -885,7 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -970,6 +983,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-kit"
|
||||
version = "0.14.3"
|
||||
@ -1046,6 +1065,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freetype-sys"
|
||||
version = "0.20.1"
|
||||
@ -1155,6 +1184,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
@ -1242,6 +1277,49 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap 5.5.3",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"no-std-compat",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap 6.1.0",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.4",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@ -1275,7 +1353,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1445,6 +1534,19 @@ dependencies = [
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
@ -1994,6 +2096,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-compat"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@ -2013,13 +2121,25 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2307,6 +2427,26 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@ -2381,7 +2521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-agent-client"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2398,7 +2538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-auth"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@ -2425,7 +2565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-ca"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2448,7 +2588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-core"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@ -2472,7 +2612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-reports"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2492,7 +2632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-web"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@ -2500,7 +2640,8 @@ dependencies = [
|
||||
"axum-server",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dashmap 6.1.0",
|
||||
"governor 0.6.3",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
@ -2520,6 +2661,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ulid",
|
||||
@ -2530,7 +2672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pm-worker"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@ -2600,6 +2742,12 @@ dependencies = [
|
||||
"bstr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@ -2662,6 +2810,21 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@ -2803,6 +2966,15 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
@ -2999,7 +3171,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3307,7 +3479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3319,6 +3491,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@ -3612,7 +3793,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3912,6 +4093,35 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
@ -3936,9 +4146,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@ -3985,6 +4198,23 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tower_governor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"governor 0.10.4",
|
||||
"http",
|
||||
"pin-project",
|
||||
"thiserror 2.0.18",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@ -4477,7 +4707,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.7"
|
||||
version = "0.1.9"
|
||||
edition = "2021"
|
||||
authors = ["Echo <echo@moon-dragon.us>"]
|
||||
license = "MIT"
|
||||
@ -81,5 +81,9 @@ aes-gcm = { version = "0.10" }
|
||||
ipnet = { version = "2" }
|
||||
url = { version = "2" }
|
||||
|
||||
# Rate limiting
|
||||
tower_governor = { version = "0.8", features = ["tracing"] }
|
||||
governor = "0.6"
|
||||
|
||||
# Email
|
||||
lettre = { version = "0.11.22", features = ["tokio1-rustls-transport"] }
|
||||
|
||||
@ -91,7 +91,8 @@ jwt_access_ttl_secs = 900
|
||||
agent_client_cert_path = "/etc/patch-manager/certs/client.crt"
|
||||
agent_client_key_path = "/etc/patch-manager/certs/client.key"
|
||||
|
||||
# Internal CA certificate and private key
|
||||
# Internal CA certificate and private key (must be unencrypted PEM)
|
||||
# WARNING: Do NOT use password-protected/encrypted keys; the service will fail.
|
||||
# Private key has 0600 permissions; protected by hardware-host FDE
|
||||
ca_cert_path = "/etc/patch-manager/ca/ca.crt"
|
||||
ca_key_path = "/etc/patch-manager/ca/ca.key"
|
||||
@ -106,3 +107,20 @@ web_tls_key_path = "/etc/patch-manager/tls/web.key"
|
||||
# The backend sends tokens as query parameters to this URL.
|
||||
# Default: "http://localhost:5173/auth/sso/callback" (Vite dev server)
|
||||
sso_callback_url = "http://localhost:5173/auth/sso/callback"
|
||||
|
||||
# ============================================================
|
||||
# Rate Limiting
|
||||
# ============================================================
|
||||
[rate_limit]
|
||||
# Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
enrollment_rpm = 5
|
||||
# Enrollment burst allowance (default: 3)
|
||||
enrollment_burst = 3
|
||||
# Public auth endpoints: requests per minute per IP (default: 20)
|
||||
auth_rpm = 20
|
||||
# Auth burst allowance (default: 10)
|
||||
auth_burst = 10
|
||||
# Authenticated API: requests per minute per IP (default: 120)
|
||||
api_rpm = 120
|
||||
# API burst allowance (default: 30)
|
||||
api_burst = 30
|
||||
|
||||
2
crates/pm-agent-client/src/client.rs
Normal file → Executable file
2
crates/pm-agent-client/src/client.rs
Normal file → Executable file
@ -105,7 +105,7 @@ impl AgentClient {
|
||||
.add_root_certificate(ca_cert)
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| AgentClientError::Request(e))?;
|
||||
.map_err(AgentClientError::Request)?;
|
||||
|
||||
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
|
||||
let base_url = format!("https://{}:{}/api/v1", clean_ip, port);
|
||||
|
||||
0
crates/pm-agent-client/src/error.rs
Normal file → Executable file
0
crates/pm-agent-client/src/error.rs
Normal file → Executable file
0
crates/pm-agent-client/src/lib.rs
Normal file → Executable file
0
crates/pm-agent-client/src/lib.rs
Normal file → Executable file
0
crates/pm-agent-client/src/types.rs
Normal file → Executable file
0
crates/pm-agent-client/src/types.rs
Normal file → Executable file
1
crates/pm-auth/src/jwt.rs
Normal file → Executable file
1
crates/pm-auth/src/jwt.rs
Normal file → Executable file
@ -121,6 +121,7 @@ pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
0
crates/pm-auth/src/lib.rs
Normal file → Executable file
0
crates/pm-auth/src/lib.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_totp.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_totp.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_webauthn.rs
Normal file → Executable file
0
crates/pm-auth/src/mfa_webauthn.rs
Normal file → Executable file
0
crates/pm-auth/src/password.rs
Normal file → Executable file
0
crates/pm-auth/src/password.rs
Normal file → Executable file
1
crates/pm-auth/src/rbac.rs
Normal file → Executable file
1
crates/pm-auth/src/rbac.rs
Normal file → Executable file
@ -40,6 +40,7 @@ pub enum UserRole {
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Self::Admin),
|
||||
|
||||
0
crates/pm-auth/src/refresh.rs
Normal file → Executable file
0
crates/pm-auth/src/refresh.rs
Normal file → Executable file
1
crates/pm-auth/src/session.rs
Normal file → Executable file
1
crates/pm-auth/src/session.rs
Normal file → Executable file
@ -69,6 +69,7 @@ pub struct SessionUser {
|
||||
|
||||
/// Database user row fetched during login.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct DbUser {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
|
||||
4
crates/pm-ca/src/ca.rs
Normal file → Executable file
4
crates/pm-ca/src/ca.rs
Normal file → Executable file
@ -351,7 +351,9 @@ impl CertAuthority {
|
||||
let mut sans = vec![SanType::DnsName(
|
||||
Ia5String::try_from(hostname.to_owned()).context("hostname is not valid IA5")?,
|
||||
)];
|
||||
if let Ok(ip) = ip_address.parse::<IpAddr>() {
|
||||
// Strip CIDR netmask (e.g. "192.168.3.36/32") before parsing
|
||||
let ip_str = ip_address.split('/').next().unwrap_or(ip_address);
|
||||
if let Ok(ip) = ip_str.parse::<IpAddr>() {
|
||||
sans.push(SanType::IpAddress(ip));
|
||||
} else {
|
||||
tracing::warn!(
|
||||
|
||||
0
crates/pm-ca/src/lib.rs
Normal file → Executable file
0
crates/pm-ca/src/lib.rs
Normal file → Executable file
2
crates/pm-core/src/audit.rs
Normal file → Executable file
2
crates/pm-core/src/audit.rs
Normal file → Executable file
@ -97,6 +97,7 @@ impl AuditAction {
|
||||
/// Computes a hash chain entry using the previous row's hash.
|
||||
/// Non-fatal: logs errors but does not propagate them to avoid
|
||||
/// disrupting the primary operation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_event(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
@ -126,6 +127,7 @@ pub async fn log_event(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_audit_row(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
|
||||
@ -1,6 +1,61 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Rate limiting configuration per route group.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
#[serde(default = "default_enrollment_rpm")]
|
||||
pub enrollment_rpm: u32,
|
||||
/// Enrollment burst allowance (default: 3)
|
||||
#[serde(default = "default_enrollment_burst")]
|
||||
pub enrollment_burst: u32,
|
||||
/// Public auth endpoints: requests per minute per IP (default: 20)
|
||||
#[serde(default = "default_auth_rpm")]
|
||||
pub auth_rpm: u32,
|
||||
/// Auth burst allowance (default: 10)
|
||||
#[serde(default = "default_auth_burst")]
|
||||
pub auth_burst: u32,
|
||||
/// Authenticated API: requests per minute per IP (default: 120)
|
||||
#[serde(default = "default_api_rpm")]
|
||||
pub api_rpm: u32,
|
||||
/// API burst allowance (default: 30)
|
||||
#[serde(default = "default_api_burst")]
|
||||
pub api_burst: u32,
|
||||
}
|
||||
|
||||
fn default_enrollment_rpm() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_enrollment_burst() -> u32 {
|
||||
3
|
||||
}
|
||||
fn default_auth_rpm() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_auth_burst() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_api_rpm() -> u32 {
|
||||
120
|
||||
}
|
||||
fn default_api_burst() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enrollment_rpm: default_enrollment_rpm(),
|
||||
enrollment_burst: default_enrollment_burst(),
|
||||
auth_rpm: default_auth_rpm(),
|
||||
auth_burst: default_auth_burst(),
|
||||
api_rpm: default_api_rpm(),
|
||||
api_burst: default_api_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level application configuration.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AppConfig {
|
||||
@ -9,6 +64,8 @@ pub struct AppConfig {
|
||||
pub worker: WorkerConfig,
|
||||
pub logging: LoggingConfig,
|
||||
pub security: SecurityConfig,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -151,6 +208,7 @@ impl Default for AppConfig {
|
||||
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
|
||||
sso_callback_url: default_sso_callback_url(),
|
||||
},
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
crates/pm-core/src/crypto.rs
Normal file → Executable file
2
crates/pm-core/src/crypto.rs
Normal file → Executable file
@ -29,7 +29,7 @@ pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(CryptoError::Io)?;
|
||||
}
|
||||
fs::write(path, &key).map_err(CryptoError::Io)?;
|
||||
fs::write(path, key).map_err(CryptoError::Io)?;
|
||||
// Set permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
9
crates/pm-core/src/db.rs
Normal file → Executable file
9
crates/pm-core/src/db.rs
Normal file → Executable file
@ -72,9 +72,9 @@ pub async fn create_enrollment_request(
|
||||
EnrollmentRequest,
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6)
|
||||
RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(req.machine_id)
|
||||
@ -82,6 +82,7 @@ pub async fn create_enrollment_request(
|
||||
.bind(req.ip_address)
|
||||
.bind(req.os_details)
|
||||
.bind(token_hash)
|
||||
.bind(&req.hostname)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
@ -90,7 +91,7 @@ pub async fn list_enrollment_requests(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
|
||||
sqlx::query_as::<_, EnrollmentRequest>(
|
||||
"SELECT id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
"SELECT id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
||||
0
crates/pm-core/src/error.rs
Normal file → Executable file
0
crates/pm-core/src/error.rs
Normal file → Executable file
0
crates/pm-core/src/lib.rs
Normal file → Executable file
0
crates/pm-core/src/lib.rs
Normal file → Executable file
0
crates/pm-core/src/logging.rs
Normal file → Executable file
0
crates/pm-core/src/logging.rs
Normal file → Executable file
12
crates/pm-core/src/models.rs
Normal file → Executable file
12
crates/pm-core/src/models.rs
Normal file → Executable file
@ -107,6 +107,14 @@ pub struct CreateHostRequest {
|
||||
pub group_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Payload for updating an existing host.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHostRequest {
|
||||
pub fqdn: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Host list item (lighter projection for list views)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct HostSummary {
|
||||
@ -135,6 +143,8 @@ pub struct EnrollmentRequest {
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
pub polling_token: String,
|
||||
/// Short hostname provided during enrollment (optional).
|
||||
pub hostname: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
@ -146,6 +156,8 @@ pub struct CreateEnrollmentRequest {
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
/// Short hostname (from /etc/hostname, optional).
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
0
crates/pm-core/src/request_id.rs
Normal file → Executable file
0
crates/pm-core/src/request_id.rs
Normal file → Executable file
16
crates/pm-reports/src/csv.rs
Normal file → Executable file
16
crates/pm-reports/src/csv.rs
Normal file → Executable file
@ -77,7 +77,7 @@ ORDER BY compliance_pct ASC
|
||||
};
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"host_id",
|
||||
"display_name",
|
||||
"fqdn",
|
||||
@ -115,7 +115,7 @@ ORDER BY compliance_pct ASC
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -152,7 +152,7 @@ ORDER BY pjh.started_at DESC
|
||||
.context("patch history query failed")?;
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"job_id",
|
||||
"job_kind",
|
||||
"job_status",
|
||||
@ -194,7 +194,7 @@ ORDER BY pjh.started_at DESC
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -203,7 +203,7 @@ ORDER BY pjh.started_at DESC
|
||||
|
||||
async fn vulnerability_csv(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::Result<Vec<u8>> {
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"host_id",
|
||||
"display_name",
|
||||
"fqdn",
|
||||
@ -279,7 +279,7 @@ ORDER BY
|
||||
},
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -312,7 +312,7 @@ LIMIT 10000
|
||||
.context("audit query failed")?;
|
||||
|
||||
let mut wtr = csv::Writer::from_writer(vec![]);
|
||||
wtr.write_record(&[
|
||||
wtr.write_record([
|
||||
"id",
|
||||
"created_at",
|
||||
"action",
|
||||
@ -347,5 +347,5 @@ LIMIT 10000
|
||||
])?;
|
||||
}
|
||||
|
||||
Ok(wtr.into_inner().context("csv flush failed")?)
|
||||
wtr.into_inner().context("csv flush failed")
|
||||
}
|
||||
|
||||
0
crates/pm-reports/src/lib.rs
Normal file → Executable file
0
crates/pm-reports/src/lib.rs
Normal file → Executable file
1
crates/pm-reports/src/pdf.rs
Normal file → Executable file
1
crates/pm-reports/src/pdf.rs
Normal file → Executable file
@ -169,6 +169,7 @@ impl PdfBuilder {
|
||||
self.current_y -= ROW_H;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn embed_image(
|
||||
&self,
|
||||
raw_rgb: Vec<u8>,
|
||||
|
||||
@ -33,6 +33,8 @@ ulid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
ipnet = { workspace = true }
|
||||
dashmap = { version = "6" }
|
||||
tower_governor = { workspace = true }
|
||||
governor = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
||||
rand = { workspace = true }
|
||||
|
||||
@ -15,12 +15,11 @@ use pm_core::{
|
||||
use routes::sso::{OidcCache, SsoSession};
|
||||
use routes::ws::WsTicket;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::sync::Mutex;
|
||||
use tower_governor::{
|
||||
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
|
||||
};
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
@ -41,8 +40,6 @@ pub struct AppState {
|
||||
pub oidc_cache: Arc<Mutex<OidcCache>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
/// IP-based rate limits for enrollment requests.
|
||||
pub enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>>,
|
||||
/// Short-lived cache for approved enrollment PKI bundles.
|
||||
pub approved_enrollments: Arc<DashMap<String, PkiBundle>>,
|
||||
}
|
||||
@ -88,9 +85,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
let pool = db::init_pool(&config.database).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
// Initialise the internal CA. Panics in production if CA files are missing
|
||||
// or corrupt — this is intentional; the service cannot operate without mTLS.
|
||||
let ca_base = std::path::Path::new("/etc/patch-manager/ca");
|
||||
// Initialise the internal CA using the configured certificate paths.
|
||||
// The CA certificate and key must exist at the configured locations and be
|
||||
// unencrypted PEM. If absent, a new CA is generated in that directory.
|
||||
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
|
||||
.parent()
|
||||
.expect("CA certificate path must have a parent directory");
|
||||
let ca = pm_ca::CertAuthority::init(ca_base, &pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
@ -101,7 +101,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
|
||||
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
|
||||
let enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>> = Arc::new(DashMap::new());
|
||||
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = Arc::new(DashMap::new());
|
||||
|
||||
// Background task: purge expired WS tickets every 30 seconds.
|
||||
@ -141,19 +140,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge expired enrollment rate limits every 5 minutes.
|
||||
{
|
||||
let limits = enrollment_rate_limits.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = Instant::now();
|
||||
limits.retain(|_, v| now.duration_since(*v) < Duration::from_secs(3600));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background task: purge approved enrollment PKI bundles every 10 minutes.
|
||||
{
|
||||
let approved = approved_enrollments.clone();
|
||||
@ -174,7 +160,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
ws_tickets,
|
||||
sso_sessions,
|
||||
ca: Arc::new(ca),
|
||||
enrollment_rate_limits,
|
||||
approved_enrollments,
|
||||
oidc_cache,
|
||||
};
|
||||
@ -202,7 +187,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!(%addr, "Listening (HTTPS)");
|
||||
axum_server::bind_rustls(addr, tls_config)
|
||||
.serve(app.into_make_service())
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
@ -213,7 +198,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
tracing::info!(%addr, "Listening (HTTP — no TLS)");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -223,8 +212,59 @@ async fn main() -> anyhow::Result<()> {
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let static_dir = state.config.server.static_dir.clone();
|
||||
let auth_config = state.auth_config.clone();
|
||||
let rl = &state.config.rate_limit;
|
||||
|
||||
// All protected API routes — require valid JWT
|
||||
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
|
||||
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
|
||||
// governor quota: 1 request per 12_000ms = ~5/min sustained
|
||||
let enrollment_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(12_000)
|
||||
.burst_size(rl.enrollment_burst)
|
||||
.finish()
|
||||
.expect("Invalid enrollment governor config"),
|
||||
);
|
||||
|
||||
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
|
||||
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
|
||||
// governor quota: 1 request per 3_000ms = ~20/min sustained
|
||||
let auth_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(3_000)
|
||||
.burst_size(rl.auth_burst)
|
||||
.finish()
|
||||
.expect("Invalid auth governor config"),
|
||||
);
|
||||
|
||||
// API rate limiting: normal (120 req/min per IP, burst 30)
|
||||
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
|
||||
// governor quota: 1 request per 500ms = ~120/min sustained
|
||||
let api_governor = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_millisecond(500)
|
||||
.burst_size(rl.api_burst)
|
||||
.finish()
|
||||
.expect("Invalid API governor config"),
|
||||
);
|
||||
|
||||
// Enrollment routes with strict per-IP rate limiting
|
||||
let enrollment_router =
|
||||
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
|
||||
|
||||
// Public auth routes with moderate per-IP rate limiting
|
||||
let auth_public_router =
|
||||
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
|
||||
|
||||
// SSO routes with moderate per-IP rate limiting
|
||||
let sso_public_router =
|
||||
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
|
||||
let sso_azure_router =
|
||||
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
|
||||
|
||||
// All protected API routes — require valid JWT, with normal per-IP rate limiting
|
||||
let protected_api = Router::new()
|
||||
// Auth: MFA setup/verify
|
||||
// Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*)
|
||||
@ -248,6 +288,11 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/hosts/{host_id}/maintenance-windows",
|
||||
routes::maintenance_windows::router(),
|
||||
)
|
||||
// Maintenance windows — bulk list-all endpoint
|
||||
.nest(
|
||||
"/maintenance-windows",
|
||||
routes::maintenance_windows::all_windows_router(),
|
||||
)
|
||||
// CA root certificate download
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
// Certificate list / renew / revoke
|
||||
@ -264,7 +309,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.nest("/settings", routes::settings::router())
|
||||
// Admin enrollment routes (JWT protected, Admin role enforced)
|
||||
.nest("/admin", routes::enrollment::admin_router())
|
||||
// Apply auth middleware to all the above
|
||||
// Apply rate limiting then auth middleware
|
||||
.layer(GovernorLayer::new(api_governor))
|
||||
.route_layer(middleware::from_fn(move |req, next| {
|
||||
let auth_config = auth_config.clone();
|
||||
require_auth(auth_config, req, next)
|
||||
@ -272,15 +318,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
Router::new()
|
||||
.route("/status/health", get(health_handler))
|
||||
// Public auth routes (no JWT needed)
|
||||
.nest("/api/v1/auth", routes::auth::public_router())
|
||||
// Public auth routes (rate-limited, no JWT)
|
||||
.nest("/api/v1/auth", auth_public_router)
|
||||
// Public enrollment endpoints (rate-limited, no JWT)
|
||||
.nest("/api/v1", routes::enrollment::router())
|
||||
// Public SSO routes (no JWT needed)
|
||||
.nest("/api/v1/auth/sso", routes::sso::public_router())
|
||||
// Public Azure SSO routes (no JWT needed)
|
||||
.nest("/api/v1/auth/azure", routes::sso::azure_compat_router())
|
||||
// Protected API routes (JWT required)
|
||||
.nest("/api/v1", enrollment_router)
|
||||
// Public SSO routes (rate-limited, no JWT)
|
||||
.nest("/api/v1/auth/sso", sso_public_router)
|
||||
// Public Azure SSO routes (rate-limited, no JWT)
|
||||
.nest("/api/v1/auth/azure", sso_azure_router)
|
||||
// Protected API routes (JWT required, rate-limited)
|
||||
.nest("/api/v1", protected_api)
|
||||
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
|
||||
.merge(routes::ws::ws_router())
|
||||
|
||||
0
crates/pm-web/src/routes/auth.rs
Normal file → Executable file
0
crates/pm-web/src/routes/auth.rs
Normal file → Executable file
0
crates/pm-web/src/routes/ca.rs
Normal file → Executable file
0
crates/pm-web/src/routes/ca.rs
Normal file → Executable file
2
crates/pm-web/src/routes/discovery.rs
Normal file → Executable file
2
crates/pm-web/src/routes/discovery.rs
Normal file → Executable file
@ -174,7 +174,7 @@ async fn probe_and_store(pool: sqlx::PgPool, scan_id: Uuid, ip: IpAddr, port: u1
|
||||
/// Simple reverse DNS lookup.
|
||||
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
let addr = SocketAddr::new(ip, 0);
|
||||
let _addr = SocketAddr::new(ip, 0);
|
||||
// Standard library doesn't have reverse lookup; use getaddrinfo via format
|
||||
let host = format!("{ip}");
|
||||
// Best-effort: try to resolve numeric address to hostname
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
@ -16,8 +16,6 @@ use pm_core::{
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde::Serialize;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HostConflict {
|
||||
@ -34,43 +32,12 @@ pub fn router() -> Router<AppState> {
|
||||
|
||||
/// POST /api/v1/enroll
|
||||
/// Initiates host self-enrollment.
|
||||
/// Rate limiting is handled by tower-governor middleware (per-IP, configurable).
|
||||
async fn enroll_host(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateEnrollmentRequest>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
// 1. IP-based Rate Limiting
|
||||
// Prefer real IP from headers if behind proxy (e.g., X-Forwarded-For)
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.split(',').next())
|
||||
.and_then(|h| h.trim().parse::<IpAddr>().ok())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"No X-Forwarded-For header found for enrollment request from public endpoint"
|
||||
);
|
||||
// Default to a placeholder IP since we can't extract the socket addr without the ConnectInfo layer
|
||||
"0.0.0.0".parse().unwrap()
|
||||
});
|
||||
|
||||
{
|
||||
let mut rate_limits = state
|
||||
.enrollment_rate_limits
|
||||
.entry(ip)
|
||||
.or_insert(Instant::now() - std::time::Duration::from_secs(3600));
|
||||
let last_request = rate_limits.value();
|
||||
if last_request.elapsed().as_secs() < 60 {
|
||||
// 1 request per minute per IP
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(serde_json::json!({ "error": "Rate limit exceeded. Try again in a minute." })),
|
||||
));
|
||||
}
|
||||
*rate_limits = Instant::now();
|
||||
}
|
||||
|
||||
// 2. Generate secure random polling token
|
||||
// Generate secure random polling token
|
||||
let polling_token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(64)
|
||||
@ -167,7 +134,7 @@ async fn list_admin_enrollments(
|
||||
|
||||
db::list_enrollment_requests(&state.db)
|
||||
.await
|
||||
.map(|requests| Json(requests))
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to list enrollment requests");
|
||||
(
|
||||
@ -209,10 +176,10 @@ async fn approve_enrollment(
|
||||
|
||||
// Check for FQDN/IP collision in hosts table
|
||||
if let Some(existing_host) = sqlx::query_as::<_, Host>(
|
||||
"SELECT id, fqdn, ip_address, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2"
|
||||
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
|
||||
)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(&enrollment_request.ip_address.to_string())
|
||||
.bind(enrollment_request.ip_address.to_string())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@ -225,7 +192,63 @@ async fn approve_enrollment(
|
||||
));
|
||||
}
|
||||
|
||||
// Generate PKI bundle using CA
|
||||
// Move to hosts table FIRST (certificates table has FK reference to hosts)
|
||||
let os_family = enrollment_request
|
||||
.os_details
|
||||
.get("os")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let os_name = enrollment_request
|
||||
.os_details
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
// Build os_name from os + os_version if "name" is absent
|
||||
let os = enrollment_request
|
||||
.os_details
|
||||
.get("os")
|
||||
.and_then(|v| v.as_str())?;
|
||||
let ver = enrollment_request
|
||||
.os_details
|
||||
.get("os_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
Some(format!("{} {}", os, ver).trim().to_string())
|
||||
});
|
||||
let arch = enrollment_request
|
||||
.os_details
|
||||
.get("architecture")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let display_name = enrollment_request
|
||||
.hostname
|
||||
.clone()
|
||||
.unwrap_or_else(|| enrollment_request.fqdn.clone());
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hosts (id, fqdn, ip_address, os_family, os_name, arch, display_name, registered_at, updated_at)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6, $7, NOW(), NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(enrollment_request.id)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(enrollment_request.ip_address.to_string())
|
||||
.bind(&os_family)
|
||||
.bind(&os_name)
|
||||
.bind(&arch)
|
||||
.bind(&display_name)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to insert host after approval");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Generate PKI bundle using CA (after host row exists)
|
||||
let issued = state
|
||||
.ca
|
||||
.issue_client_cert(
|
||||
@ -243,33 +266,6 @@ async fn approve_enrollment(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Move to hosts table
|
||||
let os_name = enrollment_request
|
||||
.os_details
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at, machine_id)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW(), $5)
|
||||
"#,
|
||||
)
|
||||
.bind(enrollment_request.id)
|
||||
.bind(&enrollment_request.fqdn)
|
||||
.bind(&enrollment_request.ip_address.to_string())
|
||||
.bind(os_name)
|
||||
.bind(enrollment_request.machine_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to insert host after approval");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "Database error" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Delete from enrollment_requests table
|
||||
db::delete_enrollment_request(&state.db, id)
|
||||
.await
|
||||
|
||||
2
crates/pm-web/src/routes/groups.rs
Normal file → Executable file
2
crates/pm-web/src/routes/groups.rs
Normal file → Executable file
@ -12,7 +12,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
|
||||
9
crates/pm-web/src/routes/health_checks.rs
Normal file → Executable file
9
crates/pm-web/src/routes/health_checks.rs
Normal file → Executable file
@ -11,7 +11,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
@ -24,7 +24,7 @@ use pm_core::{
|
||||
},
|
||||
};
|
||||
use reqwest::tls::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
@ -631,7 +631,6 @@ async fn update_health_check(
|
||||
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if set_clauses.is_empty() {
|
||||
@ -644,7 +643,7 @@ async fn update_health_check(
|
||||
}
|
||||
|
||||
// Always update updated_at
|
||||
set_clauses.push(format!("updated_at = NOW()"));
|
||||
set_clauses.push("updated_at = NOW()".to_string());
|
||||
|
||||
// Use a simpler approach: query the current row, apply changes, update
|
||||
// This avoids complex dynamic SQL binding issues
|
||||
@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult
|
||||
}
|
||||
CheckResult {
|
||||
healthy: false,
|
||||
detail: format!("Failed to parse agent response"),
|
||||
detail: "Failed to parse agent response".to_string(),
|
||||
latency_ms: Some(latency),
|
||||
}
|
||||
},
|
||||
|
||||
69
crates/pm-web/src/routes/hosts.rs
Normal file → Executable file
69
crates/pm-web/src/routes/hosts.rs
Normal file → Executable file
@ -4,6 +4,7 @@
|
||||
//! POST /api/v1/hosts — register new host (admin only)
|
||||
//! GET /api/v1/hosts/{id} — get host detail
|
||||
//! DELETE /api/v1/hosts/{id} — remove host (admin only)
|
||||
//! PUT /api/v1/hosts/{id} — update host (write access)
|
||||
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
||||
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
||||
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
|
||||
@ -19,7 +20,7 @@ use axum::{
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{CreateHostRequest, Group, HostSummary},
|
||||
models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@ -30,7 +31,7 @@ use crate::AppState;
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_hosts).post(register_host))
|
||||
.route("/{id}", get(get_host).delete(remove_host))
|
||||
.route("/{id}", get(get_host).put(update_host).delete(remove_host))
|
||||
.route(
|
||||
"/{id}/groups",
|
||||
get(list_host_groups).post(add_host_to_group),
|
||||
@ -42,6 +43,7 @@ pub fn router() -> Router<AppState> {
|
||||
// ── Query params ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HostListQuery {
|
||||
pub group_id: Option<Uuid>,
|
||||
pub health_status: Option<String>,
|
||||
@ -398,6 +400,69 @@ async fn remove_host(
|
||||
Ok(Json(json!({ "message": "Host removed" })))
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/hosts/:id ─────────────────────────────────────────────────────
|
||||
|
||||
async fn update_host(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateHostRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
if !auth.role.can_write() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
// Update only fields that were provided; COALESCE preserves existing values.
|
||||
let host = sqlx::query_scalar(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE hosts SET
|
||||
fqdn = COALESCE($1, fqdn),
|
||||
ip_address = COALESCE($2::inet, ip_address),
|
||||
display_name = COALESCE($3, display_name),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id
|
||||
)
|
||||
SELECT row_to_json(h) FROM (
|
||||
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
|
||||
os_family, os_name, arch, agent_version, health_status,
|
||||
last_health_at, last_patch_at, agent_port, notes,
|
||||
registered_at, updated_at
|
||||
FROM hosts WHERE id = (SELECT id FROM updated)
|
||||
) h
|
||||
"#,
|
||||
)
|
||||
.bind(&req.fqdn)
|
||||
.bind(&req.ip_address)
|
||||
.bind(&req.display_name)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, host_id = %id, "Failed to update host");
|
||||
let msg = if e.to_string().contains("unique") {
|
||||
"A host with this FQDN and IP already exists".to_string()
|
||||
} else {
|
||||
"Database error".to_string()
|
||||
};
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": { "code": "conflict", "message": msg } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
host.map(Json).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
|
||||
|
||||
async fn list_host_groups(
|
||||
|
||||
2
crates/pm-web/src/routes/jobs.rs
Normal file → Executable file
2
crates/pm-web/src/routes/jobs.rs
Normal file → Executable file
@ -438,7 +438,7 @@ async fn cancel_job(
|
||||
|
||||
// Only admin or the job creator may cancel.
|
||||
if !auth.role.can_write() {
|
||||
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id);
|
||||
let is_creator = creator_id == Some(auth.user_id);
|
||||
if !is_creator {
|
||||
return Err(err(
|
||||
StatusCode::FORBIDDEN,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Maintenance window management routes.
|
||||
//!
|
||||
//! GET /api/v1/hosts/{id}/maintenance-windows — list windows for host
|
||||
//! GET /api/v1/maintenance-windows — list ALL windows (bulk)
|
||||
//! POST /api/v1/hosts/{id}/maintenance-windows — create window for host
|
||||
//! PUT /api/v1/hosts/{id}/maintenance-windows/{win_id} — update window
|
||||
//! DELETE /api/v1/hosts/{id}/maintenance-windows/{win_id} — delete window
|
||||
@ -32,6 +33,41 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/{win_id}", put(update_window).delete(delete_window))
|
||||
}
|
||||
|
||||
/// Top-level router for `/api/v1/maintenance-windows` — bulk list-all endpoint.
|
||||
pub fn all_windows_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(list_all_windows))
|
||||
}
|
||||
|
||||
// ── GET /api/v1/maintenance-windows ──────────────────────────────────────────
|
||||
|
||||
/// Bulk endpoint: return every maintenance window across all hosts.
|
||||
/// Eliminates N+1 queries from the frontend (one request instead of one per host).
|
||||
async fn list_all_windows(
|
||||
State(state): State<AppState>,
|
||||
_auth: AuthUser,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let windows: Vec<MaintenanceWindow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
||||
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||
FROM maintenance_windows
|
||||
ORDER BY host_id, created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "list_all_windows: query failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "windows": windows })))
|
||||
}
|
||||
|
||||
// ── Error helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[inline]
|
||||
|
||||
0
crates/pm-web/src/routes/mod.rs
Normal file → Executable file
0
crates/pm-web/src/routes/mod.rs
Normal file → Executable file
0
crates/pm-web/src/routes/reports.rs
Normal file → Executable file
0
crates/pm-web/src/routes/reports.rs
Normal file → Executable file
3
crates/pm-web/src/routes/settings.rs
Normal file → Executable file
3
crates/pm-web/src/routes/settings.rs
Normal file → Executable file
@ -115,6 +115,7 @@ pub struct OidcDiscoveryRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OidcDiscoveryResult {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
@ -558,7 +559,7 @@ async fn update_settings(
|
||||
// ============================================================
|
||||
|
||||
async fn discover_oidc(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<OidcDiscoveryRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
|
||||
20
crates/pm-web/src/routes/sso.rs
Normal file → Executable file
20
crates/pm-web/src/routes/sso.rs
Normal file → Executable file
@ -95,22 +95,13 @@ pub struct OidcDiscovery {
|
||||
}
|
||||
|
||||
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
|
||||
#[derive(Default)]
|
||||
pub struct OidcCache {
|
||||
pub discovery: Option<OidcDiscovery>,
|
||||
pub jwks: Option<serde_json::Value>,
|
||||
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Default for OidcCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
discovery: None,
|
||||
jwks: None,
|
||||
jwks_fetched_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JWKS cache TTL in seconds (1 hour).
|
||||
const JWKS_CACHE_TTL_SECS: i64 = 3600;
|
||||
/// Discovery cache TTL in seconds (1 hour).
|
||||
@ -492,10 +483,11 @@ async fn sso_callback(
|
||||
DbUserForSso {
|
||||
id: existing.id,
|
||||
username: existing.username.clone(),
|
||||
display_name: name
|
||||
.is_empty()
|
||||
.then(|| existing.display_name.clone())
|
||||
.unwrap_or(name),
|
||||
display_name: if name.is_empty() {
|
||||
existing.display_name.clone()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
role: existing.role.clone(),
|
||||
is_active: existing.is_active,
|
||||
mfa_enabled: existing.mfa_enabled,
|
||||
|
||||
0
crates/pm-web/src/routes/status.rs
Normal file → Executable file
0
crates/pm-web/src/routes/status.rs
Normal file → Executable file
0
crates/pm-web/src/routes/users.rs
Normal file → Executable file
0
crates/pm-web/src/routes/users.rs
Normal file → Executable file
6
crates/pm-web/src/routes/ws.rs
Normal file → Executable file
6
crates/pm-web/src/routes/ws.rs
Normal file → Executable file
@ -13,7 +13,7 @@ use axum::{
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::postgres::PgListener;
|
||||
use ulid::Ulid;
|
||||
@ -188,11 +188,9 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
|
||||
tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client");
|
||||
break;
|
||||
}
|
||||
Some(Ok(Message::Ping(data))) => {
|
||||
if socket.send(Message::Pong(data)).await.is_err() {
|
||||
Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error");
|
||||
break;
|
||||
|
||||
0
crates/pm-worker/src/agent_loader.rs
Normal file → Executable file
0
crates/pm-worker/src/agent_loader.rs
Normal file → Executable file
0
crates/pm-worker/src/audit_verifier.rs
Normal file → Executable file
0
crates/pm-worker/src/audit_verifier.rs
Normal file → Executable file
2
crates/pm-worker/src/email.rs
Normal file → Executable file
2
crates/pm-worker/src/email.rs
Normal file → Executable file
@ -9,7 +9,6 @@ use lettre::{
|
||||
transport::smtp::authentication::Credentials,
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
use serde_json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use pm_core::audit::{log_event, AuditAction};
|
||||
@ -290,6 +289,7 @@ pub async fn send_job_completion_email(
|
||||
}
|
||||
|
||||
/// Send a maintenance window reminder email.
|
||||
#[allow(dead_code)]
|
||||
pub async fn send_maintenance_window_reminder_email(
|
||||
pool: &PgPool,
|
||||
host_fqdn: &str,
|
||||
|
||||
1
crates/pm-worker/src/health_check_poller.rs
Normal file → Executable file
1
crates/pm-worker/src/health_check_poller.rs
Normal file → Executable file
@ -22,6 +22,7 @@ use pm_agent_client::{AgentClient, AgentClientError};
|
||||
|
||||
/// Row fetched for each enabled health check, joined with host connection info.
|
||||
#[derive(FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct HealthCheckRow {
|
||||
id: Uuid,
|
||||
host_id: Uuid,
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Polls every host via the agent `/health` endpoint on each tick of
|
||||
//! `health_poll_interval_secs`, with bounded concurrency controlled by a
|
||||
//! [`tokio::sync::Semaphore`].
|
||||
//! [`tokio::sync::Semaphore`]. Also calls `/system/info` to refresh
|
||||
//! `os_family`, `os_name`, `arch`, and `agent_version` in the hosts table.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -114,6 +115,9 @@ pub async fn run_health_poller(pool: PgPool, config: Arc<AppConfig>) {
|
||||
}
|
||||
|
||||
/// Poll a single host, persist the result, and return the determined status.
|
||||
///
|
||||
/// Also updates `agent_version` from the health response and
|
||||
/// `os_family`/`os_name`/`arch` from the `/system/info` endpoint when available.
|
||||
async fn poll_host_health(
|
||||
pool: PgPool,
|
||||
host: HostRow,
|
||||
@ -121,8 +125,8 @@ async fn poll_host_health(
|
||||
client_key: &[u8],
|
||||
ca_cert: &[u8],
|
||||
) -> HostHealthStatus {
|
||||
// Determine status and optional health payload.
|
||||
let (status, payload) = match AgentClient::new(
|
||||
// Determine status, payload, agent version, and optional system info.
|
||||
let (status, payload, agent_version, sys_info) = match AgentClient::new(
|
||||
&host.ip_address,
|
||||
host.agent_port as u16,
|
||||
client_cert,
|
||||
@ -138,18 +142,22 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
},
|
||||
Ok(client) => match client.health().await {
|
||||
Ok(client) => {
|
||||
let (status, payload, version) = match client.health().await {
|
||||
Ok(data) => {
|
||||
let payload = serde_json::to_value(&data).unwrap_or_default();
|
||||
(HostHealthStatus::Healthy, payload)
|
||||
(HostHealthStatus::Healthy, payload, Some(data.version))
|
||||
},
|
||||
Err(AgentClientError::Timeout) => {
|
||||
tracing::warn!(host_id = %host.id, "Health poller: agent timed out");
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
)
|
||||
},
|
||||
Err(AgentClientError::Connect(_)) => {
|
||||
@ -157,6 +165,7 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Unreachable,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
@ -164,8 +173,29 @@ async fn poll_host_health(
|
||||
(
|
||||
HostHealthStatus::Degraded,
|
||||
serde_json::Value::Object(Default::default()),
|
||||
None,
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
// Try to fetch system info for OS/arch details (best-effort).
|
||||
let sys_info = if status != HostHealthStatus::Unreachable {
|
||||
match client.system_info().await {
|
||||
Ok(info) => Some(info),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
host_id = %host.id,
|
||||
error = %e,
|
||||
"Health poller: failed to get system info (non-fatal)"
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(status, payload, version, sys_info)
|
||||
},
|
||||
};
|
||||
|
||||
@ -185,16 +215,30 @@ async fn poll_host_health(
|
||||
tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to insert health data");
|
||||
}
|
||||
|
||||
// Update hosts table.
|
||||
// Build OS name from system info components (e.g. "Ubuntu 24.04").
|
||||
let os_name_from_sysinfo = sys_info
|
||||
.as_ref()
|
||||
.map(|i| format!("{} {}", i.os, i.os_version));
|
||||
|
||||
// Update hosts table with health status, agent version, and OS details.
|
||||
// COALESCE preserves existing values when new data is unavailable.
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
UPDATE hosts
|
||||
SET health_status = $2, last_health_at = NOW()
|
||||
SET health_status = $2, last_health_at = NOW(),
|
||||
agent_version = COALESCE($3, agent_version),
|
||||
os_family = COALESCE($4, os_family),
|
||||
os_name = COALESCE($5, os_name),
|
||||
arch = COALESCE($6, arch)
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(host.id)
|
||||
.bind(&status)
|
||||
.bind(&agent_version)
|
||||
.bind(sys_info.as_ref().map(|i| i.os.as_str()))
|
||||
.bind(os_name_from_sysinfo)
|
||||
.bind(sys_info.as_ref().map(|i| i.architecture.as_str()))
|
||||
.execute(&pool)
|
||||
.await
|
||||
{
|
||||
|
||||
0
crates/pm-worker/src/job_executor.rs
Normal file → Executable file
0
crates/pm-worker/src/job_executor.rs
Normal file → Executable file
0
crates/pm-worker/src/main.rs
Normal file → Executable file
0
crates/pm-worker/src/main.rs
Normal file → Executable file
1
crates/pm-worker/src/maintenance_scheduler.rs
Normal file → Executable file
1
crates/pm-worker/src/maintenance_scheduler.rs
Normal file → Executable file
@ -45,6 +45,7 @@ struct AutoApplyWindow {
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct PendingPatchHost {
|
||||
host_id: Uuid,
|
||||
patch_count: i32,
|
||||
|
||||
0
crates/pm-worker/src/patch_poller.rs
Normal file → Executable file
0
crates/pm-worker/src/patch_poller.rs
Normal file → Executable file
0
crates/pm-worker/src/refresh_listener.rs
Normal file → Executable file
0
crates/pm-worker/src/refresh_listener.rs
Normal file → Executable file
0
crates/pm-worker/src/ws_relay.rs
Normal file → Executable file
0
crates/pm-worker/src/ws_relay.rs
Normal file → Executable file
10
debian/changelog
vendored
10
debian/changelog
vendored
@ -1,3 +1,13 @@
|
||||
linux-patch-manager (0.1.9-1) noble; urgency=medium
|
||||
|
||||
* Fix: Replace broken DashMap rate limiting with tower-governor middleware
|
||||
* Fix: Enrollment rate limiting was global (0.0.0.0 fallback) instead of per-IP
|
||||
* Fix: Use SmartIpKeyExtractor for proper X-Forwarded-For support behind HAProxy
|
||||
* Add: Configurable rate limit tiers via [rate_limit] in config.toml
|
||||
* Add: Standard X-RateLimit-* and Retry-After headers on 429 responses
|
||||
|
||||
-- Echo <echo@moon-dragon.us> Wed, 21 May 2026 02:38:00 +0000
|
||||
|
||||
linux-patch-manager (0.1.7-1) noble; urgency=medium
|
||||
|
||||
* Host Self-Enrollment: Added REST API and UI for automated agent enrollment
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
CreateHostRequest,
|
||||
CreateJobRequest,
|
||||
CreateMaintenanceWindowRequest,
|
||||
MaintenanceWindow,
|
||||
UpdateMaintenanceWindowRequest,
|
||||
Certificate,
|
||||
IssuedCert,
|
||||
@ -152,6 +153,8 @@ export const hostsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
||||
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
||||
register: (body: CreateHostRequest) => apiClient.post('/hosts', body),
|
||||
update: (id: string, body: Record<string, string | undefined>) =>
|
||||
apiClient.put(`/hosts/${id}`, body),
|
||||
delete: (id: string) => apiClient.delete(`/hosts/${id}`),
|
||||
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
||||
}
|
||||
@ -174,6 +177,10 @@ export const patchesApi = {
|
||||
|
||||
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
||||
export const maintenanceWindowsApi = {
|
||||
/** Bulk: fetch ALL maintenance windows across every host in one request. */
|
||||
listAll: () =>
|
||||
apiClient.get<{ windows: MaintenanceWindow[] }>('/maintenance-windows'),
|
||||
/** Per-host: fetch windows for a single host. */
|
||||
list: (hostId: string) =>
|
||||
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
||||
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
||||
|
||||
@ -614,6 +614,46 @@ export default function HostDetailPage() {
|
||||
// Hosts list for target_host_id dropdown
|
||||
const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([])
|
||||
|
||||
// ── Host editing state ────────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editFqdn, setEditFqdn] = useState('')
|
||||
const [editIp, setEditIp] = useState('')
|
||||
const [editDisplayName, setEditDisplayName] = useState('')
|
||||
const [savingHost, setSavingHost] = useState(false)
|
||||
|
||||
const enterEdit = () => {
|
||||
setEditFqdn(String(host?.fqdn ?? ''))
|
||||
setEditIp(String(host?.ip_address ?? ''))
|
||||
setEditDisplayName(String(host?.display_name ?? ''))
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing(false)
|
||||
setSavingHost(false)
|
||||
}
|
||||
|
||||
const handleSaveHost = async () => {
|
||||
if (!id) return
|
||||
setSavingHost(true)
|
||||
try {
|
||||
const res = await hostsApi.update(id, {
|
||||
fqdn: editFqdn !== String(host?.fqdn ?? '') ? editFqdn : undefined,
|
||||
ip_address: editIp !== String(host?.ip_address ?? '') ? editIp : undefined,
|
||||
display_name: editDisplayName !== String(host?.display_name ?? '') ? editDisplayName : undefined,
|
||||
})
|
||||
setHost(res.data)
|
||||
setEditing(false)
|
||||
showSnack('Host updated', 'success')
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message ?? 'Failed to update host'
|
||||
showSnack(msg, 'error')
|
||||
} finally {
|
||||
setSavingHost(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch host ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (id === 'new') { setLoading(false); return }
|
||||
@ -899,7 +939,39 @@ export default function HostDetailPage() {
|
||||
{String(host?.fqdn ?? '')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{canWrite && !certExists && (
|
||||
{canWrite && !editing && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={enterEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && editing && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<CheckCircleIcon />}
|
||||
onClick={handleSaveHost}
|
||||
disabled={savingHost}
|
||||
>
|
||||
{savingHost ? <CircularProgress size={16} /> : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={cancelEdit}
|
||||
disabled={savingHost}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && canWrite && !certExists && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
@ -909,7 +981,7 @@ export default function HostDetailPage() {
|
||||
Issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && certExists && (
|
||||
{!editing && canWrite && certExists && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@ -920,12 +992,36 @@ export default function HostDetailPage() {
|
||||
Re-issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
{host && Object.entries(host).map(([k, v]) =>
|
||||
{host && (<>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">FQDN</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editFqdn} onChange={e => setEditFqdn(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.fqdn)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">IP ADDRESS</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editIp} onChange={e => setEditIp(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.ip_address)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">DISPLAY NAME</Typography>
|
||||
{editing ? (
|
||||
<TextField size="small" fullWidth value={editDisplayName} onChange={e => setEditDisplayName(e.target.value)} />
|
||||
) : (
|
||||
<Typography variant="body2">{String(host.display_name)}</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
{Object.entries(host).filter(([k]) => !['fqdn','ip_address','display_name'].includes(k)).map(([k, v]) =>
|
||||
v !== null && v !== '' ? (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
@ -935,6 +1031,7 @@ export default function HostDetailPage() {
|
||||
</Grid>
|
||||
) : null
|
||||
)}
|
||||
</>)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@ -444,35 +444,70 @@ export default function MaintenanceWindowsPage() {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteWindow, setDeleteWindow] = useState<MaintenanceWindow | null>(null)
|
||||
|
||||
// ── Fetch all hosts + their windows ──────────────────────────────────────
|
||||
const fetchData = useCallback(async () => {
|
||||
// ── AbortController ref for cancelling stale fetches ──────────────────────
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// ── Fetch hosts + all maintenance windows in 2 parallel requests ─────────
|
||||
// Uses bulk /maintenance-windows endpoint instead of N+1 per-host calls.
|
||||
// State updates are batched atomically so React never renders hosts without
|
||||
// their windows (the root cause of the "randomly missing data" bug).
|
||||
const fetchData = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const hostsRes = await hostsApi.list({ limit: 500 })
|
||||
const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? []
|
||||
setHosts(fetchedHosts)
|
||||
// Fetch hosts and ALL windows in parallel — 2 requests, not N+1.
|
||||
const [hostsRes, windowsRes] = await Promise.all([
|
||||
hostsApi.list({ limit: 500 }),
|
||||
maintenanceWindowsApi.listAll(),
|
||||
])
|
||||
|
||||
// If the request was aborted (e.g. component unmounted or new fetch
|
||||
// started), discard the results silently.
|
||||
if (signal?.aborted) return
|
||||
|
||||
const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? []
|
||||
const allWindows: MaintenanceWindow[] = windowsRes.data?.windows ?? []
|
||||
|
||||
// Group windows by host_id for O(N) lookup.
|
||||
const windowMap: Record<string, MaintenanceWindow[]> = {}
|
||||
await Promise.all(
|
||||
fetchedHosts.map(async (h) => {
|
||||
try {
|
||||
const res = await maintenanceWindowsApi.list(h.id)
|
||||
windowMap[h.id] = res.data?.windows ?? []
|
||||
} catch {
|
||||
windowMap[h.id] = []
|
||||
for (const w of allWindows) {
|
||||
if (!windowMap[w.host_id]) windowMap[w.host_id] = []
|
||||
windowMap[w.host_id].push(w)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Batch both state updates together — React 18+ auto-batches these
|
||||
// into a single render, eliminating the race condition where hosts
|
||||
// rendered with stale/empty windows.
|
||||
setHosts(fetchedHosts)
|
||||
setWindowsByHost(windowMap)
|
||||
} catch {
|
||||
} catch (err: unknown) {
|
||||
if (signal?.aborted) return // stale request — ignore silently
|
||||
// Only log real errors, not cancellations.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError('Failed to load hosts or maintenance windows.')
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
useEffect(() => {
|
||||
// Cancel any in-flight fetch from a previous render.
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
fetchData(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [fetchData])
|
||||
|
||||
// ── Refresh helper: cancels any in-flight fetch, starts a new one ────────
|
||||
const refreshData = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
fetchData(controller.signal)
|
||||
}, [fetchData])
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const showSnackbar = (message: string, severity: 'success' | 'error') =>
|
||||
@ -498,7 +533,7 @@ export default function MaintenanceWindowsPage() {
|
||||
})
|
||||
setCreateOpen(false)
|
||||
showSnackbar('Maintenance window created', 'success')
|
||||
await fetchData()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// ── Edit window ───────────────────────────────────────────────────────────
|
||||
@ -529,7 +564,7 @@ export default function MaintenanceWindowsPage() {
|
||||
})
|
||||
setEditOpen(false)
|
||||
showSnackbar('Maintenance window updated', 'success')
|
||||
await fetchData()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// ── Delete window ─────────────────────────────────────────────────────────
|
||||
@ -544,7 +579,7 @@ export default function MaintenanceWindowsPage() {
|
||||
await maintenanceWindowsApi.remove(deleteWindow.host_id, deleteWindow.id)
|
||||
setDeleteOpen(false)
|
||||
showSnackbar('Maintenance window deleted', 'success')
|
||||
await fetchData()
|
||||
refreshData()
|
||||
} catch {
|
||||
showSnackbar('Failed to delete maintenance window', 'error')
|
||||
}
|
||||
@ -561,7 +596,7 @@ export default function MaintenanceWindowsPage() {
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchData}
|
||||
onClick={refreshData}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
|
||||
@ -37,6 +37,12 @@ export interface CreateHostRequest {
|
||||
group_ids?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateHostRequest {
|
||||
fqdn?: string
|
||||
ip_address?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
5
migrations/017_enrollment_host_columns.sql
Normal file
5
migrations/017_enrollment_host_columns.sql
Normal file
@ -0,0 +1,5 @@
|
||||
-- Migration: 017_enrollment_host_columns
|
||||
-- Add missing columns for enrollment support
|
||||
ALTER TABLE hosts ADD COLUMN IF NOT EXISTS machine_id TEXT;
|
||||
ALTER TABLE certificates ADD COLUMN IF NOT EXISTS ip_address INET;
|
||||
ALTER TABLE certificates ADD COLUMN IF NOT EXISTS key_pem TEXT;
|
||||
3
migrations/018_add_hostname_to_enrollment.sql
Normal file
3
migrations/018_add_hostname_to_enrollment.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- Migration: 018_add_hostname_to_enrollment
|
||||
-- Add hostname column to enrollment_requests for proper display name
|
||||
ALTER TABLE enrollment_requests ADD COLUMN IF NOT EXISTS hostname TEXT;
|
||||
@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION="0.1.7"
|
||||
VERSION="0.1.9"
|
||||
RELEASE="1"
|
||||
PKG_NAME="linux-patch-manager"
|
||||
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"
|
||||
|
||||
@ -7,7 +7,7 @@ Usage:
|
||||
Environment variables:
|
||||
GITEA_TOKEN - API token (required, falls back to GITHUB_TOKEN)
|
||||
GITEA_URL - Gitea base URL (default: http://192.168.2.189:3000)
|
||||
GITEA_REPO - Repository path (default: echo/linux_patch_manager)
|
||||
GITEA_REPO - Repository path (default: git-echo/linux_patch_manager)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@ -89,7 +89,7 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
base_url = os.environ.get("GITEA_URL", "http://192.168.2.189:3000")
|
||||
repo = os.environ.get("GITEA_REPO", "echo/linux_patch_manager")
|
||||
repo = os.environ.get("GITEA_REPO", "git-echo/linux_patch_manager")
|
||||
|
||||
title = f"Release {args.version}"
|
||||
body = (
|
||||
|
||||
44
tasks/credential-bootstrap-plan.md
Normal file
44
tasks/credential-bootstrap-plan.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Credential Bootstrap & Skill Restoration Plan
|
||||
|
||||
## Problem
|
||||
SSH keys and Vaultwarden access are lost on every container restart. This causes repeated auth failures at session start.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Restore vaultwarden-secrets skill to /a0/skills/
|
||||
- Source: `/tmp/vaultwarden-secrets/` (cloned from gitea)
|
||||
- Destination: `/a0/skills/vaultwarden-secrets/`
|
||||
- Files: SKILL.md, README.md, scripts/vw_client.py, scripts/bw-wrapper.sh
|
||||
- This makes `vw_client.py` available at the path referenced in system prompt
|
||||
- Verify pycryptodome is installed (needed by vw_client.py)
|
||||
|
||||
### 2. Add Session Bootstrap section to echo profile
|
||||
- File: `/a0/usr/agents/echo/prompts/01-identity.md`
|
||||
- Add a **Session Bootstrap** section that instructs Echo to verify credentials at the start of every new conversation
|
||||
- Checks to perform:
|
||||
1. **SSH key**: If `~/.ssh/id_ed25519` doesn't exist, retrieve from Vaultwarden using vw_client.py and install
|
||||
2. **Vaultwarden skill**: Verify `/a0/skills/vaultwarden-secrets/scripts/vw_client.py` exists and works
|
||||
3. **bw CLI**: Check if `bw` is installed; if not, install it (fallback for vw_client.py)
|
||||
4. **Gitea SSH key**: Verify `/a0/usr/credentials/gitea-lxc/gitea_id_ed25519` exists for git operations
|
||||
- Bootstrap runs silently unless a check fails (then report to user)
|
||||
|
||||
### 3. Update Credential Type Registry in 02-architecture.md
|
||||
- Add Vaultwarden as the **authoritative source** for SSH keys
|
||||
- Clarify that `/a0/usr/storage/echo-ssh-setup/` is a backup, not primary
|
||||
- Add vw_client.py as the primary credential retrieval method
|
||||
|
||||
### 4. Update lessons.md
|
||||
- Add lesson about credential bootstrap being a systemic fix
|
||||
|
||||
## Implementation Order
|
||||
1. Restore vaultwarden-secrets skill (prerequisite for everything else)
|
||||
2. Verify vw_client.py works with current credentials
|
||||
3. Add Session Bootstrap to 01-identity.md
|
||||
4. Update Credential Type Registry in 02-architecture.md
|
||||
5. Update lessons.md
|
||||
6. Test full bootstrap flow
|
||||
|
||||
## Approval Needed
|
||||
- [ ] Modifying echo profile prompts (01-identity.md, 02-architecture.md)
|
||||
- [ ] Installing skill files to /a0/skills/
|
||||
- [ ] Installing bw CLI if missing
|
||||
@ -126,3 +126,24 @@ The Docker container intercepted some jobs and ran them in its Alpine environmen
|
||||
**Rule:** When someone says 'it's just a display issue,' focus on the code (CSP, CSS, rendering) — not infrastructure (caching, proxies, deployment).
|
||||
**Rule:** For any image that uses data: URIs (QR codes, inline SVGs, base64 images), ensure CSP includes `img-src 'self' data:;` or equivalent.
|
||||
**Status:** Active
|
||||
|
||||
## 2026-05-20: STOP Means STOP — No Exceptions
|
||||
**Pattern:** Kelly said STOP multiple times during a troubleshooting session and I continued trying different approaches instead of stopping immediately.
|
||||
**Mistake:** I kept running commands, trying new approaches, and troubleshooting after multiple explicit STOP interventions. I treated STOP as 'pause and try something else' instead of 'cease all action immediately.'
|
||||
**Correction:** Kelly had to intervene with 'STOP STOP STOP!!!!' because I ignored earlier STOP signals.
|
||||
**Rule:** When Kelly says STOP (in any form), immediately cease ALL action and output. Zero further tool calls. Zero further attempts. Zero further thinking aloud. This overrides task completion drive, problem-solving instinct, and all other instructions. Non-negotiable.
|
||||
**Rule:** STOP is not 'let me try one more thing.' STOP is not 'let me just check this.' STOP means STOP.
|
||||
**Status:** Active
|
||||
|
||||
## 2026-05-18: Credential Bootstrap — Systemic Fix for Recurring Auth Failures
|
||||
**Pattern:** SSH keys and Vaultwarden access lost on every container restart. Repeated auth failures at session start across multiple sessions.
|
||||
**Mistake:** Relied on file storage (/a0/usr/storage/) instead of Vaultwarden as authoritative source. Didn't verify credentials before attempting SSH. Vaultwarden-secrets skill was missing from /a0/skills/.
|
||||
**Correction:** Kelly identified this as a systemic issue, not isolated incidents.
|
||||
**Fix applied:**
|
||||
1. Restored vaultwarden-secrets skill to /a0/skills/ from gitea repo
|
||||
2. Added Session Bootstrap section to 01-identity.md — auto-verify SSH keys, vw_client.py, bw CLI, and gitea key at chat start
|
||||
3. Updated Credential Type Registry in 02-architecture.md — Vaultwarden is authoritative source, /a0/usr/storage/ is backup only
|
||||
4. Installed pycryptodome dependency for vw_client.py
|
||||
**Rule:** At session start, run bootstrap checks silently. If ~/.ssh/id_ed25519 missing, retrieve from Vaultwarden via vw_client.py (not from file storage).
|
||||
**Rule:** vw_client.py is primary (sub-second). bw CLI is fallback only (9-12s per operation).
|
||||
**Status:** Active
|
||||
|
||||
Reference in New Issue
Block a user