loki-mode 9.17.2 → 9.18.4

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.
@@ -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,80 @@ 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
+ # Terminal job statuses a remote client can gate on. "queued" and "running"
109
+ # are deliberately absent: a client must be able to tell "not done yet" from
110
+ # "done and failed", which a single non-passed value could not express.
111
+ #
112
+ # Only JOB_STATUS_PASSED means the build itself succeeded. JOB_STATUS_UNKNOWN
113
+ # means the build detached past our wait window and its exit code was never
114
+ # observed -- an unobserved outcome is NOT a pass, and the client exits
115
+ # non-zero on it (fail closed).
116
+ JOB_STATUS_PASSED = "passed"
117
+ JOB_STATUS_FAILED = "failed"
118
+ JOB_STATUS_UNKNOWN = "unknown"
119
+ JOB_TERMINAL_STATUSES = (JOB_STATUS_PASSED, JOB_STATUS_FAILED, JOB_STATUS_UNKNOWN)
120
+
121
+ # A run id names a directory under .loki/proofs/. run.sh mints it as
122
+ # "run-<utc>-<pid>-<rand>" or "proof-<utc>-<pid>-<rand>"; this is the alphabet
123
+ # those forms use. Applied to the pointer file's contents (never to a request
124
+ # path) so a corrupt pointer cannot name an arbitrary path.
125
+ RUN_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
126
+
127
+
128
+ def valid_spec(spec):
129
+ """Return True if spec is a safe `loki start` argument.
130
+
131
+ Must be a non-empty str (a dict/list/int is rejected outright rather than
132
+ coerced, exactly like valid_issue_number), within the size cap, free of
133
+ control characters, and not dash-leading -- a leading dash would be parsed
134
+ by `loki start` as a CLI FLAG, the same injection REPO_FULL_NAME_RE guards
135
+ against on the webhook path.
136
+ """
137
+ if not isinstance(spec, str):
138
+ return False
139
+ spec = spec.strip()
140
+ if not spec:
141
+ return False
142
+ if len(spec.encode("utf-8")) > MAX_SPEC_BYTES:
143
+ return False
144
+ if CONTROL_CHARS_RE.search(spec):
145
+ return False
146
+ if spec.startswith("-"):
147
+ return False
148
+ return True
149
+
150
+
151
+ def constant_time_equals(a, b):
152
+ """Constant-time string compare that never raises on odd input.
153
+
154
+ hmac.compare_digest raises TypeError on a non-ASCII str, so both sides are
155
+ encoded to bytes first: a weird header must produce a 401, not a 500.
156
+ """
157
+ if not isinstance(a, str) or not isinstance(b, str):
158
+ return False
159
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
160
+
161
+
73
162
  # How long to wait for a dispatched `loki start` to finish before we stop
74
163
  # waiting on it. The child is launched detached (--detach) so it backgrounds
75
164
  # itself quickly; this bound only guards the worker thread against a wedged
@@ -91,6 +180,54 @@ def get_loki_dir():
91
180
  return loki_dir
92
181
 
93
182
 
183
+ def read_proof_pointer():
184
+ """Return the run id in .loki/state/last-proof-id.txt, or "" if absent.
185
+
186
+ run.sh's generate_proof_of_run writes this pointer atomically after emitting
187
+ a receipt, naming the directory it wrote (.loki/proofs/<run_id>/). It is the
188
+ ONLY durable link from a finished build to its proof: the run id is minted
189
+ inside run.sh and is deliberately NOT derivable from LOKI_SESSION_ID (the
190
+ persisted per-run id file wins over the env var), so the server cannot
191
+ predict it and must observe it instead.
192
+
193
+ The pointer is global to the working directory and is not written at all
194
+ when LOKI_PROVEN_PR=0. Both cases are handled by the caller, which fails
195
+ closed rather than guessing.
196
+ """
197
+ try:
198
+ return Path(".loki/state/last-proof-id.txt").read_text(
199
+ encoding="utf-8"
200
+ ).strip()
201
+ except (OSError, UnicodeDecodeError):
202
+ return ""
203
+
204
+
205
+ def read_proof(run_id):
206
+ """Return the parsed proof.json for run_id, or None if unreadable.
207
+
208
+ run_id comes from the server-written pointer above, never from a request
209
+ path, so there is no traversal sink here. It is still constrained to the
210
+ id alphabet as defense in depth, since a corrupt pointer file must not be
211
+ able to name an arbitrary path.
212
+ """
213
+ # RUN_ID_RE alone is not enough: it permits "." and "..", and ".." would
214
+ # resolve to .loki/proof.json, one level above the proofs directory. The
215
+ # dot-forms are rejected outright and the resolved path is then confined
216
+ # under .loki/proofs, so no run id -- however corrupt -- escapes it.
217
+ if not run_id or run_id in (".", "..") or not RUN_ID_RE.match(run_id):
218
+ return None
219
+ proofs_root = (Path(".loki") / "proofs").resolve()
220
+ target = (proofs_root / run_id / "proof.json").resolve()
221
+ if proofs_root not in target.parents:
222
+ return None
223
+ try:
224
+ with open(target) as f:
225
+ data = json.load(f)
226
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
227
+ return None
228
+ return data if isinstance(data, dict) else None
229
+
230
+
94
231
  def load_config():
