loki-mode 9.17.2 → 9.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +439 -2
- package/autonomy/run.sh +215 -11
- package/autonomy/trigger-server.py +518 -17
- package/bin/loki +7 -1
- package/dashboard/__init__.py +1 -1
- package/docs/COMPETITIVE-SCORECARD.md +38 -0
- package/docs/COMPETITOR-DEPLOYMENT-MODELS.md +475 -0
- package/docs/DEPLOYMENT.md +542 -0
- package/docs/STALE-STATE-AUDIT.md +174 -0
- package/docs/VERIFICATION-COST.md +31 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
|
@@ -7,11 +7,25 @@ response. Supports constant-time HMAC-SHA256 signature validation, a bounded
|
|
|
7
7
|
worker queue so a webhook storm cannot fork unbounded builds, child-process
|
|
8
8
|
reaping (no zombies), and event logging.
|
|
9
9
|
|
|
10
|
+
Also serves an authenticated remote-submit API so a local `loki` client can
|
|
11
|
+
enqueue a build against a deployed cluster:
|
|
12
|
+
|
|
13
|
+
POST /jobs {"spec": "<ref-or-brief>"} -> 202 {"id": ...}
|
|
14
|
+
GET /jobs/<id> -> job status
|
|
15
|
+
GET /jobs/<id>/proof -> that job's Evidence Receipt
|
|
16
|
+
|
|
10
17
|
Security note: a webhook secret is REQUIRED. If no secret is configured the
|
|
11
18
|
server still starts (so /health and /status stay available for operators), but
|
|
12
19
|
every webhook POST is rejected with 503 and an audit log line. The server never
|
|
13
20
|
silently accepts unauthenticated webhooks.
|
|
14
21
|
|
|
22
|
+
The SAME rule holds for the /jobs API, with a SEPARATE credential: it is
|
|
23
|
+
authenticated by a bearer token (LOKI_API_TOKEN / LOKI_API_TOKEN_FILE), never by
|
|
24
|
+
the webhook HMAC. The two have different threat models -- the HMAC authenticates
|
|
25
|
+
GitHub, the bearer token authenticates a human operator -- so holding one must
|
|
26
|
+
never grant the other. If no API token is configured the server still starts but
|
|
27
|
+
every /jobs request is rejected with 503 and an audit log line.
|
|
28
|
+
|
|
15
29
|
Usage:
|
|
16
30
|
python3 autonomy/trigger-server.py [--port PORT] [--secret SECRET] [--dry-run]
|
|
17
31
|
[--workers N] [--queue-size N]
|
|
@@ -27,6 +41,7 @@ import logging
|
|
|
27
41
|
import os
|
|
28
42
|
import queue
|
|
29
43
|
import re
|
|
44
|
+
import secrets
|
|
30
45
|
import socket
|
|
31
46
|
import socketserver
|
|
32
47
|
import subprocess
|
|
@@ -70,6 +85,67 @@ def valid_issue_number(number):
|
|
|
70
85
|
return isinstance(number, int) and not isinstance(number, bool) and number > 0
|
|
71
86
|
|
|
72
87
|
|
|
88
|
+
# Remote-submit ("/jobs") limits. A remotely-submitted spec is UNTRUSTED input:
|
|
89
|
+
# it is passed to `loki start` as a single argv element (never a shell string),
|
|
90
|
+
# and must additionally survive the same rigor as REPO_FULL_NAME_RE above.
|
|
91
|
+
MAX_SPEC_BYTES = 4096
|
|
92
|
+
|
|
93
|
+
# Control characters are rejected outright: a spec is a ref, path or one-line
|
|
94
|
+
# brief, so NUL/CR/LF/ESC in it is either an injection attempt or a mistake.
|
|
95
|
+
CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
96
|
+
|
|
97
|
+
# Pseudo-event name for a remotely-submitted job. Deliberately NOT a GitHub
|
|
98
|
+
# event name, and explicitly refused on the /webhook path (see do_POST): a
|
|
99
|
+
# holder of the webhook HMAC must never be able to submit an arbitrary spec by
|
|
100
|
+
# sending X-GitHub-Event: loki_job. That is the whole point of the separate
|
|
101
|
+
# credential.
|
|
102
|
+
JOB_EVENT = "loki_job"
|
|
103
|
+
|
|
104
|
+
# How many recent job records to keep for GET /jobs/<id>. Bounded so a submit
|
|
105
|
+
# storm cannot grow memory without limit.
|
|
106
|
+
DEFAULT_JOB_HISTORY = 1024
|
|
107
|
+
|
|
108
|
+
# A run id names a directory under .loki/proofs/. run.sh mints it as
|
|
109
|
+
# "run-<utc>-<pid>-<rand>" or "proof-<utc>-<pid>-<rand>"; this is the alphabet
|
|
110
|
+
# those forms use. Applied to the pointer file's contents (never to a request
|
|
111
|
+
# path) so a corrupt pointer cannot name an arbitrary path.
|
|
112
|
+
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def valid_spec(spec):
|
|
116
|
+
"""Return True if spec is a safe `loki start` argument.
|
|
117
|
+
|
|
118
|
+
Must be a non-empty str (a dict/list/int is rejected outright rather than
|
|
119
|
+
coerced, exactly like valid_issue_number), within the size cap, free of
|
|
120
|
+
control characters, and not dash-leading -- a leading dash would be parsed
|
|
121
|
+
by `loki start` as a CLI FLAG, the same injection REPO_FULL_NAME_RE guards
|
|
122
|
+
against on the webhook path.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(spec, str):
|
|
125
|
+
return False
|
|
126
|
+
spec = spec.strip()
|
|
127
|
+
if not spec:
|
|
128
|
+
return False
|
|
129
|
+
if len(spec.encode("utf-8")) > MAX_SPEC_BYTES:
|
|
130
|
+
return False
|
|
131
|
+
if CONTROL_CHARS_RE.search(spec):
|
|
132
|
+
return False
|
|
133
|
+
if spec.startswith("-"):
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def constant_time_equals(a, b):
|
|
139
|
+
"""Constant-time string compare that never raises on odd input.
|
|
140
|
+
|
|
141
|
+
hmac.compare_digest raises TypeError on a non-ASCII str, so both sides are
|
|
142
|
+
encoded to bytes first: a weird header must produce a 401, not a 500.
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(a, str) or not isinstance(b, str):
|
|
145
|
+
return False
|
|
146
|
+
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
|
147
|
+
|
|
148
|
+
|
|
73
149
|
# How long to wait for a dispatched `loki start` to finish before we stop
|
|
74
150
|
# waiting on it. The child is launched detached (--detach) so it backgrounds
|
|
75
151
|
# itself quickly; this bound only guards the worker thread against a wedged
|
|
@@ -91,6 +167,54 @@ def get_loki_dir():
|
|
|
91
167
|
return loki_dir
|
|
92
168
|
|
|
93
169
|
|
|
170
|
+
def read_proof_pointer():
|
|
171
|
+
"""Return the run id in .loki/state/last-proof-id.txt, or "" if absent.
|
|
172
|
+
|
|
173
|
+
run.sh's generate_proof_of_run writes this pointer atomically after emitting
|
|
174
|
+
a receipt, naming the directory it wrote (.loki/proofs/<run_id>/). It is the
|
|
175
|
+
ONLY durable link from a finished build to its proof: the run id is minted
|
|
176
|
+
inside run.sh and is deliberately NOT derivable from LOKI_SESSION_ID (the
|
|
177
|
+
persisted per-run id file wins over the env var), so the server cannot
|
|
178
|
+
predict it and must observe it instead.
|
|
179
|
+
|
|
180
|
+
The pointer is global to the working directory and is not written at all
|
|
181
|
+
when LOKI_PROVEN_PR=0. Both cases are handled by the caller, which fails
|
|
182
|
+
closed rather than guessing.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
return Path(".loki/state/last-proof-id.txt").read_text(
|
|
186
|
+
encoding="utf-8"
|
|
187
|
+
).strip()
|
|
188
|
+
except (OSError, UnicodeDecodeError):
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def read_proof(run_id):
|
|
193
|
+
"""Return the parsed proof.json for run_id, or None if unreadable.
|
|
194
|
+
|
|
195
|
+
run_id comes from the server-written pointer above, never from a request
|
|
196
|
+
path, so there is no traversal sink here. It is still constrained to the
|
|
197
|
+
id alphabet as defense in depth, since a corrupt pointer file must not be
|
|
198
|
+
able to name an arbitrary path.
|
|
199
|
+
"""
|
|
200
|
+
# RUN_ID_RE alone is not enough: it permits "." and "..", and ".." would
|
|
201
|
+
# resolve to .loki/proof.json, one level above the proofs directory. The
|
|
202
|
+
# dot-forms are rejected outright and the resolved path is then confined
|
|
203
|
+
# under .loki/proofs, so no run id -- however corrupt -- escapes it.
|
|
204
|
+
if not run_id or run_id in (".", "..") or not RUN_ID_RE.match(run_id):
|
|
205
|
+
return None
|
|
206
|
+
proofs_root = (Path(".loki") / "proofs").resolve()
|
|
207
|
+
target = (proofs_root / run_id / "proof.json").resolve()
|
|
208
|
+
if proofs_root not in target.parents:
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
with open(target) as f:
|
|
212
|
+
data = json.load(f)
|
|
213
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
214
|
+
return None
|
|
215
|
+
return data if isinstance(data, dict) else None
|
|
216
|
+
|
|
217
|
+
|
|
94
218
|
def load_config():
|
|
95
219
|
"""Load trigger config from .loki/triggers/config.json."""
|
|
96
220
|
config_path = get_loki_dir() / "config.json"
|
|
@@ -112,6 +236,29 @@ def load_config():
|
|
|
112
236
|
return defaults
|
|
113
237
|
|
|
114
238
|
|
|
239
|
+
def load_api_token():
|
|
240
|
+
"""Return the /jobs bearer token from env or a mounted file ("" if unset).
|
|
241
|
+
|
|
242
|
+
Sources, in order: LOKI_API_TOKEN, then the file at LOKI_API_TOKEN_FILE
|
|
243
|
+
(the normal Kubernetes mounted-secret path). Deliberately NOT a CLI flag --
|
|
244
|
+
an argv secret is readable by any local user via the process list -- and
|
|
245
|
+
never persisted to config.json.
|
|
246
|
+
|
|
247
|
+
A mounted secret file usually ends with a newline, so the contents are
|
|
248
|
+
stripped; otherwise every comparison would fail.
|
|
249
|
+
"""
|
|
250
|
+
token = os.environ.get("LOKI_API_TOKEN", "").strip()
|
|
251
|
+
if token:
|
|
252
|
+
return token
|
|
253
|
+
token_file = os.environ.get("LOKI_API_TOKEN_FILE", "").strip()
|
|
254
|
+
if token_file:
|
|
255
|
+
try:
|
|
256
|
+
return Path(token_file).read_text(encoding="utf-8").strip()
|
|
257
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
258
|
+
logging.error("Failed to read LOKI_API_TOKEN_FILE %s: %s", token_file, e)
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
|
|
115
262
|
def save_config(config):
|
|
116
263
|
"""Save trigger config to .loki/triggers/config.json."""
|
|
117
264
|
config_path = get_loki_dir() / "config.json"
|
|
@@ -124,10 +271,15 @@ def save_config(config):
|
|
|
124
271
|
_log_lock = threading.Lock()
|
|
125
272
|
|
|
126
273
|
|
|
274
|
+
def _utc_now():
|
|
275
|
+
"""UTC timestamp string shared by the event log and job records."""
|
|
276
|
+
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
277
|
+
|
|
278
|
+
|
|
127
279
|
def log_event(event_type, action, payload_summary, status):
|
|
128
280
|
"""Append event to .loki/triggers/events.log (thread-safe)."""
|
|
129
281
|
log_path = get_loki_dir() / "events.log"
|
|
130
|
-
timestamp =
|
|
282
|
+
timestamp = _utc_now()
|
|
131
283
|
entry = {
|
|
132
284
|
"timestamp": timestamp,
|
|
133
285
|
"event": event_type,
|
|
@@ -331,11 +483,34 @@ def handle_workflow_run_event(payload, dry_run=False):
|
|
|
331
483
|
return summary, status
|
|
332
484
|
|
|
333
485
|
|
|
334
|
-
|
|
486
|
+
def handle_job_event(payload, dry_run=False):
|
|
487
|
+
"""Handle a remotely-submitted job: loki start <spec> --detach.
|
|
488
|
+
|
|
489
|
+
Runs on the same worker pool as the webhook handlers. The spec was already
|
|
490
|
+
validated by valid_spec() at admission; it is re-checked here so this
|
|
491
|
+
handler is safe no matter who calls it, and it is passed as a single argv
|
|
492
|
+
element -- never interpolated into a shell string.
|
|
493
|
+
"""
|
|
494
|
+
spec = payload.get("spec")
|
|
495
|
+
if not valid_spec(spec):
|
|
496
|
+
return None, "rejected (invalid spec)"
|
|
497
|
+
args = ["start", spec.strip(), "--detach"]
|
|
498
|
+
summary = "job %s: %s" % (payload.get("job_id", "?"), spec.strip())
|
|
499
|
+
success = run_loki_command(args, dry_run=dry_run)
|
|
500
|
+
status = "fired" if success else "error"
|
|
501
|
+
if success:
|
|
502
|
+
send_notification("Trigger fired: %s" % summary)
|
|
503
|
+
return summary, status
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# Map event name -> handler. Keeps do_POST routing declarative. JOB_EVENT rides
|
|
507
|
+
# the same table (and therefore the same bounded queue and worker pool) but is
|
|
508
|
+
# explicitly refused on the /webhook path.
|
|
335
509
|
EVENT_HANDLERS = {
|
|
336
510
|
"issues": handle_issues_event,
|
|
337
511
|
"pull_request": handle_pull_request_event,
|
|
338
512
|
"workflow_run": handle_workflow_run_event,
|
|
513
|
+
JOB_EVENT: handle_job_event,
|
|
339
514
|
}
|
|
340
515
|
|
|
341
516
|
|
|
@@ -370,9 +545,23 @@ class Dispatcher:
|
|
|
370
545
|
"""
|
|
371
546
|
|
|
372
547
|
def __init__(self, workers=DEFAULT_WORKERS, queue_size=DEFAULT_QUEUE_SIZE,
|
|
373
|
-
dry_run=False, dedup_size=DEFAULT_DEDUP_SIZE
|
|
548
|
+
dry_run=False, dedup_size=DEFAULT_DEDUP_SIZE,
|
|
549
|
+
job_history=DEFAULT_JOB_HISTORY):
|
|
374
550
|
self.dry_run = dry_run
|
|
375
551
|
self.queue = queue.Queue(maxsize=max(1, queue_size))
|
|
552
|
+
# Job status for GET /jobs/<id>. Same bounded-FIFO idiom as the dedup
|
|
553
|
+
# cache below: oldest records are evicted so memory cannot grow without
|
|
554
|
+
# limit under a submit storm.
|
|
555
|
+
self._job_max = max(1, job_history)
|
|
556
|
+
self._jobs = collections.OrderedDict()
|
|
557
|
+
self._jobs_lock = threading.Lock()
|
|
558
|
+
# Proof-attribution bookkeeping. The proof pointer is global to the
|
|
559
|
+
# working directory, so a receipt only identifies a job when that job
|
|
560
|
+
# was the only build that could have written it. All three are updated
|
|
561
|
+
# on EVERY dispatch (webhook and remote-submit alike) under _jobs_lock.
|
|
562
|
+
self._dispatch_seq = 0 # monotonic count of dispatches
|
|
563
|
+
self._pending = 0 # dispatches with no receipt seen yet
|
|
564
|
+
self._last_pointer = read_proof_pointer()
|
|
376
565
|
# Idempotency: remember recently seen GitHub delivery IDs so a
|
|
377
566
|
# redelivered webhook (GitHub retries on non-2xx, and operators can
|
|
378
567
|
# manually redeliver) does not dispatch the same build twice. Bounded
|
|
@@ -414,6 +603,119 @@ class Dispatcher:
|
|
|
414
603
|
self._seen_deliveries.popitem(last=False)
|
|
415
604
|
return False
|
|
416
605
|
|
|
606
|
+
def _begin_proof_window(self, job_id=None):
|
|
607
|
+
"""Record that a build was dispatched, snapshotting the proof pointer.
|
|
608
|
+
|
|
609
|
+
Called for EVERY dispatch, not just remotely-submitted ones. A webhook
|
|
610
|
+
build (issues / pull_request / workflow_run) runs `loki start` in the
|
|
611
|
+
same working directory and writes the same global pointer, so if it
|
|
612
|
+
finished during a submitted job's window and was not counted here, its
|
|
613
|
+
receipt would be attributed to that job. That is exactly the
|
|
614
|
+
borrowed-receipt failure this design exists to prevent.
|
|
615
|
+
|
|
616
|
+
Attribution is deliberately NOT resolved when the dispatch returns.
|
|
617
|
+
`loki start --detach` returns as soon as the child forks, while the
|
|
618
|
+
build runs for minutes and writes its receipt at the very end -- so a
|
|
619
|
+
window closed at dispatch-return would always see an unchanged pointer
|
|
620
|
+
and every real build would 404. Resolution happens lazily in
|
|
621
|
+
get_job_proof_id() instead, at the moment someone asks.
|
|
622
|
+
"""
|
|
623
|
+
with self._jobs_lock:
|
|
624
|
+
self._dispatch_seq += 1
|
|
625
|
+
# A build whose receipt has not yet appeared stays PENDING. While
|
|
626
|
+
# any earlier dispatch is pending, a pointer change is ambiguous:
|
|
627
|
+
# it could be that build finishing late rather than this one. The
|
|
628
|
+
# snapshot is only taken when this job is the sole pending build.
|
|
629
|
+
pointer = read_proof_pointer()
|
|
630
|
+
if pointer != self._last_pointer:
|
|
631
|
+
# Every pending build's receipt could be the one that just
|
|
632
|
+
# landed, so none of them can claim it, and the slate clears.
|
|
633
|
+
self._last_pointer = pointer
|
|
634
|
+
self._pending = 0
|
|
635
|
+
sole = self._pending == 0
|
|
636
|
+
self._pending += 1
|
|
637
|
+
entry = self._jobs.get(job_id) if job_id else None
|
|
638
|
+
if entry is not None:
|
|
639
|
+
entry["_proof_before"] = pointer
|
|
640
|
+
entry["_proof_seq"] = self._dispatch_seq
|
|
641
|
+
entry["_proof_sole"] = sole
|
|
642
|
+
|
|
643
|
+
def _resolve_proof_id(self, entry):
|
|
644
|
+
"""Attribute the current proof pointer to `entry`, or refuse to guess.
|
|
645
|
+
|
|
646
|
+
Caller holds _jobs_lock. Returns (run_id, reason): exactly one is set.
|
|
647
|
+
|
|
648
|
+
A CHANGED pointer means some build wrote a receipt since this job was
|
|
649
|
+
dispatched. That identifies THIS job's receipt only if no other build
|
|
650
|
+
was dispatched afterwards -- otherwise the pointer names whichever
|
|
651
|
+
finished last, and handing that to this submitter would give them
|
|
652
|
+
someone else's evidence labelled as theirs. Worse than the 404 an
|
|
653
|
+
absent receipt already returns, so a contended window resolves to
|
|
654
|
+
nothing.
|
|
655
|
+
|
|
656
|
+
An UNCHANGED pointer means no receipt has been written yet (the build
|
|
657
|
+
is still running, produced none, or LOKI_PROVEN_PR=0 suppressed the
|
|
658
|
+
pointer). Also nothing: we never fall back to the newest directory
|
|
659
|
+
under .loki/proofs/, which would serve an unrelated earlier run.
|
|
660
|
+
"""
|
|
661
|
+
before = entry.get("_proof_before", "")
|
|
662
|
+
seq = entry.get("_proof_seq")
|
|
663
|
+
if seq is None:
|
|
664
|
+
return "", "this job was not dispatched with proof tracking"
|
|
665
|
+
# Attributable only when this job was the sole pending build at
|
|
666
|
+
# dispatch (nothing earlier could still write a receipt) AND nothing
|
|
667
|
+
# has been dispatched since (nothing later could have written the one
|
|
668
|
+
# we are about to read). Either alone is insufficient: without the
|
|
669
|
+
# first, a second job claims the first job's receipt; without the
|
|
670
|
+
# second, a job claims a receipt a later build produced.
|
|
671
|
+
if not entry.get("_proof_sole") or seq != self._dispatch_seq:
|
|
672
|
+
return "", (
|
|
673
|
+
"another build was dispatched during this job's window, so "
|
|
674
|
+
"the proof pointer cannot be attributed to this job"
|
|
675
|
+
)
|
|
676
|
+
after = read_proof_pointer()
|
|
677
|
+
if after and after != before:
|
|
678
|
+
return after, ""
|
|
679
|
+
return "", "this job wrote no Evidence Receipt"
|
|
680
|
+
|
|
681
|
+
def record_job(self, job_id, status, summary=""):
|
|
682
|
+
"""Create or update the status record for a remotely-submitted job."""
|
|
683
|
+
with self._jobs_lock:
|
|
684
|
+
entry = self._jobs.get(job_id)
|
|
685
|
+
if entry is None:
|
|
686
|
+
entry = {"id": job_id, "created": _utc_now()}
|
|
687
|
+
self._jobs[job_id] = entry
|
|
688
|
+
while len(self._jobs) > self._job_max:
|
|
689
|
+
self._jobs.popitem(last=False)
|
|
690
|
+
entry["status"] = status
|
|
691
|
+
entry["updated"] = _utc_now()
|
|
692
|
+
if summary:
|
|
693
|
+
entry["summary"] = summary
|
|
694
|
+
|
|
695
|
+
def get_job(self, job_id):
|
|
696
|
+
"""Return a copy of the job record, or None if unknown/evicted.
|
|
697
|
+
|
|
698
|
+
Underscore-prefixed keys are internal bookkeeping for proof attribution
|
|
699
|
+
and are stripped: the status response is a public API surface.
|
|
700
|
+
"""
|
|
701
|
+
with self._jobs_lock:
|
|
702
|
+
entry = self._jobs.get(job_id)
|
|
703
|
+
if not entry:
|
|
704
|
+
return None
|
|
705
|
+
return {k: v for k, v in entry.items() if not k.startswith("_")}
|
|
706
|
+
|
|
707
|
+
def get_job_proof_id(self, job_id):
|
|
708
|
+
"""Return (run_id, reason) for job_id. Exactly one is non-empty.
|
|
709
|
+
|
|
710
|
+
Resolved lazily, at ask time, because a detached build finishes long
|
|
711
|
+
after its dispatch returns (see _begin_proof_window).
|
|
712
|
+
"""
|
|
713
|
+
with self._jobs_lock:
|
|
714
|
+
entry = self._jobs.get(job_id)
|
|
715
|
+
if entry is None:
|
|
716
|
+
return "", "unknown job id"
|
|
717
|
+
return self._resolve_proof_id(entry)
|
|
718
|
+
|
|
417
719
|
def submit(self, event_type, payload):
|
|
418
720
|
"""Enqueue an event. Returns True if accepted, False if the queue is full."""
|
|
419
721
|
try:
|
|
@@ -430,7 +732,18 @@ class Dispatcher:
|
|
|
430
732
|
continue
|
|
431
733
|
try:
|
|
432
734
|
event_type, payload = item
|
|
433
|
-
|
|
735
|
+
job_id = payload.get("job_id") if isinstance(payload, dict) else None
|
|
736
|
+
if job_id:
|
|
737
|
+
self.record_job(job_id, "running")
|
|
738
|
+
# Counted for every dispatch, including webhook builds that
|
|
739
|
+
# have no job_id: they write the same global proof pointer, so
|
|
740
|
+
# an uncounted one would be misattributed to a submitted job.
|
|
741
|
+
self._begin_proof_window(job_id)
|
|
742
|
+
summary, status = dispatch_event(
|
|
743
|
+
event_type, payload, dry_run=self.dry_run
|
|
744
|
+
)
|
|
745
|
+
if job_id:
|
|
746
|
+
self.record_job(job_id, status, summary or "")
|
|
434
747
|
finally:
|
|
435
748
|
self.queue.task_done()
|
|
436
749
|
|
|
@@ -444,6 +757,9 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
444
757
|
# Set on the class in main() before the server starts.
|
|
445
758
|
dry_run = False
|
|
446
759
|
secret = ""
|
|
760
|
+
# Bearer token for the /jobs API. MUST be a different secret from `secret`
|
|
761
|
+
# above: one authenticates GitHub, the other authenticates a human.
|
|
762
|
+
api_token = ""
|
|
447
763
|
dispatcher = None
|
|
448
764
|
|
|
449
765
|
# Cap the body we will read so a huge POST cannot exhaust memory.
|
|
@@ -453,9 +769,11 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
453
769
|
logging.info("%s - %s", self.address_string(), format % args)
|
|
454
770
|
|
|
455
771
|
def do_GET(self):
|
|
456
|
-
|
|
772
|
+
# Strip any query string: do_GET matches paths exactly.
|
|
773
|
+
path = self.path.split("?", 1)[0]
|
|
774
|
+
if path == "/health":
|
|
457
775
|
self._send_json(200, {"status": "ok", "service": "loki-trigger-server"})
|
|
458
|
-
elif
|
|
776
|
+
elif path == "/status":
|
|
459
777
|
config = load_config()
|
|
460
778
|
self._send_json(200, {
|
|
461
779
|
"status": "running",
|
|
@@ -463,57 +781,221 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
463
781
|
"port": config.get("port", 7373),
|
|
464
782
|
"enabled_events": config.get("enabled_events", []),
|
|
465
783
|
"secret_configured": bool(self.secret),
|
|
784
|
+
"api_token_configured": bool(self.api_token),
|
|
466
785
|
})
|
|
786
|
+
elif path.startswith("/jobs/") and path.endswith("/proof"):
|
|
787
|
+
self._handle_job_proof(path[len("/jobs/"):-len("/proof")])
|
|
788
|
+
elif path.startswith("/jobs/"):
|
|
789
|
+
self._handle_job_status(path[len("/jobs/"):])
|
|
467
790
|
else:
|
|
468
791
|
self._send_json(404, {"error": "not found"})
|
|
469
792
|
|
|
470
|
-
def
|
|
471
|
-
|
|
472
|
-
|
|
793
|
+
def _check_api_auth(self):
|
|
794
|
+
"""Authenticate a /jobs request. Returns True if the caller may proceed.
|
|
795
|
+
|
|
796
|
+
Sends the error response itself (503 / 401) and returns False otherwise,
|
|
797
|
+
so callers just `if not self._check_api_auth(): return`.
|
|
798
|
+
|
|
799
|
+
Fails closed exactly like the webhook path: with no API token configured
|
|
800
|
+
every /jobs request is rejected with 503 plus an audit line. It is never
|
|
801
|
+
satisfied by the webhook HMAC -- a different credential entirely.
|
|
802
|
+
"""
|
|
803
|
+
if not self.api_token:
|
|
804
|
+
logging.warning(
|
|
805
|
+
"Rejecting /jobs request from %s: no API token configured "
|
|
806
|
+
"(set LOKI_API_TOKEN or LOKI_API_TOKEN_FILE to enable submits)",
|
|
807
|
+
self.address_string(),
|
|
808
|
+
)
|
|
809
|
+
log_event(JOB_EVENT, "", "", "rejected (no API token configured)")
|
|
810
|
+
self._send_json(503, {"error": "API token not configured"})
|
|
811
|
+
return False
|
|
812
|
+
|
|
813
|
+
header = self.headers.get("Authorization", "") or ""
|
|
814
|
+
prefix = "Bearer "
|
|
815
|
+
if not header.startswith(prefix):
|
|
816
|
+
log_event(JOB_EVENT, "", "", "rejected (missing bearer token)")
|
|
817
|
+
self._send_json(401, {"error": "missing bearer token"})
|
|
818
|
+
return False
|
|
819
|
+
|
|
820
|
+
if not constant_time_equals(header[len(prefix):].strip(), self.api_token):
|
|
821
|
+
logging.warning("Invalid API token from %s", self.address_string())
|
|
822
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid API token)")
|
|
823
|
+
self._send_json(401, {"error": "invalid API token"})
|
|
824
|
+
return False
|
|
825
|
+
|
|
826
|
+
return True
|
|
827
|
+
|
|
828
|
+
def _handle_job_status(self, job_id):
|
|
829
|
+
"""GET /jobs/<id>. Authenticated: a status record echoes the spec."""
|
|
830
|
+
if not self._check_api_auth():
|
|
831
|
+
return
|
|
832
|
+
job = self.dispatcher.get_job(job_id) if self.dispatcher else None
|
|
833
|
+
if job is None:
|
|
834
|
+
self._send_json(404, {"error": "unknown job id"})
|
|
835
|
+
return
|
|
836
|
+
self._send_json(200, job)
|
|
837
|
+
|
|
838
|
+
def _handle_job_proof(self, job_id):
|
|
839
|
+
"""GET /jobs/<id>/proof -- that job's Evidence Receipt.
|
|
840
|
+
|
|
841
|
+
Returns proof.json UNWRAPPED at the top level. The detached gpg
|
|
842
|
+
signature, when the build made one, already lives inside it at
|
|
843
|
+
verification.gpg_signature, and the integrity hash is computed over the
|
|
844
|
+
receipt with `verification` stripped. Wrapping the body in an envelope
|
|
845
|
+
would therefore break hash recomputation for a client that writes the
|
|
846
|
+
response to disk, making every honest receipt read as tampered.
|
|
847
|
+
|
|
848
|
+
Same bearer-token auth and fail-closed behavior as POST /jobs. A job
|
|
849
|
+
that produced no attributable proof is a 404: a synthesized or
|
|
850
|
+
borrowed-from-another-run receipt would be worse than none, since its
|
|
851
|
+
whole value is that the submitter can check it without trusting us.
|
|
852
|
+
"""
|
|
853
|
+
if not self._check_api_auth():
|
|
854
|
+
return
|
|
855
|
+
job = self.dispatcher.get_job(job_id) if self.dispatcher else None
|
|
856
|
+
if job is None:
|
|
857
|
+
self._send_json(404, {"error": "unknown job id"})
|
|
473
858
|
return
|
|
859
|
+
run_id, reason = self.dispatcher.get_job_proof_id(job_id)
|
|
860
|
+
proof = read_proof(run_id) if run_id else None
|
|
861
|
+
if proof is None:
|
|
862
|
+
self._send_json(404, {
|
|
863
|
+
"error": "no proof available for this job",
|
|
864
|
+
"reason": reason or "the recorded receipt could not be read",
|
|
865
|
+
})
|
|
866
|
+
return
|
|
867
|
+
self._send_json(200, proof)
|
|
474
868
|
|
|
869
|
+
def _read_body(self):
|
|
870
|
+
"""Read and return the request body, or None if it was refused.
|
|
871
|
+
|
|
872
|
+
Shared by /webhook and /jobs so both get the same Content-Length cap and
|
|
873
|
+
the same bounded read (a slow-loris that drips or never finishes the
|
|
874
|
+
body must not tie up a worker thread BEFORE authentication). On refusal
|
|
875
|
+
the error response is already sent and None is returned.
|
|
876
|
+
"""
|
|
475
877
|
try:
|
|
476
878
|
content_length = int(self.headers.get("Content-Length", 0))
|
|
477
879
|
except (TypeError, ValueError):
|
|
478
880
|
self._send_json(400, {"error": "invalid Content-Length"})
|
|
479
|
-
return
|
|
881
|
+
return None
|
|
480
882
|
if content_length < 0 or content_length > self.MAX_BODY_BYTES:
|
|
481
883
|
self._send_json(413, {"error": "payload too large"})
|
|
482
|
-
return
|
|
884
|
+
return None
|
|
483
885
|
|
|
484
|
-
# L3 fix: bound the body read so a slow or under-delivering client (a
|
|
485
|
-
# slow-loris that drips or never finishes the body) cannot tie up a
|
|
486
|
-
# worker thread BEFORE authentication. A stalled read raises
|
|
487
|
-
# socket.timeout; we drop the connection rather than block forever.
|
|
488
886
|
prev_timeout = self.connection.gettimeout()
|
|
489
887
|
self.connection.settimeout(BODY_READ_TIMEOUT_SECONDS)
|
|
490
888
|
try:
|
|
491
889
|
body = self.rfile.read(content_length)
|
|
492
890
|
except (socket.timeout, TimeoutError, ConnectionError, OSError):
|
|
493
891
|
logging.warning(
|
|
494
|
-
"Dropping slow/incomplete
|
|
892
|
+
"Dropping slow/incomplete request body from %s",
|
|
495
893
|
self.address_string(),
|
|
496
894
|
)
|
|
497
895
|
try:
|
|
498
896
|
self._send_json(408, {"error": "request body timeout"})
|
|
499
897
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
500
898
|
pass
|
|
501
|
-
return
|
|
899
|
+
return None
|
|
502
900
|
finally:
|
|
503
901
|
try:
|
|
504
902
|
self.connection.settimeout(prev_timeout)
|
|
505
903
|
except OSError:
|
|
506
904
|
pass
|
|
905
|
+
|
|
507
906
|
if len(body) != content_length:
|
|
508
907
|
# Client closed before delivering the declared body. Refuse rather
|
|
509
908
|
# than authenticate a truncated payload.
|
|
510
909
|
self._send_json(400, {"error": "incomplete request body"})
|
|
910
|
+
return None
|
|
911
|
+
return body
|
|
912
|
+
|
|
913
|
+
def _handle_job_submit(self):
|
|
914
|
+
"""POST /jobs -- authenticated remote submit of a spec.
|
|
915
|
+
|
|
916
|
+
Enqueues onto the SAME bounded queue and worker pool as the webhook
|
|
917
|
+
path; only the credential and the admission validation differ.
|
|
918
|
+
"""
|
|
919
|
+
body = self._read_body()
|
|
920
|
+
if body is None:
|
|
921
|
+
return
|
|
922
|
+
# Authenticate BEFORE parsing: an unauthenticated caller never reaches
|
|
923
|
+
# the JSON parser, and never gets a job id back.
|
|
924
|
+
if not self._check_api_auth():
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
try:
|
|
928
|
+
payload = json.loads(body)
|
|
929
|
+
except json.JSONDecodeError:
|
|
930
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
931
|
+
return
|
|
932
|
+
if not isinstance(payload, dict):
|
|
933
|
+
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
spec = payload.get("spec")
|
|
937
|
+
if not valid_spec(spec):
|
|
938
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid spec)")
|
|
939
|
+
self._send_json(400, {"error": "invalid spec"})
|
|
940
|
+
return
|
|
941
|
+
|
|
942
|
+
job_id = secrets.token_urlsafe(12)
|
|
943
|
+
job = {"spec": spec.strip(), "job_id": job_id}
|
|
944
|
+
|
|
945
|
+
# Record BEFORE enqueueing. A worker can pick the job up the instant
|
|
946
|
+
# submit() returns and write "running"/"fired"; record_job is
|
|
947
|
+
# last-writer-wins, so recording afterwards could clobber a terminal
|
|
948
|
+
# status with "queued" and leave a finished job polling as queued
|
|
949
|
+
# forever. On the 429 path below the record is unreachable (no id is
|
|
950
|
+
# handed out) and the store is bounded, so nothing leaks.
|
|
951
|
+
self.dispatcher.record_job(job_id, "queued", job["spec"])
|
|
952
|
+
|
|
953
|
+
# Enforce the existing queue bound so a submit storm cannot fork
|
|
954
|
+
# unbounded builds. 429 (not a silent drop) tells the client to retry.
|
|
955
|
+
if not self.dispatcher.submit(JOB_EVENT, job):
|
|
956
|
+
logging.warning(
|
|
957
|
+
"Queue full; shedding job submit from %s", self.address_string()
|
|
958
|
+
)
|
|
959
|
+
log_event(JOB_EVENT, "", "", "rejected (queue full)")
|
|
960
|
+
self._send_json(429, {"error": "server busy, retry later"})
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
log_event(JOB_EVENT, "", job["spec"], "queued")
|
|
964
|
+
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
965
|
+
|
|
966
|
+
def do_POST(self):
|
|
967
|
+
path = self.path.split("?", 1)[0]
|
|
968
|
+
if path == "/jobs":
|
|
969
|
+
self._handle_job_submit()
|
|
970
|
+
return
|
|
971
|
+
if path != "/webhook":
|
|
972
|
+
self._send_json(404, {"error": "not found"})
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
body = self._read_body()
|
|
976
|
+
if body is None:
|
|
511
977
|
return
|
|
512
978
|
|
|
513
979
|
event_type = self.headers.get("X-GitHub-Event", "")
|
|
514
980
|
signature = self.headers.get("X-Hub-Signature-256", "")
|
|
515
981
|
delivery_id = self.headers.get("X-GitHub-Delivery", "")
|
|
516
982
|
|
|
983
|
+
# Credential separation: JOB_EVENT rides the shared EVENT_HANDLERS table
|
|
984
|
+
# so it uses one dispatch path, but it is NOT a GitHub event and must
|
|
985
|
+
# never be reachable with the webhook HMAC. Without this, a holder of
|
|
986
|
+
# the webhook secret could POST X-GitHub-Event: loki_job and submit an
|
|
987
|
+
# arbitrary spec, defeating the separate-credential requirement.
|
|
988
|
+
if event_type == JOB_EVENT:
|
|
989
|
+
logging.warning(
|
|
990
|
+
"Rejecting %s on /webhook from %s: submits require the /jobs "
|
|
991
|
+
"API and its own bearer token",
|
|
992
|
+
JOB_EVENT,
|
|
993
|
+
self.address_string(),
|
|
994
|
+
)
|
|
995
|
+
log_event(event_type, "", "", "rejected (job submit not allowed on webhook)")
|
|
996
|
+
self._send_json(403, {"error": "use POST /jobs to submit a job"})
|
|
997
|
+
return
|
|
998
|
+
|
|
517
999
|
# Defect 1 fix: refuse to dispatch when no secret is configured. The
|
|
518
1000
|
# server stays up for ops endpoints, but webhooks are rejected with an
|
|
519
1001
|
# audit line. We never silently accept-all.
|
|
@@ -641,6 +1123,17 @@ def main():
|
|
|
641
1123
|
if not secret:
|
|
642
1124
|
secret = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
|
643
1125
|
|
|
1126
|
+
# The /jobs bearer token has NO CLI flag on purpose: a CLI arg lands in the
|
|
1127
|
+
# process list where any local user can read it. Env or mounted file only.
|
|
1128
|
+
api_token = load_api_token()
|
|
1129
|
+
if api_token and secret and constant_time_equals(api_token, secret):
|
|
1130
|
+
logging.error(
|
|
1131
|
+
"LOKI_API_TOKEN is identical to the webhook secret. They "
|
|
1132
|
+
"authenticate different parties and MUST differ; disabling the "
|
|
1133
|
+
"/jobs API until a distinct token is configured."
|
|
1134
|
+
)
|
|
1135
|
+
api_token = ""
|
|
1136
|
+
|
|
644
1137
|
# Persist resolved values, but never write the secret to disk.
|
|
645
1138
|
config["port"] = port
|
|
646
1139
|
config["dry_run"] = dry_run
|
|
@@ -653,6 +1146,7 @@ def main():
|
|
|
653
1146
|
|
|
654
1147
|
WebhookHandler.dry_run = dry_run
|
|
655
1148
|
WebhookHandler.secret = secret
|
|
1149
|
+
WebhookHandler.api_token = api_token
|
|
656
1150
|
WebhookHandler.dispatcher = dispatcher
|
|
657
1151
|
|
|
658
1152
|
server = ThreadingWebhookServer(("", port), WebhookHandler)
|
|
@@ -662,7 +1156,14 @@ def main():
|
|
|
662
1156
|
logging.info("Loki trigger server starting on port %d%s", port, mode_label)
|
|
663
1157
|
logging.info("Webhook endpoint: POST http://localhost:%d/webhook", port)
|
|
664
1158
|
logging.info("Health check: GET http://localhost:%d/health", port)
|
|
1159
|
+
logging.info("Submit endpoint: POST http://localhost:%d/jobs", port)
|
|
665
1160
|
logging.info("Workers: %d, queue size: %d", workers, queue_size)
|
|
1161
|
+
if not api_token:
|
|
1162
|
+
logging.warning(
|
|
1163
|
+
"No API token configured: ALL /jobs requests will be rejected with "
|
|
1164
|
+
"503. Set LOKI_API_TOKEN or LOKI_API_TOKEN_FILE to enable remote "
|
|
1165
|
+
"submits."
|
|
1166
|
+
)
|
|
666
1167
|
if not secret:
|
|
667
1168
|
logging.warning(
|
|
668
1169
|
"No webhook secret configured: ALL webhooks will be rejected with "
|