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.
@@ -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 = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
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
- # Map GitHub event name -> handler. Keeps do_POST routing declarative.
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
- dispatch_event(event_type, payload, dry_run=self.dry_run)
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
- if self.path == "/health":
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 self.path == "/status":
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 do_POST(self):
471
- if self.path != "/webhook":
472
- self._send_json(404, {"error": "not found"})
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 webhook body from %s",
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 "