95
232
  """Load trigger config from .loki/triggers/config.json."""
96
233
  config_path = get_loki_dir() / "config.json"
@@ -112,6 +249,29 @@ def load_config():
112
249
  return defaults
113
250
 
114
251
 
252
+ def load_api_token():
253
+ """Return the /jobs bearer token from env or a mounted file ("" if unset).
254
+
255
+ Sources, in order: LOKI_API_TOKEN, then the file at LOKI_API_TOKEN_FILE
256
+ (the normal Kubernetes mounted-secret path). Deliberately NOT a CLI flag --
257
+ an argv secret is readable by any local user via the process list -- and
258
+ never persisted to config.json.
259
+
260
+ A mounted secret file usually ends with a newline, so the contents are
261
+ stripped; otherwise every comparison would fail.
262
+ """
263
+ token = os.environ.get("LOKI_API_TOKEN", "").strip()
264
+ if token:
265
+ return token
266
+ token_file = os.environ.get("LOKI_API_TOKEN_FILE", "").strip()
267
+ if token_file:
268
+ try:
269
+ return Path(token_file).read_text(encoding="utf-8").strip()
270
+ except (OSError, UnicodeDecodeError) as e:
271
+ logging.error("Failed to read LOKI_API_TOKEN_FILE %s: %s", token_file, e)
272
+ return ""
273
+
274
+
115
275
  def save_config(config):
116
276
  """Save trigger config to .loki/triggers/config.json."""
117
277
  config_path = get_loki_dir() / "config.json"
@@ -124,10 +284,15 @@ def save_config(config):
124
284
  _log_lock = threading.Lock()
125
285
 
126
286
 
287
+ def _utc_now():
288
+ """UTC timestamp string shared by the event log and job records."""
289
+ return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
290
+
291
+
127
292
  def log_event(event_type, action, payload_summary, status):
128
293
  """Append event to .loki/triggers/events.log (thread-safe)."""
129
294
  log_path = get_loki_dir() / "events.log"
