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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +456 -2
- package/autonomy/run.sh +215 -11
- package/autonomy/trigger-server.py +588 -37
- 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,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 =
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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 "
|