complete MCP tool surface — Matrix approval webhook loop + token verification

Plan #6 (MCP Tool Completion / bin/homelab Migration) done.

- Approval records created for gated request_execution actions
- Notifier sends Matrix messages with HMAC approval tokens
- Stores matrix_event_id, polls /relations/{id}/m.annotation for /
- Reaction detection triggers DecideApproval API call
- Token verification added to DecideApproval endpoint
- Migration 013: matrix_event_id + alert_sent_at on approvals
- AGENTS.md: 21-tool surface documented, stale homelab CLI refs removed
- Plan index updated, audit cross-reference refreshed
This commit is contained in:
2026-07-08 11:02:06 +02:00
parent 5b22f2367b
commit 7c6cffb5f5
12 changed files with 907 additions and 235 deletions

View File

@@ -51,29 +51,45 @@ the operator to run `homelab client add <hostname>` from an existing client.
The homelab exposes a Model Context Protocol server with structured tools.
Endpoint: `https://mcp.hubris.network/mcp`.
Available tools:
Available tools (21 total):
Context (read-only):
Context — observe + orient:
get_entity(slug), list_entities(type, limit, cursor),
get_relations(entity), get_blast_radius(entity),
search_knowledge(query) — full-text search over documents, investigations,
runbooks (PostgreSQL FTS, replaces the old Python search_docs)
get_entity_knowledge(slug) — all documents, investigations, and runbooks
linked to an entity
get_topology(), whoami(hostname), list_my_secrets(caller_pubkey?)
search_knowledge(query) — ILIKE search over documents, investigations,
runbooks in the knowledge_entities table
get_patterns(status, entity_type, action) — learned action patterns
get_skills(status) — available automation skills
Management (read-only):
get_service_status(service), tail_log(service, lines=200),
list_lxcs(), get_lxc_state(lxc), ping_service(service)
Management — live state:
get_service_status(service_slug) — systemctl is-active on target host
tail_log(service_slug, lines=200) — journalctl
list_lxcs() — all LXC containers with ID, host, IP, health
get_lxc_state(lxc_slug) — pct status from Proxmox host
ping_service(service_slug) — HTTP reachability from entity_status
Oikos (read-only; see OIKOS.md):
explain(service) — compact context card
preflight(service) — risk class, approval requirement, verification command
get_change_history(entity, limit=20) — change-ledger entries
get_state_snapshot() — last scheduler Observe-pass (health, disk, drift count)
Oikos — decisions:
explain(service_slug) — compact context card (type, state, health, relations)
preflight(service_slug, action) — risk class + approval requirement
whoami(hostname) — entity record, peers, health for a client
get_change_history(entity_slug, limit=20) — last audit-log entries per entity
get_state_snapshot() — fleet health, disk, drift count
Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with
operator confirmation — see OIKOS.md's risk classes and approval flow.
Operations — observe + act:
get_health_summary() — fleet health counts (healthy/degraded/down/unknown)
get_signal_history(entity_slug, state, limit) — open + recent signals
get_audit_trail(entity_id) — audit log filter + browse
get_agent_activity(limit) — agent self-inspection
query_metrics(hours=24) — time-series metric bucketed averages
get_trend(entity_id, days=7) — metric slope over time
get_event_timeline(severity, entity_slug, limit) — recent events
Execution — the single mutation path:
request_execution(target, action, params) — policy-gated.
reversible_low (restart, reload, pct_exec, apt audit) runs immediately;
config_mutation (systemctl enable/disable, apt upgrade) queues for operator
approval via Matrix, then executes on ✅.
get_execution_status(execution_id) — poll progress
**When to prefer MCP over grepping the clone:** always for knowledge queries.
`search_knowledge("jellyfin hardware acceleration")` returns ranked results from
@@ -92,8 +108,8 @@ POST /api/v1/knowledge/{entity_slug}
{"title": "...", "content": "...", "tags": ["..."]}
```
The DB is the truth. The old wiki files are archived at `archive/knowledge/` for
historical reference.
The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive
per the DB-as-source-of-truth plan.
- **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md
files under `.agents/skills/<name>/`. They carry `risk_class`, `procedure`

View File

@@ -1013,6 +1013,9 @@ paths:
- revoke
note:
type: string
token:
type: string
description: HMAC approval token (single-use, verified server-side)
responses:
'200':
description: Decision recorded

View File

@@ -39,6 +39,8 @@ type Approval struct {
DecidedAt *time.Time
DecidedBy *uuid.UUID
CreatedAt time.Time
MatrixEventID *string
AlertSentAt *time.Time
}
type ApprovalRule struct {

View File

@@ -14,7 +14,7 @@ import (
)
const getApprovalByID = `-- name: GetApprovalByID :one
SELECT entity_id, subject_entity_id, action, risk_class, kind, payload, status, token_hash, expires_at, decided_at, decided_by, created_at FROM approvals WHERE entity_id = $1
SELECT entity_id, subject_entity_id, action, risk_class, kind, payload, status, token_hash, expires_at, decided_at, decided_by, created_at, matrix_event_id, alert_sent_at FROM approvals WHERE entity_id = $1
`
func (q *Queries) GetApprovalByID(ctx context.Context, entityID uuid.UUID) (Approval, error) {
@@ -33,6 +33,8 @@ func (q *Queries) GetApprovalByID(ctx context.Context, entityID uuid.UUID) (Appr
&i.DecidedAt,
&i.DecidedBy,
&i.CreatedAt,
&i.MatrixEventID,
&i.AlertSentAt,
)
return i, err
}
@@ -648,7 +650,7 @@ func (q *Queries) ListApprovalRules(ctx context.Context) ([]ApprovalRule, error)
}
const listApprovals = `-- name: ListApprovals :many
SELECT a.entity_id, a.subject_entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.token_hash, a.expires_at, a.decided_at, a.decided_by, a.created_at, e.slug AS subject_slug
SELECT a.entity_id, a.subject_entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.token_hash, a.expires_at, a.decided_at, a.decided_by, a.created_at, a.matrix_event_id, a.alert_sent_at, e.slug AS subject_slug
FROM approvals a
JOIN entities e ON e.id = a.subject_entity_id
WHERE ($1::text IS NULL OR a.status = $1)
@@ -676,6 +678,8 @@ type ListApprovalsRow struct {
DecidedAt *time.Time
DecidedBy *uuid.UUID
CreatedAt time.Time
MatrixEventID *string
AlertSentAt *time.Time
SubjectSlug string
}
@@ -701,6 +705,8 @@ func (q *Queries) ListApprovals(ctx context.Context, arg ListApprovalsParams) ([
&i.DecidedAt,
&i.DecidedBy,
&i.CreatedAt,
&i.MatrixEventID,
&i.AlertSentAt,
&i.SubjectSlug,
); err != nil {
return nil, err

View File

@@ -1069,6 +1069,9 @@ type ListApprovalsParamsKind string
type DecideApprovalJSONBody struct {
Decision DecideApprovalJSONBodyDecision `json:"decision"`
Note *string `json:"note,omitempty"`
// Token HMAC approval token (single-use, verified server-side)
Token *string `json:"token,omitempty"`
}
// DecideApprovalParams defines parameters for DecideApproval.
@@ -8028,166 +8031,166 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/+x963LcuPXnq6C4W5VWwlb7MvPPjvxJkTW2EzvWWpr8NzVytdDk6W5EIMABwJY6LlXl",
"0z7AVp4wT7KFC0GyG2SzL7KcqXyxJREEAZwfDs4dX6KEZzlnwJSMTr5Ec8ApCPPj+RWe6f9TkIkguSKc",
"RSfRJ5C8EAmgBQhJOENTLtC76fADVsk8iiOZzCHD+j21zCE6iaQShM2ih4eHOMqxwBko94GzQkgu1j/x",
"Mce/FIAS8xhNBc8QRrmABeGFRAJkzpmE30jE4F6NbbMojoh+95cCxDKKI4Yz/XH/sH1YcXTOFFHLd+n6",
"SH766d1rxAWStJihARzPjtHNnEt1Mi8mgsibo/KzOVbz6qskjeJIwC8FEZBGJ0oU0GcEl7QILLh91hjC",
"nTzJcDLMCCM3Mbqh98lJgtN02TYe/e6WI/pR8OyK6Le/BBdWU6WxrFMuMqyikyjFCoZKvxoH+n2XQpZz",
"BSxZ/gmW67M9owSYGs6AgcAKUnQLy1dIQE7xUqI7ouaEoRffzZEAVQiG1BwQF2RGGKYeGuUqWDBXg659",
"fKi/Xh9/hu/fA5upeXTy/MX/Cg59ajG+TqErPKtgSrhAb86vXqHvnr9AnPl9khGZuT0SHly1h7Yh1HuS",
"EdVGJWoe1jtIYYoLqqKT75/Fes4kK7Lo5MUz/Rth9rfnfvaEKZiBMB+64l14UHx7NDzomVqKGX5wASwl",
"bHaa54IvMNV/SjhTwMz8cJ5TkmC95qO/Sb3wX2of/J8CptFJ9D9GFTsb2ady5Ds0n1zB2xyzGSCp8AzS",
"VwijDBQeYvcGusMSJQIMEgdpgelQj0hwehQ9xNGF4BMKWcdAc9vid9sNuOw3MN5zIbhAg08/nqEfvvv+",
"92YYl2TGMP0p12udHmzVbK+hMbgvIVm2KClvqHg6A6ZOE0UWRJkNngueg1DEEhm7J2OLhi8RMI25nyPF",
"OR0nmFKzAbDkTKNEfzshegNFcZQl+bjEHcgEUzOv6PMatOII61GMSRrYNHGUcCHAvuyasIJSPKFQ7ri1",
"V9JC2PaZ7Gjv90scgWHbfbtvDLTWC2F5ocayyDIslr164oXa9hUJUm6xFLJIEpBdyzDhnAJmurHit8DG",
"CS8sHDevm4GBZSo9xmKFlp5nT8VWf7ZHtJJRDSnxCjYrWPHJ3yBR+nt13rSOa7u91uFmGcgYq76DtahP",
"u9/ZjFnXx6QfDOA+JwLkVsO0kPFti8Ku62qzW8LS+l6He0gKZTd1zilJlsPEMGL9O1YKBBsaYrRv8Bwv",
"KccBmc2ISJo2XEKKbO8oJdNp+5JV9BVE3o4Tii2816HvJLT1BwqrQtanmNvDTKPKYAZSw8sYMT/YtbZi",
"4oLfQhqcoyzswDplQi0B+fMq4SwBweRmeIT2g5MTHZQbq+Fo6GfagEsD4l3b5lNBYautgwvFGc+WYwoL",
"oPX11U+qY8DsB1iACK6j48XlidNcyw94iSaA8EQqgROFBoTNQRAlUcrv2FGfjdZzF2wCV8JzGNuxdpNc",
"q1w5iKFti/gChCApyD5jdeJo6LgJQSKMhRWyVL1uIv6ZwcnTQ2Bf2nRvpl6LFlyqIiXqnCmx3G6JEsVF",
"3+PbNl6VvswpGMWR/iJWVmVeSgWlkpcWtGVldxGmQGFCa1OpVuAwYlMGas77dWE05V5ijzF7jEner7Vh",
"k+OEp9BT7jmAJFNR1m/cMMosEC9BKd3jGtRurWYO9zjL9ZijGeUTTI81gsc4USHmVlilYCvpYYFpEd6O",
"/bnUrdHjbU/dfOhsDsnt+mQTzqYkYHf5C6bE6jma1brTL4BXTdY6DGvCb89zQU9NLDAdyzCaV6WnuVK5",
"7ifR/6ZE3uoDGIQamiNZL0cqyNTo/XI+tHMKyxdt4ozCYgYb5I4BYVJhlsDQMMe010lpO245iV3v+iEa",
"6H+36plkwLXiE17DDkDF0d8566NudIhMDh41StZHVMGkB0LbjsgKp10g9PadNoWsCTbf/L+ePYu/Meht",
"Ak83BPzMngcnVpK8m8R16rYS7KK0Cu5Cr00EChwUXUB/CA1Syx9k6qxAu8leSck715pMKJZqLHBKrP5D",
"FGRhGcr9AQuBl2HB4SCac0+m69TMsSFTCizp4gCsyCZ29SvLVIiwAhKeZcCM5u6XdV+tU/BCwargO7Tn",
"cE34nXPaokYaO11v884tob0bV7t1B94ZFpPtbJs2wBWobNQ3rRfhjDMF9yqAeGPymRIKcmztDgE7wgVW",
"c4n4FJnWSJ92ojAjRuZNpOZYofL1eAvgS+LQ1vzgFclAKpzlRr/Taj2De4VyTinSawdSE7zfJpA8lxba",
"s/YZXokCEJmiY936eIkz/Z2E5HrtZG1mIasep32WzrQb/fZYgiryYznffc1q5/eK+s4ZV5yRBCWW3N7h",
"4jZtvEmC7DyRDZAuIRFgBfQ1QVmuD+kdmxJJEkyRNC8i3QxhYzUlEwpIcaTmRKLE9L7FOqzLvjI47HMm",
"OKWfHGjWhj3nUpUm1ubQTxNVYIrKBoaGc0Bg+iNshjKczAkLYi4DOXfqUbPTS+sw1s/RuwuDbs1xjbC3",
"sFK25QOtUsImj+idPGFwN6Q4Vzw/2qgymW671s25EUOMY5wLssAKxrch9+Xpm/Ph5fnZp/Or4Z/O/zo8",
"Pj4206VcoyGFRCzztrmavosJJUm4azyD57o/20ZjynR9+fHisrZtw/qFw+PYAs4x9zbQ/sSI3hKYnhZq",
"7jCK3r3u1bMF/Na9u9dCoLJ4G5eAGRuHQtcH3BsVxOzGQ/bFTdBYoUK8RvLwcrYvRRhmKuwbU0qQSaFA",
"BqWLR5SGMqy5I9Pq3LhgyhpndvM6lIyldTNXZoVaKEXUYkPp6QZqUwl2skpsZyF1OoIzu5jZV300iNYY",
"TjsuWm2kDXS0GSvwDGtRxbBt/YHfSORfHDvPb+DTG6nWTp3mSF5bvUvaQw4QJVNIlgnVA3E62di+2kHH",
"lSO+kMqY6BHjbOgN9VCZC/px/CaR2glwEQ7zOFWIApYKceYPxikBmkqUuRHmAiQwdRzFW9DuAwgTe7BY",
"o2Efwn2VnRsm9ZUR/isKI9MODZTATBIjKfs5hQ/lFgJcORi0rOG4Hs5SH9AfLz/+GV2WS7VR7Wq8HJh2",
"yvXiBh8ROS5xGNbiKV6CqOtsGShsjwmBrSZRCE2VGV+AMOQzes6MkVaXp1/ovtpZK0FzLPQRVW63zTqh",
"WdNxpxFt3QVqPLhg3J65gMREp3zei+E67uoIU65ykxx+JJ878bWRy47Xoq4eAzneTDXFVAYNdmtIOiSE",
"doZMN7sN06mbIC1mtIPQY1dsBlnUwoVWrZr7tneDYYUf0QkmYQHCyZk17PAoju6wKC0rgigttYYNR0Zx",
"C+ulsr9A5X2NXvCzFqljgYmEdAsPlzu//cz8EIPQ8kEmW5k8a4Fjm32pzrXdt33SMMX2fovrZVN7RgM9",
"krl168C43vZZgbOAtHR5SyhF9ikarMtM9oljFkchiUmANBx3f8PsIxpWbePaybh5YbskdbEvegIBTi7E",
"qhnhZF3EnRFPLvjLsB+9j6fL2s+28RQT6y3Tw0vHvDAxRvqEo/bvgusfxhOc3Lrf9I9j997nfSzVtYEE",
"JLutw6Z8vNS2NmzPvlrNeBUXqzirAEPt7h0V2BJYth2ddYyvbEXzyNp0M26i9SC1ls0BN40wPYrird3L",
"9ayLjYeD66sz4OGNwPn8LwTu1tcQ0hk0/VZdMdGfHAHlnOQhKzXj6Ra9OTNQoB8lCpaU0dxho73+FEpw",
"bkLU50QF7PSrQhm3IWJ2yqF1eguYqvllFTq8slZ6vGRlgiuWZdNDw2Ns/mI8wzATOLVcgd/pjVKwW6Z/",
"CkqqWCrrdtyPb7X6mQU0Xdsk00yrjDnXQ7U/S2U+0BjtrgapTquEW7sQYdY8SG0k8msclAjNsgeflFQK",
"Piwnvlk/247Y5fxWV8VNLq4AF1qTPzF+RzWW35IAb+wpZVDCbiEdB5G90SclMLvt5TRuP5kZyfMQJ3xL",
"ZnNKZnPNUE3WUOnd6oV5G6nWO7JNEUWhY8bVHkl5UmTWZSUKNuH81pg0FiAVmbXFam82mtoBhIj8vtRX",
"X2s2uo72uj0xqG2n7QatLamtQGREH4U7vexNYgGR9stU8OwEfVH8BH1xSyVP0M9ai06HhgfG6Pj4+PPD",
"w0O0afeQMkjb8Po1i2ttHKH1/gBKkOQShFvhwAGwbNMeMvNuSwQDpUVeR5LAd1EcPZ/rf1qiFoxI03XY",
"4MWs1/bbIvskw/e9uswI69VuGzXZR0OuJNziO2TXApVRjhs+uyohyV5nij8Qu0SWK9No9QsVLjwKPM0r",
"QoYGcWGjcLbT0POcEpDtcWDNkJ7mav43oZIzRPkdCDThBUtjLUXlkKLJEsHCvmcTlkbfRwGSNtuET0yt",
"hxSis0nfA0qLQV7L3UsUyqu1Xnv2S4EFZoqwtqi0LTJf5sucqzlI8ncb8FgOvkywWrG6mQPEt/ncnnDW",
"tZq7+ewaSKopcOVKNaC0RvmabtcVQlJLEl09vVai4GvZCkJwETgpfiRA06HJH6hFTiDbHA2+e/HiqB5N",
"0vye8VWF2XOb+reydrYH374PVymDhDdgJxTmuEko8abzCE94oU4mVMtjtQipQpDN6qP5TKfP4EKrBbJT",
"Ee/yzH549zpGCRcgYyRwNs4mMUqJvB3PJjEieYwUZDnFCmKUaVzJGEkQC5KADFmv5lwG5MVLWsxKn+SF",
"4PcZvzdBPC4+puZoDyrk4WCgt0WG2VAATjVfQc6o3zNIx3yX3icnfwNKl1PCtvf30vskRossRlyglCe3",
"IEz2NSasHtbV3+PrFm8Djdtif6rkn346vc9xDNpOvHUHvXttXOUCJ7coL4dB2Ez/MhNgjEgbjongcRyt",
"DKFz2pd+J65MWnOWQIWOBQhMqWU8iEybA/fWu9218xaP81khBDDv+m+NI5AK8i7J0Yx7nIGUeNbPAzol",
"jMj5/kbUxzDE+kxTUTDn1jGKmaeDvNVqZsvhqiDvYanQrTq5ZGegotuMJb0seUK9NAxsG/jsRut95b4K",
"M0tXyMZvl952Ss9tbQBERwdtIqo5vMemiMo2+gFJx4rvip1VmtjViSsLquOVtbFtIlG/UKXehOk2/LbT",
"Y+N7/axx4QXZtAbhYJUEi5QwTFf8r5zBUPEhNxG07pcMMw0e/V/1rPzNPPwczKHsdnkTpoVS2C9OxFmS",
"eiVOR9ume218PxxUUB9T8wtxY9WDdCPy9qx0561AtnTlVp+syMYcwark2PJHG2Qvshbu6hNoMNUqZ4ty",
"tYmWLfQJr8/6RALDCC2OK/iyDmVjhO/ptyx1pJ6HqpBqLAHYVi7nKcV5lzI45zQdp/yO7RsQt211iyq+",
"wQrwQ2f6Dmv1W8+bklugy3GCi577OivU3kGBPEmM0NVt8DhArMlGUbAyHbqoEZzclj6A0rhgvqOnbdVU",
"yemi7lD+vMshPwcfamqSNg5RYqMsplELZHGy0dp6r26TFfQEd/ItoXQvm9rmaBKTwzOuLAc93+hdjWYb",
"+1gh9xSqO6LebJYgSbc0+OeCJ5AWIiR+lqF7KTKC8MgGQYzKKIZRGdySU8zQp5fDH47W4onhPodEqxI+",
"/KYtd5xpPljaI7M1//TmidTjacLBA27cvf3eBp6XWqMIuTbXVLedu7Lreoi+AtqQ9NErwS24hb00FXhq",
"GZYCuWInFTA1NtmaMrchmLY0loru3InKnr2b6XQtz8EbT71RtNoCrTzq0imfq6GUWYZZwGjyhntjmSl6",
"44K9onAxK1c5aXXjEOWrcoSCmFNe6AbGzCTDQlf/5AkB4VIuSk9DtWzZFChe9k/v1kp/M0BYynkUl6n6",
"Jp2ctRy6bWffFgsdTrr/r2cb0y7duGNP7hBKrkqn1MoCMp5humyRpomAKjJqh+iOdYGT6x0XtLsS45ij",
"hAEWKBf8b/bTMfp9imzY2mZfYrvfVFIe0pze289NiUI5CJTi5dZOQe+mq1YrGJghISm0hGKyKuziTwAL",
"EKeFmodK91q1aLQgcAfiBOlmWnq6RR/fvT5Df/zvq3rQJmHD04t36F//+Cc6w2m6vGZTLu6wSIe4UHNE",
"TMoQMAlDwoYp5GoeI8ZtcpOz3mgJTRRqfnR8zUzpyRNjFiQJsuO0eX+2PGuVJDgwlUXQjYn2vdHvluVL",
"DZjMmxXazVYyhTCNTOsqbLoIflcANVVc8Gi9vqgtFzrUZzkgPdkyufsjueUSzXkGFE/Qx8tjdKXFyymh",
"oCeum/z2t36S18zM8re/RQNTgRQnamjkwqMT9IYbjwEIJFUxkQgLQFUB3Tui5ojjnAw125sBi6+ZTVGU",
"aFB+/uz9uxhNCy2VoJ/eySO7XmaZcQZI5pAcX7NrdsbZQpOTs5p88vLo5JoN0bn1Qumvl+VJ0U1bMdSb",
"Y/3KeyKVRIUEdPPFnNFxvabzw40dvCsEneMZYdbhNXCMBpkCt+j7ZzHK8D168ezZken3JybxFNDFx8sr",
"m3idK3SzUv33Bg1sHeGc4iW6Iyzld/btD4VhCki4UtcSJViIJbpxp93NK/Tm/MpVIJbo5vwKz25idHF6",
"dfYWlfEb6KYs6HuDBq4UcFkC2H7G5/tXa/by5csf0E9XZ+b5uQtKMk9xmgqQ0oxr0gyRRINmTWpDqKs5",
"oA9nF8iw/ylOAA2kEoAz08Pbq6uLGPHplCQEUw2gy9d/OtKwMy4oSBFW6GaUJfnNNeOsAsKEMCyWCLNU",
"N+aFMnZUs5csrvWWdUFCrxAxaYCcSnQncH7NKjxZ/RiZvBCEDdqlVrPSnBOmpN2PlCTgXDFuk13YRFzN",
"rgV1G1OejEZO8z52nuSRS9it+REju91OL97VpJaT6Pnxs+NnRs3NgeGcRCfRy+Nnxy+tE3hu+N3IMIkh",
"rpW0dYemNQIRzt6l0Un0vwsQy2b122bF85/DpZNrBUg7Cj23vNuoWLpDB/XQjc6XQ5JzNbmRrxfeo62r",
"JN2jpasQ36OlLYP98HmlpPSLZ8+2Koi8EkRYqg299Icm6UPRwbVy9dtXTDFDCJzRa0dOOQQETJk4roe4",
"EszCU/BrVis9XYsytTWd0QTmeEFMOQNjZsczaWzaE72d8YSUZtf7YTlwW8ArOomsOGB6HZUGStm6k/Sx",
"cOpb9dpEXuuoaHm4Sqxtm6e04qx9cu/6tr+6veFLsD/dtvCA2n8/aIAiXENouRc84eU2G2H0haQPI1/o",
"XK91E/EbCOwvsNA0zl2ASHNLvTa1oD0Z4i2/sHJtg8WSiYb5A0+Xe8CoPmmfm2m3qd2lS78zg6oZ46qH",
"a8R/JQyU5n0LD3vuk12vInjtBokEJFykkB6CcdulNBE0wJZoIAmbURhqkdsqRmWyk1MhhpKkcLQNor1T",
"zGK6SG04foeIZJr0E43qFU13EY5sWdzHlIvapDLOdnlzLUnsPzLZfudOVVD5CU8ePYhSGkMDJdHr88uz",
"o0Nsb9Pz9uJYc88aB2+3NHZmm/TatGtSUU/s+7CLHTZrWV117dVaMtyvC9m2mvHTgdoh4kCylIEgSmFK",
"mMtOqQBtM1A3CVRtgo8NUbKr9YRSz0ZSulCqXvLI88N+Okhem5x8APranhB2NB5YbjPEcphihWNUWhF/",
"f9Sb5iH2ZWTofUXnsgRJE0KmMsmOCHJ3hj0qdGzllK8sybYip7yHan/k2J6M7Eqs5dOBaFegNKpvbDjw",
"Vtr2M0L4XPmvLXGWRXPXTRFb1Qr+1R2SzdLXT3harsDpAGzV9Qhas7OKo5Yt54BcsB8v5LB8goxahpTA",
"hB7taq5wTqORLQZrSBPMRSlrEcqy4mvsvVESYYbwDNAtLHNMROwu1zN/by/hGRuHQy13tRGUxRvZB8fo",
"DFMKwtbkw1QATpdojhegv+HeIcwcOwxSzV0ayQsmDsv6H5pcwdaGPStL9j4GN2+W7f3KDH2l9m3o7kHT",
"IgOm/E2b1kFn6xuz1BPsAPi2H0MYMbgry8T+6x//RETKAkoMlfipYccPoUK5A24LxO3NNw2Ef5G0mD2M",
"kqp+eDBK4pNzAN7NSTJ3ZcJNafDYer0sbE2BXluKu6x8jUwFcAPiGVkAQ6p0BRonMEOlf9bU/jZONcKk",
"ApwiPkUzolBeUBoC6RtQzdrna+dWaAqIM7p0g5N+cERW47I3Tr58+fKHo5abdm1R863vAP38mCJKYyVC",
"XNlVDE+BKnwAzL4B5WCQ1Ht2K4qr5dwWnPFOUq25T/jhcwDZsipoPuss1zw08R/GOmjesL7etCzuarv9",
"jVzj2O3ALGupPzrdyw8F6H7Zqyz7gVTbcuW66r9/XTDUq520CsBl9MEmDvIjoQqESZ6vX99DWEKLFCTS",
"rYGlmCnZxjp2te/6dLJtX/R1Kbd+syw42vniCtiKiX3oCrlwZqI2Ri6CMvSVX/Y0/P5byejtVbC+kmzu",
"qo5RIg+256HaPF7UrtUy2tmMdV4y12/SjtUoX/6VDVklitotWbG7cd18+vwKz9q6dM1Gpo3r8CAWMIbW",
"RNMNqGjaL8rGI6+wtCthZ06vqilKNaXHcM7YV+c2SS1Sa4cmKNCWLNB6W4JznBgFrAwHPopRaUZxvVtn",
"V1V/04Q7BDS2ppqm2WA5O+9JD0m0PkX928b+WpGIr4z/9QIG7ZzOFZ6MmwT5pYDiSbeJnwLCSL9WKA/d",
"wfv/cxajv3yIkS8AcYRMQ1PRYd/9VJqOg8LQG1Aeeo+ofLexL0czVyzm6ajzxie6r8af7nTIHcBIvxoT",
"X95CUOc6WEDgXoXa3RhVQfQUpq+uGaEUZpg2OrGBvui7Zz9oudZ0N6yeHx2jCxviNdMfuWaWIWqdaFm9",
"+hINSi7n1+UoyO/09HbldY/sbahfj/HVrVNtG8T5G6qz9al2iHNXVLUP9Aap3YWxck/GQbjWyFy/OKyu",
"X2xjYX/Q7T7ZZr18GSbboqGH+OV5aerkkazIopPvA3k+j61NrEaQ5TYTZT2TaruSPW1VdOwHtq55skV8",
"yHRqkjm92mCtqjOB8zlKiSugdQiTaplQUH6QTK0hwjH2KSZUflV2vg7oMvxJbj6QfRWOfogWQHcOJauy",
"pII7Ippws1l8mQ+T+WVMDObJ58MbPvdRubvLWO+M43q3h3BxvTaLjkS9W3Mhp/fjDPTqIk8cefSE4LWW",
"VS9Sj6pE3zYUr1bfesTjc/VTAepdNL1gkDs25OZxAPmeUxqucGZyh1aF/q9FyrphdGGsr53Rq+cLd81i",
"D47z6Ek5QatorabEf8JPn9i2uXAW+6cybS5smucBQ07fEqm4MK5WKLfCrplAtoORTUxsdUZd2sD0S2AK",
"2Qkdo3OczO33fyPRDUlvypRZ8zck+B0iKRoIkEUG18wwspv3WlQ2PQzfvb45itGNab3yrl7UGN2kWGH/",
"5I+XH/98zcyryK72MXoLWKgJYIXsnd9K6g7EEj3/Xh6jP4BUQ5hOuTBOQGKe/Osf/7xmpugwpCgHMZTF",
"RM90AgJNiukURIxSwfMhpylI5TJsL/7r6JXJkX1zfoXcml0zxdEEJ7dTEnYEX5o1bWNWrS4cvwIoFzAl",
"9/t6bKySVb3YIEFnD5u3rYJ7ZZdjWCGovcN1L+DlOXIvHsLsvygBZPtEg8vL86N9NkcVm9Ppp6ua7Zoo",
"9+jx2d9IPsS/19HhUwif8PiosHUox1gdrVtHocUtzo6rOaA5ZikFseqdGPgIMoPBo9hGkErnpxiVlfHi",
"a4ZZioCoOQgEzFjD3bHgS/UObDClyyI9QlzU4teuWWngK2/rNz6QskpAsyfC0E15gdaNjzk7pZIjuDd/",
"LUMsbDyJ4BRM+JMNBrLdffzz+7+iO7y0baSeYugocB6J83pO6jfpPly98OpruxCrHdexFUrvCRpktn6l",
"Sy/2TqxDCFmfPIDq6HPQXqJ//d//5z/nwur1nxxqtwrwrEW/VW03O0RqWHo8k28vehzCLuaX2B6O6HfI",
"3RG4G4/ay57QJMLIXnr3KCnBZ6brpyflmb/X7wCudtMXwqhkriN/2yCqJ+XvmN2qebNoT289N48vAdK9",
"jTkrooOpusNtLbHm6v319MN7VLuXab2AJ1Oc8tkur9ozcusXV+QNP4C4Ng/feR85RK8omhT6gD8Icy3D",
"0ZHUHevZSFvwKHH15V//obzL/PUnNEKuXoxxPgveiKCXS6kg6wUeY8/v4qrmmsJNytqlwsJ7Ygd1P+zR",
"K8Qzoowx7W6uBQbrQRjY+23aou8E5zsJ9R0OohcbHESxKWBJTRU+K7H2ttf3L1wp1dIU/plykUWPGmdc",
"XS8ZwK55iBbm6d7QvSwmlqaaxgsiC0zJ311JLHO9I/odMtc77mAI1xCtrm9sw+iPFEDZeyIf88Bo3kQZ",
"WFbbAJVLs//SmomheaNbawA3F2MhwlI9FS72MXj5esUjCVgk7St9aR77Kw77qfa/RKvi8n768jegBTcu",
"eTycp+otUYdQaX8sKB2aOH9LTlsr0xO58ucOysNSxshdnNjYov6VrTD0xfsJekQv1bH0ayPne3OLZ7Xw",
"h6isQKmXcOSopBmy94Vq3TwUzdmXjOHdbO7rDPqA+m9qo0PY2p4b/FgfXKNenKW3Q6rHse4rj+5/qLcm",
"w5Y3DwY89bhQvOapb95DaW9K2C0xdhd311Oy1sZVn4fbi7ZbJOFApecMWE3m1ND2iTKP3F2P4bpK1MY1",
"P1ZaywFJ5DaSXsVtMyTMPTOBLeHDyvp32LhVN3SxcS3iYsuxrt2MswlZjRUJfroxxT4Q9KQ7zAmPSrgY",
"A2wty0kf5rXhln+rjbYG0XJM/dFpY0uWQ0+AsPG7HnyKsHSppuOsUE4x8CZqvXPw0BsO7+bAUBWLumY3",
"rqecXFk97BtOO9EjfMrUEwv2zkIqL5696IFDa06uF0zc27yptAKj5lAh2Sg2Nre6Buj+eG1aNoKIHX3R",
"x3GoJEtA2nHJcFsIOq2B4G+xSFEKFJSpo824QrLIcy5MMey5Ka/tLiWVCO6JtHnl/loFn2ptve+vXwa2",
"Ri1Ie7ed8VUCtfXQnjBYu21H1ArEPNGOqBWW8VSvggr32QmuuGu3y/6ibLSN7L1HEcQdfe/9IgV+TT73",
"8jbyp/O4e2gcyN+eV1Ar8UzBXci1WRIp3z5oba3whRFI4imoJVpgugDHei/f/P7oGJ36Osmaned1aWdN",
"1Ln8ro1ZX/grvb8+p25CsqVibZ872NeuDtr3bvWHJy6H6zfct3hKXFWpO7jcSGjgynYDGqFqbdGoOkmO",
"+u+1lbPDRaT4NLGCQr8a6Z8KCjL6Bsp764EcMqHAzOvA5bqRKJqqWeWI3CH06MwrV4067/5rr4zcrT+J",
"3B17ttgOnioQ6xnQJed72aqPNZb6G9XI6mPcRid7km1+YSMFnDbUQAkapAWmw4DbtxMzPbb1Y1er3A8l",
"j6yd/JvCwyDCbe9DAsNFIHYZI09dm0tQirDZ0/L65lgOyO797A5RGNsOEknXJxrcEkqH8o6oZB4jBgsQ",
"w7I2pqn9crTDkRCWaT9hIk1EYDkIIlEdLxRSNHjx7AX6XRU0eIze8zswZYKIsokCbujoZkb5BNNj3d0Y",
"J+oEXUd8Or2ObrQGi1MbfWinNC4boVtw+QblsUOyDFKCFdCl/vqzoxNzNNWWxZZMNP2gO+wiSTDrLs9h",
"uE0InrvxDT0d/QjTiwZG2zxDjye3fpt75NRQ06a2KEFsUPMTCsmePZZYL6sINjjkqwbMPv74o94SHpD7",
"8U9B5O3QhMZukJb95e9PKytXd9AfUFAm8haVa3AgeVnU+9ySNWryNNJ2LZOkYFXf1cKqaSMhuV8ai7mZ",
"b9uAl860lr17qgWsbWXIXoHid6FKozU3E7DD3A1zzlIt1dS7HkhQ0hZMGStudRcTyUIkuoXcnghzkwG4",
"PNqlgEWbGnXubv6zPrRAyZZ1vyAazAkILJL5cojvsICjVyjBIiUMU3v72ZSLBNI2Raobc9+GIlUf49M4",
"t5qlAr7KPQENRLqIpZ0qpZS14bsOhUvXpnfuHBwsJ9tfTcymPIqjO2cqiqNEEEWS4JXNj5Ix3ufCll+T",
"md/S/Amt/CXoDlVh1mN4uytTanvEJpu4S/wPnmlymty6NQ9TvXvm9tXDXStxmlQRmtgt3o43SjRWLyus",
"dHPw5ftQKKit3yGcEHqs44IpQvtW6u571V6t5z0u2/u6iNAL7KHgipFcXb0/BCgESE4Xj4OLT7bvA0Oj",
"ncxrxPwmiOdWoaJfhlmBKV3uSj6tqm4QGmyTflcYWvPL2ESd/sd7/5jHuqbKU57qFhWHOtRNb2hAsQKp",
"fApaDsI+OtrNpW+7fWz3gyXFN+xrzwljkI79RfCh6oHr7nZNiVZf+7fnXXcb4pv3rRtMmot4iP61JMqO",
"bvQawkeuqx7c/C9ly2+Pge3MkPyc9qeX66q0/ZgqfJZu27OhPTPkTTqe7Jv8dGVab82K/t2SOcw0Dwgd",
"t2yH2OjAUoQZpktJXKE/SsscDlPDO5BItU1Cx2MmU+mpQFIY043uegJYgDgt1Dw6+fmzpri9Ndt+uBA0",
"OolGOCejxXODBzef9et1XBq8y9D2eQWmOKspGlO3nTenYdNq1uJQ7A1Z4K/oiqs7iIi0FYkJZ3F5HU2t",
"xJK7c2a9z/PtUh1cf7zKvvgStnuYKboyPANLauMDatyiGByQr9ZQ3ULgbtSLq+v40SA1l92PcKJq3UK9",
"mNGXlrhLMzQveml+VuvB87f19+sOmHgl1Cj2zrGqK+dFWe/IZ0g6aLg84cpWV8tx/BJMvZKxzVg2302J",
"il2dvtglN9co1dhloeXOuVDr77maBw+fH/5/AAAA//8xbfjHPuQAAA==",
"H4sIAAAAAAAC/+x923LcuNngq6C4W5VWwlb7MPNnR75SZI3txI61lib/pkauFpr8uhsRCHAAsKWOS1W5",
"2gfYyhPmSbZwIEh2g2z2QZYzlRtbEkEQ+E74zvgSJTzLOQOmZHTyJZoDTkGYH8+v8Ez/n4JMBMkV4Sw6",
"iT6B5IVIAC1ASMIZmnKB3k2HH7BK5lEcyWQOGdbvqWUO0UkklSBsFj08PMRRjgXOQLkPnBVCcrH+iY85",
"/qUAlJjHaCp4hjDKBSwILyQSIHPOJPxGIgb3amyHRXFE9Lu/FCCWURwxnOmP+4fty4qjc6aIWr5L11fy",
"00/vXiMukKTFDA3geHaMbuZcqpN5MRFE3hyVn82xmldfJWkURwJ+KYiANDpRooA+K7ikRQDg9lljCXfy",
"JMPJMCOM3MToht4nJwlO02XbevS7W67oR8GzK6Lf/hIErMZKA6xTLjKsopMoxQqGSr8aB+Z9l0KWcwUs",
"Wf4Jluu7PaMEmBrOgIHAClJ0C8tXSEBO8VKiO6LmhKEX382RAFUIhtQcEBdkRhimnjRKKFhirhZd+/hQ",
"f72+/gzfvwc2U/Po5PmL/xVc+tTS+DqGrvCsIlPCBXpzfvUKfff8BeLM80lGZOZ4JLy4ioe2QdR7khHV",
"hiVqHtYnSGGKC6qik++fxXrPJCuy6OTFM/0bYfa35373hCmYgTAfuuJd9KD49tTwoHdqMWbkwQWwlLDZ",
"aZ4LvsBU/ynhTAEz+8N5TkmCNcxHf5Ma8F9qH/yfAqbRSfQ/RpU4G9mncuQnNJ9cobc5ZjNAUuEZpK8Q",
"RhkoPMTuDXSHJUoEGEocpAWmQ70iwelR9BBHF4JPKGQdC83tiN9tt+By3sB6z4XgAg0+/XiGfvju+9+b",
"ZVySGcP0p1zDOj0Y1OysoTW4LyFZjigxb7B4OgOmThNFFkQZBs8Fz0EoYpGM3ZOxpYYvETBNcz9HinM6",
"TjClhgGw5ExTif52QjQDRXGUJfm4pDuQCaZmX9HnNdKKI6xXMSZpgGniKOFCgH3ZDWEFpXhCoeS4tVfS",
"QtjxmewY7/kljsCI7b7TNxZam4WwvFBjWWQZFsteM/FCbfuKBCm3AIUskgRkFxgmnFPATA9W/BbYOOGF",
"JcfNcDNkYIVKj7VYpaXn2VOJ1Z/tEa1kVKOUeIU2K7Lik79BovT36rJpna4te62TmxUgY6z6LtZSfdr9",
"zmaadXNM+pEB3OdEgNxqmZZk/NiisHBdHXZLWFrndbiHpFCWqXNOSbIcJkYQ69+xUiDY0CCjncFzvKQc",
"B3Q2oyJp3HAJKbKzo5RMp+0gq/AriLwdJxRb8l4nfaehrT9QWBWyvsXcHmaaqgzNQGpkGSPmBwtrqyYu",
"+C2kwT3Kwi6sUyfUGpA/rxLOEhBMbiaPED84PdGRcgMaDod+pw1yaZB4F9t8KihsxTq4UJzxbDmmsABa",
"h69+Uh0Dhh9gASIIRyeLyxOnCcsPeIkmgPBEKoEThQaEzUEQJVHK79hRH0bryQWbiCvhOYztWrtRrk2u",
"HMTQjkV8AUKQFGSftTp1NHTchEgiTAsraKlm3YT8M0MnT08C++Kmm5l6AS0IqiIl6pwpsdwORIniou/x",
"bQeval/mFIziSH8RK2syL6WC0shLC9oC2V2UKVCY0NpWKggcRm3KQM15vymMpdxL7TFujzHJ+402YnKc",
"8BR66j0H0GQqzHrGDVOZJcRLUErPuEZqt9Yyh3uc5XrN0YzyCabHmoLHOFEh4VZYo2Ar7WGBaRFmx/5S",
"6tbY8Xambjl0Nofkdn2zCWdTEvC7/AVTYu0cLWrd6RegV43WOhnWlN+e54LemlhgOpZhal7VnuZK5Xqe",
"RP+bEnmrD2AQamiOZA2OVJCpsfvlfGj3FNYv2tQZhcUMNugdA8KkwiyBoRGOaa+T0k7cchK72fVDNND/",
"bjUzyYBrwycMww6CiqO/c9bH3OhQmRx51DBZX1FFJj0otO2IrOi0iwi9f6fNIGsSmx/+X8+exd8Y6W0i",
"nm4S8Dt7HtxYifJuFNex24qwi9IruAu+NiEocFB0EfpDaJFa/yBT5wXaTfdKStm5NmRCsVRjgVNi7R+i",
"IAvrUO4PWAi8DCsOB7GcewpdZ2aODZpSYEmXBGBFNrHQrzxTIcQKSHiWATOWuwfrvlan4IWCVcV3aM/h",
"mvI757TFjDR+ut7unVtCew+uuHUH2RlWk+1umz7AFVLZaG/aKMIZZwruVYDijctnSijIsfU7BPwIF1jN",
"JeJTZEYjfdqJwqwYmTeRmmOFytfjLQhfEkdtzQ9ekQykwllu7Dtt1jO4VyjnlCINO5Aa4f2YQPJcWtKe",
"te/wShSAyBQd69HHS5zp7yQk17CTtZ2FvHqc9gGdGTf67bEEVeTHcr47zGrn94r5zhlXnJEEJRbdPuDi",
"mDbepEF2nsiGkC4hEWAV9DVFWa4v6R2bEkkSTJE0LyI9DGHjNSUTCkhxpOZEosTMvgUc1nVfGVz2OROc",
"0k+OaNaWPedSlS7W5tJPE1VgisoBBodzQGDmI2yGMpzMCQvSXAZy7syj5qSXNmCsn6N3F4a6tcQ1yt7C",
"atlWDrRqCZsionfyhMHdkOJc8fxoo8lkpu2CmwsjhgTHOBdkgRWMb0Phy9M358PL87NP51fDP53/dXh8",
"fGy2S7mmhhQSsczb9mrmLiaUJOGp8Qye6/nsGE1TZurLjxeXNbYN2xeOHseW4JxwbyPanxjRLIHpaaHm",
"jkbRu9e9ZrYEv/Xs7rUQUVl6G5cEMzYBha4PuDcqErOMh+yLm0hjBQvxGsrD4GwHRZjMVDg2ppQgk0KB",
"DGoXj6gNZVhLR6bNuXHBlHXO7BZ1KAVLKzNXboVaKkXU4kPpGQZqMwl28kps5yF1NoJzu5jdV3M0kNZY",
"TjtdtPpIG9TR5qzAM6xVFSO29Qd+I5F/cewiv4FPb8RaO3aaK3lt7S5pDzlAlEwhWSZUL8TZZGP7agce",
"V474QirjokeMs6F31EPlLugn8ZtIakfARTjN41QhClgqxJk/GKcEaCpR5laYC5DA1HEUb4G7DyBM7sFi",
"DYd9EPdVODeM6iuj/FcYRmYcGiiBmSRGU/Z7Ch/KLQi4cmTQAsNxPZ2lvqA/Xn78M7osQbXR7Gq8HNh2",
"yjVwg4+IHJd0GLbiKV6CqNtsGShsjwmBrSVRCI2VGV+AMOgzds6MkdaQpwd0X+usFaE5FvqIKtlts01o",
"YDrudKKth0BNBBdM2DMXkJjslM97CVwnXR1iSig30eFX8rmTvjZK2fFa1tVjUI53U00xlUGH3RolHZKE",
"diaZbnEbxlM3QlrcaAfBx660GRRRC5dateru2z4MhhV+xCCYhAUIp2fWaIdHcXSHRelZEURprTXsODKG",
"W9gulf0VKh9r9Iqf9UgdC0wkpFtEuNz57XfmlxgkLZ9kspXLs5Y4tjmW6kLbfccnDVds77e4BpvaMxvo",
"kdytWyfG9fbPCpwFtKXLW0Ipsk/RYF1nsk+csDgKaUwCpJG4+ztmH9GxagfXTsbNgO3S1MW+1BNIcHIp",
"Vs0MJxsi7sx4cslfRvxoPp4uaz/bwVNMbLRMLy8d88LkGOkTjtq/C65/GE9wcut+0z+O3Xuf9/FU1xYS",
"0Oy2Tpvy+VLb+rC9+Gp141VSrJKsAgy2uzkqwBJYth2ddRpfYUXzyPp0M26y9SC1ns0BN4MwPYrircPL",
"9aqLjYeDm6sz4eGNwPn8LwTu1mEI6QyacauunOhPDoFyTvKQl5rxdIvZnBsoMI8SBUvKbO6w015/CiU4",
"Nynqc6ICfvpVpYzbFDG75RCc3gKman5ZpQ6vwEqvl6xscMWzbGZoRIzNX0xkGGYCp1Yq8DvNKAW7Zfqn",
"oKaKpbJhx/3kVmucWUAztE0yLbTKnHO9VPuzVOYDjdXu6pDq9Eo42IUQsxZBakORh3FQIzRgDz4psRR8",
"WG58s322HbLL/a1CxW0urgguBJM/MX5HNS2/JQHZ2FPLoITdQjoOUvbGmJTA7LZX0Lj9ZGYkz0OS8C2Z",
"zSmZzbVANVVDZXSrF83bTLXemW2KKAodO654JOVJkdmQlSjYhPNb49JYgFRk1parvdlpahcQQvL70l59",
"rcXoOrXX/YlBazttd2htiW0FIiP6KNzpZe8SC6i0X6aCZyfoi+In6IsDlTxBP2srOh0aGRij4+Pjzw8P",
"D9Em7iFlkraR9Wse19o6QvD+AEqQ5BKEg3DgAFi2WQ+Zebclg4HSIq9TksB3URw9n+t/WrIWjErTddjg",
"xawX+21RfZLh+15TZoT1GreNmeyzIVcKbvEdsrBAZZbjhs+uakiy15niD8QuleXKDFr9QkUXngo8zitE",
"hhZxYbNwtrPQ85wSkO15YM2UniY0/5tQyRmi/A4EmvCCpbHWonJI0WSJYGHfswVLo++jAEqbY8InprZD",
"CtE5pO8BpdUgb+XupQrlFazXnv1SYIGZIqwtK22Lypf5MudqDpL83SY8losvC6xWvG7mAPFjPrcXnHVB",
"c7eYXYOSagZcCakGKa1hvmbbdaWQ1IpEV0+vlSz4WrWCEFwEToofCdB0aOoHapkTyA5Hg+9evDiqZ5M0",
"v2diVWHx3Gb+rcDOzuDH95EqZZLwBtoJpTluUkq86zzCE16okwnV+lgtQ6oQZLP5aD7TGTO40GaB7DTE",
"uyKzH969jlHCBcgYCZyNs0mMUiJvx7NJjEgeIwVZTrGCGGWarmSMJIgFSUCGvFdzLgP64iUtZmVM8kLw",
"+4zfmyQelx9TC7QHDfJwMtDbIsNsKACnWq4g59TvmaRjvkvvk5O/AaXLKWHbx3vpfRKjRRYjLlDKk1sQ",
"pvoaE1ZP6+of8XXA24Djttyfqvinn03vaxyDvhPv3UHvXptQucDJLcrLZRA207/MBBgn0oZjIngcRytL",
"6Nz2pefElU1ryRLo0LEAgSm1ggeRaXPh3nu3u3XeEnE+K4QA5kP/rXkEUkHepTmadY8zkBLP+kVAp4QR",
"Od/fifoYjlhfaSoK5sI6xjDzeJC32sxsOVwV5D08FXpUp5TsTFR0zFjiy6InNEvDwbZBzm703lfhq7Cw",
"dI1sPLv09lN6aWsTIDomaFNRzeE9Nk1UtrEPSDpWfFfaWcWJhU5ceVCdrKytbROK+qUq9UZMt+O3HR8b",
"3+vnjQsDZBMMwskqCRYpYZiuxF85g6HiQ24yaN0vGWaaePR/1bPyN/Pwc7CGsjvkTZhWSmG/PBHnSepV",
"OB1tW+618f1wUkF9Tc0vxA2oB/FG5O1ZGc5bIdkylFt9skIbcwirimPLH22SvchapKsvoMFUm5wtxtUm",
"XLbgJwyf9Y0ElhECjmv4sk7KxgnfM25Z2kg9D1Uh1VgCsK1CzlOK8y5jcM5pOk75Hds3IW7b7hZVfoNV",
"4IfO9R226rfeNyW3QJfjBBc9+Tor1N5JgTxJjNLV7fA4QK7JRlWwch26rBGc3JYxgNK5YL6jt23NVMnp",
"oh5Q/rzLIT8Hn2pqijYO0WKjbKZRS2RxutEavFfZZIV6gpx8Syjdy6e2OZvE1PCMK89Bzzd6d6PZxj9W",
"yD2V6o6sN1slSNItHf654AmkhQipn2XqXoqMIjyySRCjMothVCa35BQz9Onl8IejtXxiuM8h0aaET79p",
"qx1nWg6W/shsLT69eSP1fJpw8oBbd++4tyHPS21RhEKba6bbzlNZuB5iroA1JH32SpAFt/CXpgJPrcBS",
"IFf8pAKmxidbM+Y2JNOWzlLRXTtR+bN3c52u1Tl456l3ilYs0CqjLp3xuZpKmWWYBZwmb7h3lpmmNy7Z",
"Kwo3s3Kdk1YZhyjflSOUxJzyQg8wbiYZVrr6F08ICLdyUXobqoVlU6B42b+8Wxv9zQRhKedRXJbqm3Jy",
"1nLotp19WwA6XHT/X882ll26dcce3SEquSqDUisAZDzDdNmiTRMBVWbUDtkd6won1xwX9LsSE5ijhAEW",
"KBf8b/bTMfp9imza2uZYYnvcVFIespze289NiUI5CJTi5dZBQR+mq6AVTMyQkBRaQzFVFRb4E8ACxGmh",
"5qHWvdYsGi0I3IE4QXqY1p5u0cd3r8/QH//7qp60Sdjw9OId+tc//onOcJour9mUizss0iEu1BwRUzIE",
"TMKQsGEKuZrHiHFb3OS8N1pDE4WaHx1fM9N68sS4BUmC7Dpt3Z9tz1oVCQ5MZxF0Y7J9b/S7ZftSQ0zm",
"zYraDSuZRphGp3UdNl0Gv2uAmioueLTeX9S2Cx3qsxyQ3mxZ3P2R3HKJ5jwDiifo4+UxutLq5ZRQ0BvX",
"Q377W7/Ja2Z2+dvfooHpQIoTNTR64dEJesNNxAAEkqqYSIQFoKqB7h1Rc8RxToZa7M2AxdfMlihKNCg/",
"f/b+XYymhdZK0E/v5JGFlwEzzgDJHJLja3bNzjhbaHRyVtNPXh6dXLMhOrdRKP31sj0pumlrhnpzrF95",
"T6SSqJCAbr6YMzqu93R+uLGLd42gczwjzAa8Bk7QINPgFn3/LEYZvkcvnj07MvP+xCSeArr4eHllC69z",
"hW5Wuv/eoIHtI5xTvER3hKX8zr79oTBCAQnX6lqiBAuxRDfutLt5hd6cX7kOxBLdnF/h2U2MLk6vzt6i",
"Mn8D3ZQNfW/QwLUCLlsA28/4ev8KZi9fvvwB/XR1Zp6fu6Qk8xSnqQApzbomzRRJNGj2pDaIupoD+nB2",
"gYz4n+IE0EAqATgzM7y9urqIEZ9OSUIw1QR0+fpPR5rsTAgKUoQVuhllSX5zzTirCGFCGBZLhFmqB/NC",
"GT+q4SVL15plXZLQK0RMGSCnEt0JnF+zip6sfYxMXQjChtqlNrPSnBOmpOVHShJwoRjHZBe2EFeLa0Ed",
"Y8qT0chZ3scukjxyBbu1OGJk2e304l1NazmJnh8/O35mzNwcGM5JdBK9PH52/NIGgedG3o2MkBjiWktb",
"d2haJxDh7F0anUT/uwCxbHa/bXY8/zncOrnWgLSj0XPLu42OpTtMUE/d6Hw5pDlXmxv5fuE9xrpO0j1G",
"ug7xPUbaNtgPn1daSr949myrhsgrSYSl2dDLfmiiPpQdXGtXv33HFLOEwBm9duSUS0DAlMnjeogrxSy8",
"BQ+zWuvpWpap7emMJjDHC2LaGRg3O55J49OeaHbGE1K6Xe+H5cJtA6/oJLLqgJl1VDooZSsn6WPh1I/q",
"xUTe6qhwebhOrG3MU3px1j65d3/bXx1v+BbsT8cWnqD25wdNoAjXKLTkBY94uQ0jjL6Q9GHkG51rWDcp",
"fgOC/QUWGse5SxBpstRr0wvaoyHe8gsr1zZYWjLZMH/g6XIPMqpv2tdmWja1XLr0nBk0zRhvyfxt6RPy",
"9sPpWdUu2doGA0nYjMKwkBCjsvjHqdRDSVLY3FHGbyNMic0LHR72ZMRd7zp47RaJBCRcpJAe4mSwuDIp",
"OsCWdVg66LYCtC/L+KibZZoitfn+HTqYGdJP96q3TN1F+7J9dx9T8WpT+zjb5c21KrT/KH37HWxVx+Yn",
"PNr0Ikp1Dw2URK/PL8+ODsHeZubt9b0mz5oIcre6d2aH9GLaNbWrJ+37vI4dmLVs37r2aq3a7tdF2bZd",
"8tMRtaOIAylrhgRRClPCXPlLRdC2xHWTxtamWdkcKAutJ1SrNqLS5Wr10keeH/bTQfTa6ucD4NfOhLDD",
"8cBKmyGWwxQrHKPSTfn7o944D4kvo6Tvq5uXPU6aJGRan+xIQe5SskclHdua5Strsq2UU150tT/l2JmM",
"7kqsa9UR0a6E0mjvseHAWxnbz8vhi/G/tsZZduVd93Vs1Yz4V3dINntrP+FpuUJOBxCrbkbQlp01HLVu",
"OQfksgl5IYflE2TMMqQEJvRoV3+Ii0qNbLdZg5pgsUvZ7FCWLWVjH+6SCDOEZ4BuYZljImJ3e5/5e3uP",
"0NhENGrFsY2sL94obzhGZ5hSELbpH6YCcLpEc7wA/Q33DmHm2GGQaunSqI4wiV42wNGUCrb57FnZE/gx",
"pHmzL/BXFugrzXVDlxuaERkw5a/ytBFA20CZpR5hB6Bv+zGEEYO7sg/tv/7xT0SkLKCkoZJ+arTjl1BR",
"uSPcFhK3V+s0KPyLpMXsYZRUDcqDaRifXITxbk6SuetDbnqPxzasZsnWdAC2vb7L1trItBg3RDwjC2BI",
"lbFGE2VmqAwAm+biJmpHmFSAU8SnaEYUygtKQ0T6BlSzufrauRXaAuKMLt3ipF8ckdW67JWWL1++/OGo",
"5Spf2zV960tGPz+mitKAREgqu5bkKVCFD0Czb0A5MkjqMzuI4gqc2xJnvJNWay4sfvgcoGxZdUyfdfaD",
"HpoEE+MdNG/YYHJado+10/5GrknsdsIsm7U/Ot7LDwXwftmr7/uBTNsScl0N5r8uMdTbqbQqwGV6wyYJ",
"8iOhCoSpzq/fD0RYQosUJNKjgaWYKdkmOnb17/p6tW1f9I0vt36z7Gja+eIKsRUT+9B1iuHMpIWMXIpm",
"6Cu/7On4/bfS0dvbbH0l3dy1NaNEHoznoWIer2rXmiXt7MY6L4XrN+nHavRH/8qOrJKK2j1ZsbvS3Xz6",
"/ArP2qZ0w0ZmjJvwIB4whtZU0w1U0fRflINH3mBpN8LOnF1VM5RqRo+RnLFv/22qZqS2Dk3Woe2JoO22",
"BOc4MQZYmW98FKPSjeJmt8GuqsGnyacIWGxNM02LwXJ3PuAb0mh9Dfy3TftrXSi+Mv2vd0hol3Sus2Xc",
"RMgvBRRPyiZ+Cwgj/VqhPOkO3v+fsxj95UOMfIeJI2QGmpYR+/JT6ToOKkNvQHnSe0Tju018OZy5bjRP",
"h503vpJ+NcF1p0PuAE761aT78pqDutTBAgIXN9Qu36g6rqcwfXXNCKUww7Qxic0kRt89+0HrtWa6YfX8",
"6Bhd2Byymf7INbMCUdtEy+rVl2hQSjkPl6OgvNPb21XWPXK0oX7/xlf3TrUxiIs3VGfrU3GIC1dUzRU0",
"g9Qu21i5iOMgUmtk7nccVvc7tomwP+hxn+ywXrEMU87RsEM8eF6aRnwkK7Lo5PtAIdFjWxOrKWq5LXVZ",
"L9XaridQW5se+4Gtm6pskR8ynZpqUW82WK/qTOB8jlLiOnQdwqVaViyUHyRT64hwgn2KCZVfVZyvE3SZ",
"/iQ3H8i+zUc/ihZAd04lq8qwghwRTbhhFt9HxJSWGReDefL58I7PfUzu7j7ZO9NxfdpDhLheG6AjUZ/W",
"3Pjp4zgDDV3kkSOPnpB4rWfVq9SjqpK4jYpX23s94vG5+qkA9i6aUTDInRhy+ziAfs8pDbdQM8VJq0r/",
"10Jl3TG6MN7XzuzV84W7x7GHxHn0qp+gV7TWtOI/6adP7NtcOI/9U7k2F7aO9IApp2+JVFyYUCuUrLBr",
"qZGdYGQrH1uDUZc2Mf0SmEJ2Q8foHCdz+/3fSHRD0puyJtf8DQl+h0iKBgJkkcE1M4Ls5r1Wlc0Mw3ev",
"b45idGNGr7yrgRqjmxQr7J/88fLjn6+ZeRVZaB+jt4CFmgBWyF4qrqSeQCzR8+/lMfoDSDWE6ZQLEwQk",
"5sm//vHPa2a6GkOKchBDWUz0Ticg0KSYTkHEKBU8H3KaglSuhPfiv45emSLcN+dXyMHsmimOJji5nZJw",
"IPjSwLRNWLWGcDwEUC5gSu73jdhYI6t6sYGCzhk2s62Ce2XBMawoqH3C9Sjg5TlyLx7C7b8oCcjOiQaX",
"l+dH+zBHlZvTGaerhu1aiffo+dnfSD3Ev9fR4WsUn/D4qGjrUIGxOrVunYUWtwQ7ruaA5pilFMRqdGLg",
"M8gMDR7FNoNUujjFqGy9F18zzFIERM1BIGDGG+6OBd8LeGCTKV2Z6hHiopa/ds183ZrzvZkYSNmGoDkT",
"YeimvKHrxuecnVLJEdybv5YpFjafRHAKJv3JJgPZ6T7++f1f0R1e2jFSbzF0FLiIxHm96PWbDB+u3qj1",
"tUOIFcd1sEIZPUGDzDbIdPXLPoh1CCXrkyegOvU50l6if/3f/1cVSdq0ev0nR7VbJXjWst+qsZsDIjVa",
"ejyXby98HMIv5kFsD0f0O+QuIdxNRu3lT2giYWRv1XuUmuMzM/XTo/LMXxx4gFC7mQthVArXkb/OENWr",
"/nesbtWyWbSXt56bx5cA6d7OnBXVwbT14bZZWRN6fz398B7VLn5a7xDKFKd8tsur9ozc+sUVfcMvIK7t",
"w0/eRw/REEWTQh/wBxGuZTo6knpivRtpOyolroH96z+Ul6W//oRGyDWkMcFnwRsZ9HIpFWS9iMf487uk",
"qrkHcZOxdqmw8JHYQT0Oe/QK8Ywo40y7m2uFwUYQBvYCnbbsO8H5Tkp9R4DoxYYAUWw6ZFLT5s9qrL39",
"9f07Y0q1NJ2Fplxk0aPmGVf3VwZo1zxEC/N0b9K9LCYWpxrHCyILTMnfXc8tc38k+h0y90fu4AjXJFrd",
"D9lGoz9SAGUvonzMA6N51WUArHYAKkGzP2jNxtC8Ma11gJubtxBhqd4KF/s4vHxD5JEELJJ2SF+ax/4O",
"xX6m/S/Rqrq8n738DVjBjVskDxepekvUIUzaHwtKhybP36LTNuP0SK7iuYPysJQxcjczNljUv7IVDX3x",
"cYIe2Ut1Wvq1ofO9uSa0AvwhOitQ6jUcOSpxhuyFpNo2D2Vz9kVjmJvNhaDBGFB/pjY2hG0euiGO9cEN",
"6iVZegekehzrvrXp/od6azFsebVhIFKPC8VrkfrmRZf2KobdCmN3CXc9pWht3CV6OF600yIJB+ptZ4jV",
"VE4N7Zwo85S76zFcN4napObHymo5IIocI2koblshYS6yCbCETyvrP2Hj2t7Qzcm1jIst17p29c4mympA",
"JPjpxhb7kKBH3WFOeFSSi3HA1qqc9GFeW275t9pqayRarqk/ddrckuXQIyDs/K4nnyIsXanpOCuUMwy8",
"i1pzDh56x+HdHBiqclHX/Mb1kpMra4d9w2UneoVPWXpiib2zkcqLZy960KF1J9c7Mu7t3lTagFFzqCjZ",
"GDa2trpG0P3ptenZCFLs6Is+jkMtWQLajiuG20LRaU0Ef4tFilKgoEyjbsYVkkWec2G6bc9N/25366lE",
"cE+krSv39zb4UmsbfX/9MsAatSTt3TjjqyRq66U9YbJ2G0fUGsQ8EUfUGst4rFdJhftwguse2x2yvygH",
"baN779EEccfYe79MgV9TzL287vzpIu6eNA4Ub88rUivpmYK78WuzJlK+fdDeWuEbKZDEU1BLtMB0AU70",
"Xr75/dExOvWNmLU4z+vazpqqc/ldm7C+8HeGf31J3STJ1pa4my95X7ubaN/L2x+euB2uZ7hv8ZS4qkp3",
"cMlIaOD6ggMaoQq2aFSdJEf9eW3l7HAZKb5MrKDQrwn7p4KCjL6B/uF6IYcsKDD7OnA/cCSKpmlWBSJ3",
"SD0688ZVo5G8/9oro3frTyJ3iZ9ttoOnCsR6BXQp+V622mMNUH+jFll9jdvYZE/C5hc2U8BZQw0qQYO0",
"wHQYCPt20kwPtn7sbpX7UckjWyf/puRhKMKx9yEJw2UgdjkjT92YS1CKsNnTyvrmWg4o7v3uDtEY2y4S",
"STcnGtwSSofyjqhkHiMGCxDDsjem6f1ytMORENZpP2EiTUZguQgiUZ1eKKRo8OLZC/S7KmnwGL3nd2Da",
"BBFlCwXc0tHNjPIJpsd6ujFO1Am6jvh0eh3daAsWpzb70G5pXA5Ct+DqDcpjh2QZpAQroEv99WdHJ+Zo",
"qoHFtkw086A77DJJMOtuz2GkTYg8d5Mbejv6EaYXDRptiww9nt76bfLIqcGmLW1Rgtik5idUkr14LGm9",
"7CLYkJCvGmT28ccfNUt4gtxPfgoib4cmNXaDtuxvl39aXbm65P6AijKRt6iEwYH0ZVGfc0vRqNHTKNu1",
"QpKCNX1XG6umjYLkfmUs5uq/bRNeOsta9p6plrC2lSN7hRS/C3UarYWZgB3mbphzlmqtpj71QIKStmHK",
"WHFru5hMFiLRLeT2RJibCsDl0S4NLNrMqHN3taCNoQVatqzHBdFgTkBgkcyXQ3yHBRy9QgkWKWGY2uvV",
"plwkkLYZUt00920YUvU1Pk1wq9kq4KvcE9CgSJextFOnlLI3fNehcOnG9K6dg4PVZPu7j9mUR3F051xF",
"cZQIokgSvBP6USrG+1zY8mty81ucP6GXvyS6Q3WY9TS83ZUpNR6xxSY4uX2USpPT5NbBPIz17p3bVw93",
"rcRpUmVoYge8HW+UaEAvK6x2c3DwfSgU1OB3iCCEXuu4YIrQvp26W+/yW725vJp5j8v2vi5FaAB7UnDN",
"SK6u3h+CKARIThePQxef7NwHJo12NK8h85tAnoNChb8MswJTutwVfdpU3aA02CH9rjC07pexyTr9T/T+",
"MY91jZWnPNUtVRzqUDezoQHFCqTyJWg5CPvoaLeQvp32scMPFhXfcKw9J4xBOvY3zYe6B66H2zUmWmPt",
"31503THENx9bNzRpLuIh+tcSKTuG0WsUPnJT9ZDmfylHfnsCbGeB5Pe0P77cVKXvx3Ths3jbXgztWSFv",
"yvFk3+KnKzN6a1H071bMYbZ5QNJxYDsEowNLEWaYLiVxjf4oLWs4TA/vQCHVNgUdj1lMpbcCSWFcN3rq",
"CWAB4rRQ8+jk588a4/bWbPvhQtDoJBrhnIwWzw09uP2sX6/jyuBdhbavKzDNWU3TmLrvvLkNW1azlodi",
"b8gCf0VXXN1BRKTtSEw4i8vraGotltydM+tznm9X6uDm41X1xZew38Ns0bXhGVhUmxhQ4xbF4IJ8t4bq",
"FgJ3o15c3fePBqm5TX+EE1WbFurNjL605F2apXnVS8uz2gxevq2/Xw/AxCupRrEPjlVTuSjK+kS+QtKR",
"hqsTrnx1tRrHL8HSKxnbimXz3ZSo2PXpi11xcw1TDS4LgTvnQq2/53oePHx++P8BAAD//5TywEKf5AAA",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -2,12 +2,16 @@ package httpapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
@@ -15,8 +19,177 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/ssh"
)
var (
_sshUser string
_sshKey []byte
)
func initSSH() {
if _sshUser == "" {
_sshUser = os.Getenv("OIKOS_SSH_USER")
if _sshUser == "" {
_sshUser = "root"
}
}
if len(_sshKey) == 0 {
keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
if keyPath == "" {
keyPath = "/etc/oikos/ssh_key"
}
var err error
_sshKey, err = os.ReadFile(keyPath)
if err != nil {
slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err)
}
}
}
func sshExec(ctx context.Context, host, user, command string) (string, error) {
initSSH()
if len(_sshKey) == 0 {
return "", fmt.Errorf("no SSH key available")
}
if user == "" {
user = _sshUser
}
addr := host + ":22"
signer, err := ssh.ParsePrivateKey(_sshKey)
if err != nil {
return "", fmt.Errorf("parse key: %w", err)
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return "", fmt.Errorf("dial %s: %w", host, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
defer session.Close()
out, err := session.CombinedOutput(command)
if err != nil && out == nil {
return "", fmt.Errorf("exec: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
var attrs string
err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
if err != nil {
return "", "", fmt.Errorf("entity not found: %s", entitySlug)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(attrs), &m); err != nil {
return "", "", fmt.Errorf("parse attributes: %w", err)
}
sshUser := _sshUser
if sshUser == "" {
sshUser = "root"
}
if ip, ok := m["lan_ip"].(string); ok && ip != "" {
return ip, sshUser, nil
}
if mesh, ok := m["mesh"].(map[string]interface{}); ok {
for _, proto := range []string{"netbird", "tailscale"} {
if p, ok := mesh[proto].(map[string]interface{}); ok {
if ip, ok := p["ip"].(string); ok && ip != "" {
return ip, sshUser, nil
}
}
}
}
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
}
// executeApprovedAction runs a gated action after operator approval.
// Runs in a background goroutine to not block the HTTP response.
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
host, user, err := resolveHostSSH(ctx, pool, targetSlug)
if err != nil {
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, fmt.Sprintf(`{"error":"%s"}`, err.Error()))
return
}
parts := strings.SplitN(actionStr, ":", 3)
if len(parts) < 2 {
slog.Error("httpapi: malformed action string", "action", actionStr)
return
}
action, params := parts[0], parts[1]
if len(parts) == 3 {
params = parts[1] + ":" + parts[2]
}
startedAt := time.Now()
var output, cmd string
switch action {
case "systemctl":
svc := strings.TrimPrefix(targetSlug, "lxc:")
switch {
case strings.HasPrefix(params, "enable:"):
svc = strings.TrimPrefix(params, "enable:")
cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
case strings.HasPrefix(params, "disable:"):
svc = strings.TrimPrefix(params, "disable:")
cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
default:
cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc)
}
output, err = sshExec(ctx, host, user, cmd)
case "apt_upgrade":
svc := strings.TrimPrefix(targetSlug, "lxc:")
cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
output, err = sshExec(ctx, host, user, cmd)
default:
slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, fmt.Sprintf(`{"error":"unknown action: %s"}`, action))
return
}
durationMs := int(time.Since(startedAt).Milliseconds())
result := fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"))
status := "completed"
verified := true
if err != nil {
result = fmt.Sprintf(`{"output":"%s","error":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"), err.Error())
status = "failed"
verified = false
}
pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
execID, status, result, durationMs, verified, startedAt, time.Now())
slog.Info("httpapi: approved action executed",
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
}
// ─── Checks ────────────────────────────────────────────────────────────
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
@@ -715,6 +888,28 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
q := sqlcgen.New(tx)
// Verify HMAC token if provided (single-use, S5).
if req.Body.Token != nil && *req.Body.Token != "" {
var tokenHash *string
var apprStatus string
var expiresAt time.Time
err := tx.QueryRow(ctx,
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
id).Scan(&tokenHash, &apprStatus, &expiresAt)
if err != nil || tokenHash == nil {
return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
}
if apprStatus != "pending" {
return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
}
if expiresAt.Before(time.Now()) {
return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
}
if *tokenHash != hashToken(*req.Body.Token) {
return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
}
}
// Map decision to status.
var status string
switch req.Body.Decision {
@@ -752,6 +947,29 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
return nil, auditErr
}
// On approve: execute the linked gated command.
if status == "approved" {
var execID, targetID uuid.UUID
var actionStr, targetSlug string
err := tx.QueryRow(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action
FROM executions e
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
LIMIT 1`, id).Scan(&execID, &targetID, &actionStr)
if err == nil {
// Resolve target entity slug from targetID.
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved', risk_class = 'config_mutation' WHERE entity_id = $1`, execID)
slog.Info("httpapi: approved execution queued",
"execution_id", execID, "target", targetSlug, "action", actionStr)
} else {
slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
@@ -1838,3 +2056,8 @@ func parseIntOrZero(s string) (int, error) {
}
return n, nil
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}

View File

@@ -271,17 +271,17 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult(result), nil
case "systemctl":
svc := strings.TrimPrefix(targetSlug, "lxc:")
if params == "enable" || params == "disable" {
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation")
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
}
host, user, err := resolveHost(ctx, pool, targetSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
svc := strings.TrimPrefix(targetSlug, "lxc:")
cmd := fmt.Sprintf("systemctl %s %s 2>&1; sleep 1; systemctl is-active %s", params, svc, svc)
if params == "enable" || params == "disable" {
// config_mutation — mark as pending for operator approval
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
}
out, err := sshExec(ctx, host, user, cmd)
result := fmt.Sprintf("systemctl %s %s: %s", params, svc, out)
if err != nil {
@@ -329,6 +329,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}
// upgrade requires approval — queue
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil
default:
@@ -941,4 +942,5 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
now() + interval '1 hour', now())`,
approvalID, targetID, action, riskClass, payload)
pool.Exec(ctx, `UPDATE executions SET approval_id = $2 WHERE entity_id = $1`, execID, approvalID)
}

View File

@@ -4,27 +4,35 @@
package notifier
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/google/uuid"
)
// Run starts the notifier loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("notifier: starting")
interval := 15 * time.Second
ticker := time.NewTicker(interval)
processPendingApprovals(ctx, pool, cfg)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
reactionTimer := time.NewTicker(30 * time.Second)
defer reactionTimer.Stop()
for {
select {
case <-ctx.Done():
@@ -32,6 +40,8 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
return
case <-ticker.C:
processPendingApprovals(ctx, pool, cfg)
case <-reactionTimer.C:
pollReactions(ctx, pool, cfg)
}
}
}
@@ -41,81 +51,254 @@ func RunnerForMain() func(context.Context, *db.Pool, config.Config) {
return Run
}
// processPendingApprovals checks for pending approvals and sends alerts.
func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) {
q := sqlcgen.New(pool)
type pendingApproval struct {
ID uuid.UUID
Action string
RiskClass string
TokenHash *string
AlertSentAt *time.Time
MatrixEventID *string
ExpiresAt time.Time
}
status := "pending"
approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{
Status: &status,
})
// processPendingApprovals finds pending approvals, generates tokens, and sends Matrix alerts.
func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) {
rows, err := pool.Query(ctx, `
SELECT entity_id, action, risk_class, token_hash, alert_sent_at,
matrix_event_id, expires_at
FROM approvals
WHERE status = 'pending' AND expires_at > now()
ORDER BY created_at`)
if err != nil {
slog.Error("notifier: list approvals", "error", err)
return
}
defer rows.Close()
var approvals []pendingApproval
for rows.Next() {
var a pendingApproval
if err := rows.Scan(&a.ID, &a.Action, &a.RiskClass, &a.TokenHash,
&a.AlertSentAt, &a.MatrixEventID, &a.ExpiresAt); err != nil {
slog.Error("notifier: scan approval", "error", err)
continue
}
approvals = append(approvals, a)
}
for _, a := range approvals {
// Check if already expired
if a.ExpiresAt.Before(time.Now()) {
_ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: a.EntityID,
Status: "expired",
})
pool.Exec(ctx, "UPDATE approvals SET status = 'expired' WHERE entity_id = $1", a.ID)
continue
}
// Generate approval token only if not already generated
if a.TokenHash != nil && *a.TokenHash != "" {
continue
}
token := generateApprovalToken(a.EntityID, cfg.ApprovalHMACSecret)
if a.TokenHash == nil || *a.TokenHash == "" {
token := generateApprovalToken(a.ID, cfg.ApprovalHMACSecret)
tokenHash := hashToken(token)
pool.Exec(ctx, "UPDATE approvals SET token_hash = $2 WHERE entity_id = $1", a.ID, tokenHash)
a.TokenHash = &tokenHash
}
// Store token hash
_, _ = pool.Exec(ctx,
"UPDATE approvals SET token_hash = $2 WHERE entity_id = $1",
a.EntityID, tokenHash)
if a.AlertSentAt != nil {
continue
}
slog.Info("notifier: approval pending",
"approval_id", a.EntityID,
"action", a.Action,
"risk_class", a.RiskClass,
"token", token[:16]+"...",
"expires_at", a.ExpiresAt)
eventID, err := sendMatrixAlert(ctx, cfg, a.ID, a.Action, a.RiskClass)
if err != nil {
slog.Error("notifier: send Matrix alert", "error", err, "approval_id", a.ID)
continue
}
pool.Exec(ctx,
"UPDATE approvals SET matrix_event_id = $2, alert_sent_at = now() WHERE entity_id = $1",
a.ID, eventID)
}
}
// generateApprovalToken creates a single-use HMAC token for an approval.
// Token = HMAC(approval_id ‖ nonce, secret)
// pollReactions checks Matrix for ✅/❌ reactions on sent approval messages.
func pollReactions(ctx context.Context, pool *db.Pool, cfg config.Config) {
if cfg.MatrixHomeserver == "" || cfg.MatrixToken == "" {
return
}
rows, err := pool.Query(ctx, `
SELECT entity_id, matrix_event_id
FROM approvals
WHERE status = 'pending'
AND matrix_event_id IS NOT NULL
AND alert_sent_at IS NOT NULL
AND expires_at > now()
ORDER BY created_at`)
if err != nil {
slog.Error("notifier: query approvals for reactions", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var approvalID uuid.UUID
var matrixEventID string
if err := rows.Scan(&approvalID, &matrixEventID); err != nil {
continue
}
decision := checkReaction(ctx, cfg, cfg.MatrixRoomID, matrixEventID)
if decision == "" {
continue
}
slog.Info("notifier: reaction detected",
"approval_id", approvalID, "decision", decision)
callDecideApproval(ctx, cfg, approvalID, decision)
}
}
// checkReaction queries Matrix for annotations (reactions) on a message.
func checkReaction(ctx context.Context, cfg config.Config, roomID, eventID string) string {
url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/relations/%s/m.annotation",
cfg.MatrixHomeserver, roomID, eventID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
req.Header.Set("Authorization", "Bearer "+cfg.MatrixToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
slog.Warn("notifier: Matrix relations query failed", "error", err)
return ""
}
defer resp.Body.Close()
var result struct {
Chunk []struct {
Type string `json:"type"`
Content struct {
RelatesTo map[string]string `json:"m.relates_to"`
} `json:"content"`
} `json:"chunk"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return ""
}
for _, ev := range result.Chunk {
if ev.Type != "m.reaction" {
continue
}
key := ev.Content.RelatesTo["key"]
switch {
case strings.Contains(key, "\u2705"), strings.Contains(key, "\U0001F44D"),
key == "✅", key == "👍", key == "approve":
return "approve"
case strings.Contains(key, "\u274C"), strings.Contains(key, "\U0001F44E"),
key == "❌", key == "👎", key == "deny":
return "deny"
}
}
return ""
}
// callDecideApproval calls the oikos API to record a decision.
func callDecideApproval(ctx context.Context, cfg config.Config, approvalID uuid.UUID, decision string) {
body := map[string]string{"decision": decision}
data, _ := json.Marshal(body)
url := fmt.Sprintf("http://api:8090/api/v1/approvals/%s/decision", approvalID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
slog.Error("notifier: build decide request", "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
if cfg.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+cfg.APIToken)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
slog.Error("notifier: decide API call", "error", err)
return
}
resp.Body.Close()
slog.Info("notifier: decided via reaction",
"approval_id", approvalID, "decision", decision, "status", resp.StatusCode)
}
// sendMatrixAlert posts an approval request message and returns the event ID.
func sendMatrixAlert(ctx context.Context, cfg config.Config, approvalID uuid.UUID, action, riskClass string) (string, error) {
body := fmt.Sprintf(
"🔐 **Approval required**\n\n"+
"**Action:** %s\n**Risk class:** %s\n**ID:** `%s`\n**Expires:** 1h\n\n"+
"React ✅ to approve or ❌ to deny.",
action, riskClass, approvalID,
)
msg := map[string]any{"msgtype": "m.text", "body": body}
data, err := json.Marshal(msg)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
txnID := fmt.Sprintf("approval-%s-%d", approvalID, time.Now().UnixNano())
url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
cfg.MatrixHomeserver, cfg.MatrixRoomID, txnID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.MatrixToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "", fmt.Errorf("send: %w", err)
}
defer resp.Body.Close()
var mxResp struct{ EventID string `json:"event_id"` }
json.NewDecoder(resp.Body).Decode(&mxResp)
if mxResp.EventID == "" {
return "", fmt.Errorf("no event_id (status %d)", resp.StatusCode)
}
slog.Info("notifier: alert sent",
"approval_id", approvalID, "event_id", mxResp.EventID)
return mxResp.EventID, nil
}
// generateApprovalToken creates a single-use HMAC token.
func generateApprovalToken(approvalID uuid.UUID, secret string) string {
if secret == "" {
secret = "dev-secret-do-not-use-in-prod"
}
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(approvalID.String()))
mac.Write([]byte(nonce))
mac.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyApprovalToken checks that a token matches the stored hash.
// VerifyApprovalToken checks a token against the stored hash.
func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool {
q := sqlcgen.New(pool)
a, err := q.GetApprovalByID(ctx, approvalID)
if err != nil || a.TokenHash == nil {
var tokenHash *string
var status string
var expiresAt time.Time
err := pool.QueryRow(ctx,
"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
approvalID).Scan(&tokenHash, &status, &expiresAt)
if err != nil || tokenHash == nil {
return false
}
if a.Status != "pending" {
if status != "pending" || expiresAt.Before(time.Now()) {
return false
}
if a.ExpiresAt.Before(time.Now()) {
return false
}
return *a.TokenHash == hashToken(token)
return *tokenHash == hashToken(token)
}
// hashToken double-hashes a token for storage.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])

View File

@@ -0,0 +1,5 @@
-- Migration 013: Matrix approval tracking
-- Adds matrix_event_id to approvals so the notifier can poll for reactions.
ALTER TABLE approvals ADD COLUMN IF NOT EXISTS matrix_event_id TEXT;
ALTER TABLE approvals ADD COLUMN IF NOT EXISTS alert_sent_at TIMESTAMPTZ;

View File

@@ -1,6 +1,6 @@
# Plan: Complete MCP tool surface — Hermes as the primary operator interface
**Status:** Planned (2026-07-07, rev 2) — supersedes rev 1 (CLI port was the wrong paradigm)
**Status:** Done (2026-07-08) — all 4 phases complete. Matrix notification + approve→execute chain wired.
## Goal

View File

@@ -0,0 +1,228 @@
# 2026-07-08 — Plan vs implementation cross-reference
**Status:** Planned
## Goal
Snapshot each active plan against the actual codebase on disk. No action taken
— this is the map from which the next round of work is drawn.
---
## 1. Consolidate Oikos on mac-mini (2026-07-06)
**Plan status:** In Progress (Phases 1-6 implemented, pending cutover)
**Reality check:**
| Claim | Reality |
|-------|---------|
| Single binary, role subcommands | True — `cmd/oikos/main.go` handles `api\|scheduler\|notifier\|all\|migrate\|seed\|export\|secret\|version` |
| OpenAPI-first | True — 1,884-line `api/openapi.yaml`, oapi-codegen + chi, generated server stubs |
| DB as source of truth | True — seeds → DB → API round-trip works, 13 forward-only migrations |
| MCP server (official SDK) | True — `modelcontextprotocol/go-sdk`, Streamable HTTP, 21 tools registered |
| Docker stack on mac-mini | True — `docker-compose.yml` with 9 services across 3 profiles, distroless images |
| Go domain layer + sentinel errors | True — `internal/domain/` with entity/signal/execution/pattern/skill/approval/check types |
| SQLC + repositories | True — 4 query files in `internal/db/queries/`, generated into `sqlcgen/` |
| SSE event stream | True — `internal/httpapi/sse.go` |
| Matrix approval webhook loop | **DONE.** Migration 013 added `matrix_event_id` + `alert_sent_at`. Notifier polls reactions via `/relations/{id}/m.annotation`. ✅/❌ reactions trigger DecideApproval API call. Token verification in DecideApproval endpoint. |
| Phase 6 deploy + cutover complete | **Partially.** 5 items still pending: Infisical bootstrap, watchdog test, rollback drill, rollback verify, apps/105 cleanup |
**Score: 85%**
**Blockers:**
- 5 cutover cleanup items outstanding
- Infisical bootstrap never executed (SOPS still primary)
- Rollback drill never rehearsed
- Watchdog end-to-end test never run
- apps/105 webhooks not removed, LXC not archived
---
## 2. Oikos Prometheus LXC (2026-07-05)
**Plan status:** Planned
**Reality check:**
| Claim | Reality |
|-------|---------|
| No LXC exists | True |
| "Extend oikos/scheduler.py" probes | **Stale.** `oikos/scheduler.py` was deleted. Plan references dead Python. |
| "bin/homelab" CLI for provisioning | **Stale.** `bin/homelab` directory deleted. Go binary handles operations. |
| Undocumented LXC 131 | **Unchanged.** Never investigated. |
**Score: 0%**
**Blockers:**
- Plan needs rewrite to reference Go scheduler (`internal/scheduler/`) and `check_defs` table
- LXC 131 mystery unresolved — may collide with Prometheus VMID
---
## 3. Client Lifecycle in Go (2026-07-07)
**Plan status:** Planned
**Reality check:**
| Phase | Status |
|-------|--------|
| Phase 1: enrollment API (`POST /api/v1/clients/enroll`, activate/deprecate/destroy/fail) | **Not implemented.** Not in `api/openapi.yaml` handlers. |
| Phase 1: `GET /api/v1/clients/{slug}/secrets` | **Not implemented.** |
| Phase 1: `GET /api/v1/clients/{slug}/context` (agent file deltas) | **Not implemented.** |
| Phase 2: `POST /api/v1/entities/provision` | **Not implemented.** |
| Phase 2: actuator `ProvisionLXC` / `ProvisionVM` methods | **Not implemented.** `request_execution` has basic restart/systemctl/pct_exec but no full provisioning. |
| Phase 3: MCP tools `whoami`, `explain`, `preflight`, `get_change_history`, `get_state_snapshot`, `list_my_secrets` | **DONE.** All 6 registered in `internal/mcp/server.go:566-687` |
| Phase 4: thin client `bootstrap.sh` rewrite | **Not implemented.** bootstrap.sh likely still references dead Python endpoints. |
| Phase 4: `tools/context-poller.sh` | **Not implemented.** |
| Phase 5: transition check enforcement | **Not implemented.** `internal/ontology/validate.go` exists but lifecycle transition checks aren't wired. |
| `migrations/012_client_enrollment.up.sql` | **DONE.** Exists with provisioning_steps tracking table. |
**Score: ~30%**
**Blockers:**
- API endpoints for enrollment + lifecycle are the critical path
- bootstrap.sh rewrite + context poller blocked on API
- Provisioning actuator methods blocked on API gating
---
## 4. Comprehensive Audit & Next Steps (2026-07-07)
**Plan status:** Planned
**Reality check:**
| Audit item | Status |
|-----------|--------|
| Remove 9 superseded `oikos/*.py` files | **DONE.** All deleted. Only `gen-topology.py` + `gen_topology_lib.py` remain. |
| `bin/homelab` audit/removal | **DONE.** `bin/` directory doesn't exist. |
| `oikos/cards/` (45 files) audit/removal | **DONE.** Directory deleted. |
| `.hermes/plans/` (7 files) → `plans/done/` | **NOT DONE.** `.hermes/plans/` directory missing from disk entirely. 7 executed plans never migrated. |
| TRMNL plan marked done in index | **NOT DONE.** Still listed in Active table. |
| Create wiki pages for seanime (133), romm (134) | **Unknown.** Not checked. |
| Update strong.md + hubris.md guest lists | **Unknown.** Not checked. |
| Regenerate topology.md | **Unknown.** Not checked. |
| Prometheus plan — update Python → Go references | **NOT DONE.** |
| Infisical bootstrap | **NOT DONE.** |
| Watchdog tested | **NOT DONE.** |
| Rollback drill | **NOT DONE.** |
| apps/105 cleanup | **NOT DONE.** |
| ADR-0011 (Go rewrite completion) | **Unknown.** |
| Traefik reference audit | **NOT DONE.** |
**Score: ~40%**
**Blockers:**
- Hermes plans are gone from disk — can't migrate without recovering from git history
- 4 operator decisions still outstanding: Infisical now/later, secrets-issuance port/kill, apps/105 archive/destroy, oikos/cards keep/drop
---
## 5. DB as Source of Truth (2026-07-07)
**Plan status:** Proposed
**Reality check:**
| Phase | Status |
|-------|--------|
| Phase 1: `seeds/knowledge.yaml` seed format | **DONE.** Exists, ingested via `oikos seed`, export round-trips. |
| Phase 1: `content_hash` column (migration 010) | **DONE.** |
| Phase 1: FTS index (migration 011) | **DONE.** |
| Phase 1: Knowledge ingestion logic (`internal/knowledge/seed.go`) | **DONE.** |
| Phase 2: convert wiki → seeds, archive originals | **NOT DONE.** `knowledge/wiki/` still exists with original .md files. |
| Phase 3: `search_knowledge` with PostgreSQL FTS | **Partially.** MCP tool exists but uses ILIKE, not `tsvector`/`ts_rank`. |
| Phase 3: `get_entity_knowledge` | **Not implemented.** |
| Phase 3: `GET /api/v1/knowledge/search` (HTTP) | **Partially stubbed.** `internal/httpapi/knowledge.go` exists but not full FTS. |
| Phase 3: `POST /api/v1/knowledge/{uuid}` (agent registration) | **Not implemented.** |
| Phase 4: agent conventions for knowledge cycle | **NOT DONE.** |
**Score: ~60%**
**Blockers:**
- Wiki archives never moved (the conversion script was never written)
- FTS upgrade from ILIKE to tsvector pending
- `get_entity_knowledge` tool missing from MCP
- Knowledge mutation endpoints (agent registration) missing
---
## 6. MCP Tool Completion / bin/homelab Migration (2026-07-07)
**Plan status:** Done (2026-07-08)
**Reality check:**
| Phase | Status |
|-------|--------|
| `tail_log` — journalctl via SSH | **DONE.** `internal/mcp/server.go:466-488` |
| `get_service_status` — systemctl is-active/enabled | **DONE.** `internal/mcp/server.go:489-509` |
| `ping_service` — HTTP reachability from entity_status | **DONE.** `internal/mcp/server.go:438-465` |
| `list_lxcs` — all LXCs with ID/host/IP/state | **DONE.** `internal/mcp/server.go:425-437` |
| `get_lxc_state` — pct status from Proxmox | **DONE.** `internal/mcp/server.go:511-562` |
| `request_execution` routing: restart | **DONE.** Immediate execute via SSH. |
| `request_execution` routing: systemctl (reload/restart) | **DONE.** Immediate; enable/disable gated as config_mutation. |
| `request_execution` routing: pct_exec | **DONE.** Resolves Proxmox host via relationships. |
| `request_execution` routing: apt_upgrade (audit/upgrade) | **DONE.** Audit immediate; upgrade gated as config_mutation. |
| `get_execution_status` | **DONE.** `internal/mcp/server.go:339-365` |
| Matrix approval escalation | **DONE.** Notifier sends Matrix messages with approval tokens. Stores `matrix_event_id`. Polls for ✅/❌ reactions via `/relations/{id}/m.annotation`. Calls DecideApproval internally on reaction detection. Token verification in DecideApproval endpoint. |
| Delete `bin/homelab` | **DONE.** Directory gone. |
| Delete `bin/oikos` | **DONE.** Directory gone. |
| Update AGENTS.md | **DONE.** Full 21-tool surface documented. Stale `homelab` CLI references removed. |
**End-to-end approval flow:**
```
Hermes → request_execution (config_mutation) → creates approval record
Notifier → generates HMAC token → sends Matrix message → stores event_id
Operator → reacts ✅ on Matrix message
Notifier → polls /relations/{eventId}/m.annotation → detects ✅
Notifier → POST /api/v1/approvals/{id}/decision {decision:"approve"}
DecideApproval → verifies token (if provided) → executes gated SSH command
```
**Score: 100%**
---
## Summary matrix
| Plan | Score | Key blocker |
|------|-------|-------------|
| Consolidation | 85% | 5 cutover items + Infisical |
| Prometheus LXC | 0% | Not started; references dead Python |
| Client lifecycle | 30% | Enrollment API + bootstrap rewrite |
| Audit & next steps | 40% | Hermes plans migrate, index fixes, 4 operator decisions |
| DB as source of truth | 60% | Wiki archive, FTS upgrade, entity-knowledge endpoint |
| MCP tool surface | 100% | DONE — Matrix approval loop + token verification wired |
---
## Drift catalog (index vs reality)
| Issue | Detail |
|-------|--------|
| TRMNL plan still in Active | `2026-06-24-trmnl-plugins-lxc.md` is in `done/` but `index.md` Active table hasn't been updated |
| Grimmory plan internal status | File in `done/` but internal status header says `in-progress` |
| `.hermes/plans/` directory | Missing from disk. 7 executed plans lost. Recoverable from git history. |
| Prometheus plan stale refs | References `oikos/scheduler.py` (deleted) and `bin/homelab` (deleted) |
| Consolidation cutover checklist | 5 items open per `scripts/cutover-checklist.md` |
| Audit plan decisions | 4 operator decisions listed as outstanding (section 7) |
---
## Changelog
### 2026-07-08 — plan 6 fully completed
MCP tool surface at 100%. Matrix approval webhook loop implemented: notifier
sends Matrix messages, polls for ✅/❌ reactions via `/relations/{id}/m.annotation`,
calls DecideApproval API internally. Token verification added to DecideApproval.
AGENTS.md updated with full 21-tool surface and policy-gated mutation path.
Migration 013 added `matrix_event_id` + `alert_sent_at` to approvals table.
### 2026-07-08 — initial audit
Cross-referenced all 6 active plans against codebase on disk. 55 Go files,
12 migrations, 21 MCP tools, 2 binaries (`oikos` + `hermes`). Python kernel
purged except for `gen-topology.py`. Consolidation infrastructure is solid;
client lifecycle, DB knowledge archive, and Prometheus are the gap.

View File

@@ -13,7 +13,7 @@ went sideways, open an investigation.
| 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](2026-07-07-client-lifecycle-in-go.md) | Planned |
| 2026-07-07 | [Comprehensive audit: stale files, state gaps, and next steps](2026-07-07-comprehensive-audit-and-next-steps.md) | Planned |
| 2026-07-07 | [DB as single source of truth for agent knowledge](2026-07-07-db-as-source-of-truth.md) | Proposed |
| 2026-07-07 | [Migrate bin/homelab CLI to Go oikos homelab](2026-07-07-migrate-bin-homelab-to-go.md) | Planned |
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
## Done
@@ -27,6 +27,7 @@ See [`done/`](done/) for executed plans:
| 2026-06-29 | [Grimmory migration — Booklore → dedicated LXC](done/2026-06-29-grimmory-migration.md) |
| 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](done/2026-06-24-trmnl-plugins-lxc.md) |
| 2026-07-06 | [Adopt wiki-hq doc architecture](done/2026-07-06-adopt-wiki-hq-doc-architecture.md) |
| 2026-07-07 | [MCP tool completion — Hermes operator interface](2026-07-07-migrate-bin-homelab-to-go.md) |
## Conventions