130
- timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
295
+ timestamp = _utc_now()
131
296
  entry = {
132
297
  "timestamp": timestamp,
133
298
  "event": event_type,
@@ -186,21 +351,30 @@ def _reap_child(proc):
186
351
  pass
187
352
 
188
353
 
189
- def run_loki_command(args, dry_run=False):
190
- """Run a loki command synchronously and reap it; or print it if dry_run.
354
+ # Outcome of a dispatch, as distinct from "did it launch".
355
+ #
356
+ # run_loki_command already distinguished all three of these and then threw two
357
+ # of them away by returning a bool. Collapsing "exited 0" and "still detached"
358
+ # into True is what made a remotely-submitted build that STARTS and then FAILS
359
+ # report success: the client had no value to gate on. UNKNOWN is not a pass --
360
+ # an outcome we could not observe must fail closed.
361
+ # ponytail: the outcome IS the terminal job status, so they are the same three
362
+ # strings rather than two enums plus a mapping table.
363
+ OUTCOME_PASSED = JOB_STATUS_PASSED # ran to completion and exited 0
364
+ OUTCOME_FAILED = JOB_STATUS_FAILED # launch failed, or exited non-zero
365
+ OUTCOME_UNKNOWN = JOB_STATUS_UNKNOWN # detached; exit code never observed
191
366
 
192
- Returns True if the command was launched and exited 0 (or backgrounded
193
- cleanly within the wait window), False if the launch failed or it exited
194
- non-zero. The child is always waited on, so no zombies accumulate. stderr
195
- is captured on failure so a broken dispatch is diagnosable.
196
367
 
197
- This is invoked from worker threads, so blocking here does not block the
198
- HTTP listener.
368
+ def run_loki_outcome(args, dry_run=False):
369
+ """Run a loki command and report WHICH of the three outcomes occurred.
370
+
371
+ Returns one of OUTCOME_PASSED / OUTCOME_FAILED / OUTCOME_UNKNOWN. This is
372
+ the honest version of run_loki_command, which answers only "did it launch".
199
373
  """
200
374
  cmd = ["loki"] + args
201
375
  if dry_run:
202
376
  logging.info("[DRY-RUN] Would run: %s", " ".join(cmd))
203
- return True
377
+ return OUTCOME_PASSED
204
378
  logging.info("Running: %s", " ".join(cmd))
205
379
  try:
206
380
  proc = subprocess.Popen(
@@ -210,18 +384,14 @@ def run_loki_command(args, dry_run=False):
210
384
  )
211
385
  except (FileNotFoundError, OSError) as e:
212
386
  logging.error("Failed to launch %s: %s", " ".join(cmd), e)
213
- return False
387
+ return OUTCOME_FAILED
214
388
 
215
389
  try:
216
- # Wait (and thereby reap) the child. A --detach launch returns quickly;
217
- # this bound only guards against a wedged launch.
218
390
  _, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
219
391
  except subprocess.TimeoutExpired:
220
- # The dispatch is still running past our wait window. We stop blocking
221
- # the worker thread, but the child is NOT abandoned: a one-shot daemon
222
- # reaper thread waits on it so it is always reaped (no zombie) while
223
- # THIS process is alive, and the OS reparents it after we exit. This
224
- # keeps the "always waited on, no zombies" guarantee honest.
392
+ # Detached past the wait window. The reaper collects the child, but we
393
+ # never see its exit code, so the outcome is genuinely UNKNOWN -- NOT a
394
+ # pass. Reporting success here is exactly the defect this replaces.
225
395
  logging.info(
226
396
  "Dispatch pid=%d still running after %ds; reaping in background",
227
397
  proc.pid,
@@ -233,11 +403,11 @@ def run_loki_command(args, dry_run=False):
233
403
  name="loki-trigger-reaper-%d" % proc.pid,
234
404
  daemon=True,
235
405
  ).start()
236
- return True
406
+ return OUTCOME_UNKNOWN
237
407
 
238
408
  if proc.returncode == 0:
239
409
  logging.info("Dispatch pid=%d completed (exit 0)", proc.pid)
240
- return True
410
+ return OUTCOME_PASSED
241
411
 
242
412
  stderr_text = ""
243
413
  if stderr:
@@ -248,7 +418,28 @@ def run_loki_command(args, dry_run=False):
248
418
  proc.returncode,
249
419
  stderr_text or "(no stderr)",
250
420
  )
251
- return False
421
+ return OUTCOME_FAILED
422
+
423
+
424
+ def run_loki_command(args, dry_run=False):
425
+ """Run a loki command synchronously and reap it; or print it if dry_run.
426
+
427
+ Returns True if the command was launched and exited 0 (or backgrounded
428
+ cleanly within the wait window), False if the launch failed or it exited
429
+ non-zero. The child is always waited on, so no zombies accumulate. stderr
430
+ is captured on failure so a broken dispatch is diagnosable.
431
+
432
+ This is invoked from worker threads, so blocking here does not block the
433
+ HTTP listener.
434
+
435
+ Kept as the bool view for the GitHub webhook handlers, which only care
436
+ whether a dispatch started. A caller that must know whether the BUILD
437
+ passed wants run_loki_outcome instead. Behaviour is unchanged: a detached
438
+ dispatch (UNKNOWN) still reads as True here, exactly as before.
439
+ """
440
+ return run_loki_outcome(args, dry_run=dry_run) in (
441
+ OUTCOME_PASSED, OUTCOME_UNKNOWN,
442
+ )
252
443
 
253
444
 
254
445
  def handle_issues_event(payload, dry_run=False):
@@ -331,11 +522,36 @@ def handle_workflow_run_event(payload, dry_run=False):
331
522
  return summary, status
332
523
 
333
524
 
334
- # Map GitHub event name -> handler. Keeps do_POST routing declarative.
525
+ def handle_job_event(payload, dry_run=False):
526
+ """Handle a remotely-submitted job: loki start <spec> --detach.
527
+
528
+ Runs on the same worker pool as the webhook handlers. The spec was already
529
+ validated by valid_spec() at admission; it is re-checked here so this
530
+ handler is safe no matter who calls it, and it is passed as a single argv
531
+ element -- never interpolated into a shell string.
532
+ """
533
+ spec = payload.get("spec")
534
+ if not valid_spec(spec):
535
+ return None, "rejected (invalid spec)"
536
+ args = ["start", spec.strip(), "--detach"]
537
+ summary = "job %s: %s" % (payload.get("job_id", "?"), spec.strip())
538
+ # A remote submitter gates CI on this, so report the BUILD's outcome, not
539
+ # merely that a build was launched. "fired" (launched) and "passed" must
540
+ # never share a value.
541
+ status = run_loki_outcome(args, dry_run=dry_run)
542
+ if status != OUTCOME_FAILED:
543
+ send_notification("Trigger fired: %s" % summary)
544
+ return summary, status
545
+
546
+
547
+ # Map event name -> handler. Keeps do_POST routing declarative. JOB_EVENT rides
548
+ # the same table (and therefore the same bounded queue and worker pool) but is
549
+ # explicitly refused on the /webhook path.
335
550
  EVENT_HANDLERS = {
336
551
  "issues": handle_issues_event,
337
552
  "pull_request": handle_pull_request_event,
338
553
  "workflow_run": handle_workflow_run_event,
554
+ JOB_EVENT: handle_job_event,
339
555
  }
340
556
 
341
557
 
@@ -370,9 +586,23 @@ class Dispatcher:
370
586
  """
371
587
 
372
588
  def __init__(self, workers=DEFAULT_WORKERS, queue_size=DEFAULT_QUEUE_SIZE,
373
- dry_run=False, dedup_size=DEFAULT_DEDUP_SIZE):
589
+ dry_run=False, dedup_size=DEFAULT_DEDUP_SIZE,
590
+ job_history=DEFAULT_JOB_HISTORY):
374
591
  self.dry_run = dry_run
375
592
  self.queue = queue.Queue(maxsize=max(1, queue_size))
593
+ # Job status for GET /jobs/<id>. Same bounded-FIFO idiom as the dedup
594
+ # cache below: oldest records are evicted so memory cannot grow without
595
+ # limit under a submit storm.
596
+ self._job_max = max(1, job_history)
597
+ self._jobs = collections.OrderedDict()
598
+ self._jobs_lock = threading.Lock()
599
+ # Proof-attribution bookkeeping. The proof pointer is global to the
600
+ # working directory, so a receipt only identifies a job when that job
601
+ # was the only build that could have written it. All three are updated
602
+ # on EVERY dispatch (webhook and remote-submit alike) under _jobs_lock.
603
+ self._dispatch_seq = 0 # monotonic count of dispatches
604
+ self._pending = 0 # dispatches with no receipt seen yet
605
+ self._last_pointer = read_proof_pointer()
376
606
  # Idempotency: remember recently seen GitHub delivery IDs so a
377
607
  # redelivered webhook (GitHub retries on non-2xx, and operators can
378
608
  # manually redeliver) does not dispatch the same build twice. Bounded
@@ -414,6 +644,119 @@ class Dispatcher:
414
644
  self._seen_deliveries.popitem(last=False)
415
645
  return False
416
646
 
647
+ def _begin_proof_window(self, job_id=None):
648
+ """Record that a build was dispatched, snapshotting the proof pointer.
649
+
650
+ Called for EVERY dispatch, not just remotely-submitted ones. A webhook
651
+ build (issues / pull_request / workflow_run) runs `loki start` in the
652
+ same working directory and writes the same global pointer, so if it
653
+ finished during a submitted job's window and was not counted here, its
654
+ receipt would be attributed to that job. That is exactly the
655
+ borrowed-receipt failure this design exists to prevent.
656
+
657
+ Attribution is deliberately NOT resolved when the dispatch returns.
658
+ `loki start --detach` returns as soon as the child forks, while the
659
+ build runs for minutes and writes its receipt at the very end -- so a
660
+ window closed at dispatch-return would always see an unchanged pointer
661
+ and every real build would 404. Resolution happens lazily in
662
+ get_job_proof_id() instead, at the moment someone asks.
663
+ """
664
+ with self._jobs_lock:
665
+ self._dispatch_seq += 1
666
+ # A build whose receipt has not yet appeared stays PENDING. While
667
+ # any earlier dispatch is pending, a pointer change is ambiguous:
668
+ # it could be that build finishing late rather than this one. The
669
+ # snapshot is only taken when this job is the sole pending build.
670
+ pointer = read_proof_pointer()
671
+ if pointer != self._last_pointer:
672
+ # Every pending build's receipt could be the one that just
673
+ # landed, so none of them can claim it, and the slate clears.
674
+ self._last_pointer = pointer
675
+ self._pending = 0
676
+ sole = self._pending == 0
677
+ self._pending += 1
678
+ entry = self._jobs.get(job_id) if job_id else None
679
+ if entry is not None:
680
+ entry["_proof_before"] = pointer
681
+ entry["_proof_seq"] = self._dispatch_seq
682
+ entry["_proof_sole"] = sole
683
+
684
+ def _resolve_proof_id(self, entry):
685
+ """Attribute the current proof pointer to `entry`, or refuse to guess.
686
+
687
+ Caller holds _jobs_lock. Returns (run_id, reason): exactly one is set.
688
+
689
+ A CHANGED pointer means some build wrote a receipt since this job was
690
+ dispatched. That identifies THIS job's receipt only if no other build
691
+ was dispatched afterwards -- otherwise the pointer names whichever
692
+ finished last, and handing that to this submitter would give them
693
+ someone else's evidence labelled as theirs. Worse than the 404 an
694
+ absent receipt already returns, so a contended window resolves to
695
+ nothing.
696
+
697
+ An UNCHANGED pointer means no receipt has been written yet (the build
698
+ is still running, produced none, or LOKI_PROVEN_PR=0 suppressed the
699
+ pointer). Also nothing: we never fall back to the newest directory
700
+ under .loki/proofs/, which would serve an unrelated earlier run.
701
+ """
702
+ before = entry.get("_proof_before", "")
703
+ seq = entry.get("_proof_seq")
704
+ if seq is None:
705
+ return "", "this job was not dispatched with proof tracking"
706
+ # Attributable only when this job was the sole pending build at
707
+ # dispatch (nothing earlier could still write a receipt) AND nothing
708
+ # has been dispatched since (nothing later could have written the one
709
+ # we are about to read). Either alone is insufficient: without the
710
+ # first, a second job claims the first job's receipt; without the
711
+ # second, a job claims a receipt a later build produced.
712
+ if not entry.get("_proof_sole") or seq != self._dispatch_seq:
713
+ return "", (
714
+ "another build was dispatched during this job's window, so "
715
+ "the proof pointer cannot be attributed to this job"
716
+ )
717
+ after = read_proof_pointer()
718
+ if after and after != before:
719
+ return after, ""
720
+ return "", "this job wrote no Evidence Receipt"
721
+
722
+ def record_job(self, job_id, status, summary=""):
723
+ """Create or update the status record for a remotely-submitted job."""
724
+ with self._jobs_lock:
725
+ entry = self._jobs.get(job_id)
726
+ if entry is None:
727
+ entry = {"id": job_id, "created": _utc_now()}
728
+ self._jobs[job_id] = entry
729
+ while len(self._jobs) > self._job_max:
730
+ self._jobs.popitem(last=False)
731
+ entry["status"] = status
732
+ entry["updated"] = _utc_now()
733
+ if summary:
734
+ entry["summary"] = summary
735
+
736
+ def get_job(self, job_id):
737
+ """Return a copy of the job record, or None if unknown/evicted.
738
+
739
+ Underscore-prefixed keys are internal bookkeeping for proof attribution
740
+ and are stripped: the status response is a public API surface.
741
+ """
742
+ with self._jobs_lock:
743
+ entry = self._jobs.get(job_id)
744
+ if not entry:
745
+ return None
746
+ return {k: v for k, v in entry.items() if not k.startswith("_")}
747
+
748
+ def get_job_proof_id(self, job_id):
749
+ """Return (run_id, reason) for job_id. Exactly one is non-empty.
750
+
751
+ Resolved lazily, at ask time, because a detached build finishes long
752
+ after its dispatch returns (see _begin_proof_window).
753
+ """
754
+ with self._jobs_lock:
755
+ entry = self._jobs.get(job_id)
756
+ if entry is None:
757
+ return "", "unknown job id"
758
+ return self._resolve_proof_id(entry)
759
+
417
760
  def submit(self, event_type, payload):
418
761
  """Enqueue an event. Returns True if accepted, False if the queue is full."""
419
762
  try:
@@ -430,7 +773,27 @@ class Dispatcher:
430
773
  continue
431
774
  try:
432
775
  event_type, payload = item
433
- dispatch_event(event_type, payload, dry_run=self.dry_run)
776
+ # Honour job_id ONLY for a remotely-submitted job. A GitHub
777
+ # payload carries no job_id of its own, so accepting one from
778
+ # any payload let a holder of the WEBHOOK HMAC write into the
779
+ # /jobs status store -- overwriting a real job's terminal
780
+ # status (e.g. "passed" -> "fired") and re-introducing the
781
+ # false-green. That is a webhook credential reaching a /jobs
782
+ # capability, which the separate-credential design forbids.
783
+ job_id = (payload.get("job_id")
784
+ if event_type == JOB_EVENT and isinstance(payload, dict)
785
+ else None)
786
+ if job_id:
787
+ self.record_job(job_id, "running")
788
+ # Counted for every dispatch, including webhook builds that
789
+ # have no job_id: they write the same global proof pointer, so
790
+ # an uncounted one would be misattributed to a submitted job.
791
+ self._begin_proof_window(job_id)
792
+ summary, status = dispatch_event(
793
+ event_type, payload, dry_run=self.dry_run
794
+ )
795
+ if job_id:
796
+ self.record_job(job_id, status, summary or "")
434
797
  finally:
435
798
  self.queue.task_done()
436
799
 
@@ -444,6 +807,9 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
444
807
  # Set on the class in main() before the server starts.
445
808
  dry_run = False
446
809
  secret = ""
810
+ # Bearer token for the /jobs API. MUST be a different secret from `secret`
811
+ # above: one authenticates GitHub, the other authenticates a human.
812
+ api_token = ""
447
813
  dispatcher = None
448
814
 
449
815
  # Cap the body we will read so a huge POST cannot exhaust memory.
@@ -453,9 +819,11 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
453
819
  logging.info("%s - %s", self.address_string(), format % args)
454
820
 
455
821
  def do_GET(self):
456
- if self.path == "/health":
822
+ # Strip any query string: do_GET matches paths exactly.
823
+ path = self.path.split("?", 1)[0]
824
+ if path == "/health":
457
825
  self._send_json(200, {"status": "ok", "service": "loki-trigger-server"})
458
- elif self.path == "/status":
826
+ elif path == "/status":
459
827
  config = load_config()
460
828
  self._send_json(200, {
461
829
  "status": "running",
@@ -463,57 +831,221 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
463
831
  "port": config.get("port", 7373),
464
832
  "enabled_events": config.get("enabled_events", []),
465
833
  "secret_configured": bool(self.secret),
834
+ "api_token_configured": bool(self.api_token),
466
835
  })
836
+ elif path.startswith("/jobs/") and path.endswith("/proof"):
837
+ self._handle_job_proof(path[len("/jobs/"):-len("/proof")])
838
+ elif path.startswith("/jobs/"):
839
+ self._handle_job_status(path[len("/jobs/"):])
467
840
  else:
468
841
  self._send_json(404, {"error": "not found"})
469
842
 
470
- def do_POST(self):
471
- if self.path != "/webhook":
472
- self._send_json(404, {"error": "not found"})
843
+ def _check_api_auth(self):
844
+ """Authenticate a /jobs request. Returns True if the caller may proceed.
845
+
846
+ Sends the error response itself (503 / 401) and returns False otherwise,
847
+ so callers just `if not self._check_api_auth(): return`.
848
+
849
+ Fails closed exactly like the webhook path: with no API token configured
850
+ every /jobs request is rejected with 503 plus an audit line. It is never
851
+ satisfied by the webhook HMAC -- a different credential entirely.
852
+ """
853
+ if not self.api_token:
854
+ logging.warning(
855
+ "Rejecting /jobs request from %s: no API token configured "
856
+ "(set LOKI_API_TOKEN or LOKI_API_TOKEN_FILE to enable submits)",
857
+ self.address_string(),
858
+ )
859
+ log_event(JOB_EVENT, "", "", "rejected (no API token configured)")
860
+ self._send_json(503, {"error": "API token not configured"})
861
+ return False
862
+
863
+ header = self.headers.get("Authorization", "") or ""
864
+ prefix = "Bearer "
865
+ if not header.startswith(prefix):
866
+ log_event(JOB_EVENT, "", "", "rejected (missing bearer token)")
867
+ self._send_json(401, {"error": "missing bearer token"})
868
+ return False
869
+
870
+ if not constant_time_equals(header[len(prefix):].strip(), self.api_token):
871
+ logging.warning("Invalid API token from %s", self.address_string())
872
+ log_event(JOB_EVENT, "", "", "rejected (invalid API token)")
873
+ self._send_json(401, {"error": "invalid API token"})
874
+ return False
875
+
876
+ return True
877
+
878
+ def _handle_job_status(self, job_id):
879
+ """GET /jobs/<id>. Authenticated: a status record echoes the spec."""
880
+ if not self._check_api_auth():
881
+ return
882
+ job = self.dispatcher.get_job(job_id) if self.dispatcher else None
883
+ if job is None:
884
+ self._send_json(404, {"error": "unknown job id"})
885
+ return
886
+ self._send_json(200, job)
887
+
888
+ def _handle_job_proof(self, job_id):
889
+ """GET /jobs/<id>/proof -- that job's Evidence Receipt.
890
+
891
+ Returns proof.json UNWRAPPED at the top level. The detached gpg
892
+ signature, when the build made one, already lives inside it at
893
+ verification.gpg_signature, and the integrity hash is computed over the
894
+ receipt with `verification` stripped. Wrapping the body in an envelope
895
+ would therefore break hash recomputation for a client that writes the
896
+ response to disk, making every honest receipt read as tampered.
897
+
898
+ Same bearer-token auth and fail-closed behavior as POST /jobs. A job
899
+ that produced no attributable proof is a 404: a synthesized or
900
+ borrowed-from-another-run receipt would be worse than none, since its
901
+ whole value is that the submitter can check it without trusting us.
902
+ """
903
+ if not self._check_api_auth():
904
+ return
905
+ job = self.dispatcher.get_job(job_id) if self.dispatcher else None
906
+ if job is None:
907
+ self._send_json(404, {"error": "unknown job id"})
908
+ return
909
+ run_id, reason = self.dispatcher.get_job_proof_id(job_id)
910
+ proof = read_proof(run_id) if run_id else None
911
+ if proof is None:
912
+ self._send_json(404, {
913
+ "error": "no proof available for this job",
914
+ "reason": reason or "the recorded receipt could not be read",
915
+ })
473
916
  return
917
+ self._send_json(200, proof)
474
918
 
919
+ def _read_body(self):
920
+ """Read and return the request body, or None if it was refused.
921
+
922
+ Shared by /webhook and /jobs so both get the same Content-Length cap and
923
+ the same bounded read (a slow-loris that drips or never finishes the
924
+ body must not tie up a worker thread BEFORE authentication). On refusal
925
+ the error response is already sent and None is returned.
926
+ """
475
927
  try:
476
928
  content_length = int(self.headers.get("Content-Length", 0))
477
929
  except (TypeError, ValueError):
478
930
  self._send_json(400, {"error": "invalid Content-Length"})
479
- return
931
+ return None
480
932
  if content_length < 0 or content_length > self.MAX_BODY_BYTES:
481
933
  self._send_json(413, {"error": "payload too large"})
482
- return
934
+ return None
483
935
 
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
936
  prev_timeout = self.connection.gettimeout()
489
937
  self.connection.settimeout(BODY_READ_TIMEOUT_SECONDS)
490
938
  try:
491
939
  body = self.rfile.read(content_length)
492
940
  except (socket.timeout, TimeoutError, ConnectionError, OSError):
493
941
  logging.warning(
494
- "Dropping slow/incomplete webhook body from %s",
942
+ "Dropping slow/incomplete request body from %s",
495
943
  self.address_string(),
496
944
  )
497
945
  try:
498
946
  self._send_json(408, {"error": "request body timeout"})
499
947
  except (BrokenPipeError, ConnectionResetError, OSError):
500
948
  pass
501
- return
949
+ return None
502
950
  finally:
503
951
  try:
504
952
  self.connection.settimeout(prev_timeout)
505
953
  except OSError:
506
954
  pass
955
+
507
956
  if len(body) != content_length:
508
957
  # Client closed before delivering the declared body. Refuse rather
509
958
  # than authenticate a truncated payload.
510
959
  self._send_json(400, {"error": "incomplete request body"})
960
+ return None
961
+ return body
962
+
963
+ def _handle_job_submit(self):
964
+ """POST /jobs -- authenticated remote submit of a spec.
965
+
966
+ Enqueues onto the SAME bounded queue and worker pool as the webhook
967
+ path; only the credential and the admission validation differ.
968
+ """
969
+ body = self._read_body()
970
+ if body is None:
971
+ return
972
+ # Authenticate BEFORE parsing: an unauthenticated caller never reaches
973
+ # the JSON parser, and never gets a job id back.
974
+ if not self._check_api_auth():
975
+ return
976
+
977
+ try:
978
+ payload = json.loads(body)
979
+ except json.JSONDecodeError:
980
+ self._send_json(400, {"error": "invalid JSON"})
981
+ return
982
+ if not isinstance(payload, dict):
983
+ self._send_json(400, {"error": "payload must be a JSON object"})
984
+ return
985
+
986
+ spec = payload.get("spec")
987
+ if not valid_spec(spec):
988
+ log_event(JOB_EVENT, "", "", "rejected (invalid spec)")
989
+ self._send_json(400, {"error": "invalid spec"})
990
+ return
991
+
992
+ job_id = secrets.token_urlsafe(12)
993
+ job = {"spec": spec.strip(), "job_id": job_id}
994
+
995
+ # Record BEFORE enqueueing. A worker can pick the job up the instant
996
+ # submit() returns and write "running"/"fired"; record_job is
997
+ # last-writer-wins, so recording afterwards could clobber a terminal
998
+ # status with "queued" and leave a finished job polling as queued
999
+ # forever. On the 429 path below the record is unreachable (no id is
1000
+ # handed out) and the store is bounded, so nothing leaks.
1001
+ self.dispatcher.record_job(job_id, "queued", job["spec"])
1002
+
1003
+ # Enforce the existing queue bound so a submit storm cannot fork
1004
+ # unbounded builds. 429 (not a silent drop) tells the client to retry.
1005
+ if not self.dispatcher.submit(JOB_EVENT, job):
1006
+ logging.warning(
1007
+ "Queue full; shedding job submit from %s", self.address_string()
1008
+ )
1009
+ log_event(JOB_EVENT, "", "", "rejected (queue full)")
1010
+ self._send_json(429, {"error": "server busy, retry later"})
1011
+ return
1012
+
1013
+ log_event(JOB_EVENT, "", job["spec"], "queued")
1014
+ self._send_json(202, {"id": job_id, "status": "queued"})
1015
+
1016
+ def do_POST(self):
1017
+ path = self.path.split("?", 1)[0]
1018
+ if path == "/jobs":
1019
+ self._handle_job_submit()
1020
+ return
1021
+ if path != "/webhook":
1022
+ self._send_json(404, {"error": "not found"})
1023
+ return
1024
+
1025
+ body = self._read_body()
1026
+ if body is None:
511
1027
  return
512
1028
 
513
1029
  event_type = self.headers.get("X-GitHub-Event", "")
514
1030
  signature = self.headers.get("X-Hub-Signature-256", "")
515
1031
  delivery_id = self.headers.get("X-GitHub-Delivery", "")
516
1032
 
1033
+ # Credential separation: JOB_EVENT rides the shared EVENT_HANDLERS table
1034
+ # so it uses one dispatch path, but it is NOT a GitHub event and must
1035
+ # never be reachable with the webhook HMAC. Without this, a holder of
1036
+ # the webhook secret could POST X-GitHub-Event: loki_job and submit an
1037
+ # arbitrary spec, defeating the separate-credential requirement.
1038
+ if event_type == JOB_EVENT:
1039
+ logging.warning(
1040
+ "Rejecting %s on /webhook from %s: submits require the /jobs "
1041
+ "API and its own bearer token",
1042
+ JOB_EVENT,
1043
+ self.address_string(),
1044
+ )
1045
+ log_event(event_type, "", "", "rejected (job submit not allowed on webhook)")
1046
+ self._send_json(403, {"error": "use POST /jobs to submit a job"})
1047
+ return
1048
+
517
1049
  # Defect 1 fix: refuse to dispatch when no secret is configured. The
518
1050
  # server stays up for ops endpoints, but webhooks are rejected with an
519
1051
  # audit line. We never silently accept-all.
@@ -641,6 +1173,17 @@ def main():
641
1173
  if not secret:
642
1174
  secret = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
643
1175
 
1176
+ # The /jobs bearer token has NO CLI flag on purpose: a CLI arg lands in the
1177
+ # process list where any local user can read it. Env or mounted file only.
1178
+ api_token = load_api_token()
1179
+ if api_token and secret and constant_time_equals(api_token, secret):
1180
+ logging.error(
1181
+ "LOKI_API_TOKEN is identical to the webhook secret. They "
1182
+ "authenticate different parties and MUST differ; disabling the "
1183
+ "/jobs API until a distinct token is configured."
1184
+ )
1185
+ api_token = ""
1186
+
644
1187
  # Persist resolved values, but never write the secret to disk.
645
1188
  config["port"] = port
646
1189
  config["dry_run"] = dry_run
@@ -653,6 +1196,7 @@ def main():
653
1196
 
654
1197
  WebhookHandler.dry_run = dry_run
655
1198
  WebhookHandler.secret = secret
1199
+ WebhookHandler.api_token = api_token
656
1200
  WebhookHandler.dispatcher = dispatcher
657
1201
 
658
1202
  server = ThreadingWebhookServer(("", port), WebhookHandler)
@@ -662,7 +1206,14 @@ def main():
662
1206
  logging.info("Loki trigger server starting on port %d%s", port, mode_label)
663
1207
  logging.info("Webhook endpoint: POST http://localhost:%d/webhook", port)
664
1208
  logging.info("Health check: GET http://localhost:%d/health", port)
1209
+ logging.info("Submit endpoint: POST http://localhost:%d/jobs", port)
665
1210
  logging.info("Workers: %d, queue size: %d", workers, queue_size)
1211
+ if not api_token:
1212
+ logging.warning(
1213
+ "No API token configured: ALL /jobs requests will be rejected with "
1214
+ "503. Set LOKI_API_TOKEN or LOKI_API_TOKEN_FILE to enable remote "
1215
+ "submits."
1216
+ )
666
1217
  if not secret:
667
1218
  logging.warning(
668
1219
  "No webhook secret configured: ALL webhooks will be rejected with "