add env-gated icon-reachability verifier; admin is_live; docs

This commit is contained in:
proitlab committed 2026-08-17 00:24:27 +07:00
1 parent bc8021825a
commit 77d6cd5b92
5 files changed
+103 -4

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ Project conventions and operational gotchas for agents working in this repo.
- `app/admin.py` — sqladmin panel: `TokenAdmin` (custom WTForms form, `on_model_change`), `VersionAdmin`, `create_admin(app)`. Upload icons saved under `app/static/icons/token/` with `iconUrl = {IDRS_PUBLIC_URL}/img/token/<filename>`.
- `app/models.py` — SQLAlchemy `Token`/`Version`, engine, `SessionLocal`, `ICON_DIR`, `DB_PATH` (env `IDRS_DB_PATH`).
- `app/db.py` — seed from `tokenList.json`/`versionAndroid.json` (once, empty DB only).
- `app/verify.py` — icon-reachability verifier: daemon thread started on app startup, gated by env `IDRS_VERIFY_ENABLED` (absent/0/false/off → disabled, default). When enabled, token live iff `iconUrl` reachable (local file stat under `icons/token/`, or external HTTP HEAD/GET 2xx); empty → false; flips after 2 consecutive failed passes, auto-recovers; interval env `IDRS_VERIFY_INTERVAL` (default 600s). When disabled, `is_live` is admin-owned (editable form field). External hosts must be reachable from the server or those tokens flip false while enabled.
- `app/auth.py` — `AdminAuthBackend`, fail-closed creds.
- `tokenList.json` / `versionAndroid.json` — seed source (read-only after seed).
+3
View File
@@ -13,6 +13,7 @@ FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getToken
- seed once on first run (empty DB) from repo-root `tokenList.json` (23 tokens) + `versionAndroid.json`; ⊥ reseed if DB non-empty
- `idfToken` unique; response ordered `position` ASC
- version rule: `acceptableVersion` ≤ `latestVersion` ! enforced (else reject)
- `is_live` admin-owned by default; optional verifier gated by `IDRS_VERIFY_ENABLED` (default off): when enabled, token live iff `iconUrl` reachable — local URL (host = `IDRS_PUBLIC_URL`) → file exists under `icons/token/`; external URL → HTTP HEAD/GET 2xx (~3s timeout, follow redirects); empty iconUrl → not live; flips after 2 consecutive failed passes, auto-recovers; interval env `IDRS_VERIFY_INTERVAL` (default 600s); external hosts must be reachable from the server
- out of scope: multi-user table, roles, 2FA, password reset, on-chain calls, audit trail, change history
- data survival: existing `idrs.db` file + schema kept (migration-free); SQLAlchemy reads the same file
- run: `./venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000` + `run.sh` wrapper
@@ -46,6 +47,7 @@ V13: version table single row (`id=1`) maintained — panel ⊥ creates duplicat
V14: ∀ upload → PNG verified by magic bytes `\x89PNG\r\n\x1a\n` (⊥ trust `content-type` header), extension forced `.png`, size ≤ 512KB; `/img/<filename>` served with `image/png` (⊥ `guess_type`)
V15: token edit/create form upload → `on_model_change` sets `filename` + `iconUrl={IDRS_PUBLIC_URL}/img/token/<filename>` (base env var, default `https://idrs.databisnis.id`, trailing `/` stripped) before persist; no new file → keep existing (⊥ clear)
V16: `idfToken` auto-increment only — not in form, never written from form data (⊥ injectable); `typeBlockchain` fixed `"Vexanium"` — not in form, always forced on write (⊥ injectable); `position` editable, default = next idfToken value
V17: `is_live` admin-owned by default (editable in form, writes honored). Optional verifier — runs only when `IDRS_VERIFY_ENABLED` truthy (absent/0/false/off → off): when enabled, verifier overwrites `is_live` each pass (live iff iconUrl reachable; empty → false; flips after 2 consecutive failed passes, recovers next success)
## §T — Tasks
id|status|task|cites
@@ -73,6 +75,7 @@ T21|x|`tokenList.json`: replace seed iconUrl domain `idrs.kriptoteknologi.io`
T22|x|icon upload → `token/` subdir + `/img/token/<filename>` route (flat route removed); migrate `ayam-logo.png` into `token/`, backfill token1 iconUrl|V15
T23|x|docker-compose.yml: swarm stack — service `idrs-api` (registry image `git.proit.id/proitlab/idrs-api`), traefik labels (Host `idrs.databisnis.id`, websecure, letsencrypt), constraint `node.hostname != server2U`, external `traefik-net`, NFS bind mounts (`data` + `static`)|§C
T24|x|`/public/img/token/<filename>` alias route (seed iconUrl path) serving `icons/token/`; `FORWARDED_ALLOW_IPS=*` in stack env so uvicorn trusts traefik scheme (admin assets load over https)|V14,V15
T25|x|icon-reachability verifier (`app/verify.py`): daemon thread, local file stat + external HTTP HEAD/GET, 2-pass grace, writes `is_live`; gated by `IDRS_VERIFY_ENABLED` (default off) — when off, `is_live` admin-editable|V17
## §B — Bug log
id|date|cause|fix
+5 -1
View File
@@ -6,10 +6,13 @@ from fastapi.staticfiles import StaticFiles
from app import db
from app.admin import create_admin
from app.verify import Verifier
BASE_DIR = Path(__file__).resolve().parent
ICON_DIR = BASE_DIR / "static" / "icons"
verifier = Verifier()
app = FastAPI(title="IDRS API")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
@@ -68,4 +71,5 @@ def get_public_icon(filename: str):
@app.on_event("startup")
def startup():
db.init_db()
db.init_db()
verifier.start()
+91
View File
@@ -0,0 +1,91 @@
import logging
import os
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
from app.models import ICON_DIR, SessionLocal, Token
logger = logging.getLogger("idrs.verify")
ENABLED = os.environ.get("IDRS_VERIFY_ENABLED", "").strip().lower() in ("1", "true", "yes", "on")
PUBLIC_BASE_URL = os.environ.get("IDRS_PUBLIC_URL", "https://idrs.databisnis.id").rstrip("/")
PUBLIC_HOST = urlparse(PUBLIC_BASE_URL).hostname or "idrs.databisnis.id"
INTERVAL = int(os.environ.get("IDRS_VERIFY_INTERVAL", "600"))
GRACE_PASSES = 2
HTTP_TIMEOUT = 3
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
def _local_reachable(filename: str) -> bool:
token_dir = ICON_DIR / "token"
path = (token_dir / filename).resolve()
return path.is_file() and token_dir.resolve() in path.parents
def _http_reachable(url: str) -> bool:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "image/*,*/*"})
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return 200 <= resp.status < 300
except urllib.error.HTTPError as e:
logger.warning("icon http %s for %s", e.code, url)
return False
except Exception as e:
logger.warning("icon network error %r for %s", e, url)
return False
def reachable(token) -> bool:
url = (token.iconUrl or "").strip()
if not url:
return False
if urlparse(url).hostname == PUBLIC_HOST:
return _local_reachable(token.filename or "")
return _http_reachable(url)
class Verifier(threading.Thread):
def __init__(self):
super().__init__(daemon=True, name="idrs-verify")
self._stop = threading.Event()
self._fail_counts = {}
def stop(self):
self._stop.set()
def start(self):
if not ENABLED:
logger.info("verifier disabled (IDRS_VERIFY_ENABLED not set)")
return
super().start()
def run(self):
while not self._stop.is_set():
try:
self._check_once()
except Exception:
logger.exception("verify pass failed")
self._stop.wait(INTERVAL)
def _check_once(self):
session = SessionLocal()
try:
for token in session.query(Token).all():
ok = reachable(token)
if ok:
self._fail_counts[token.idfToken] = 0
live = True
else:
fails = self._fail_counts.get(token.idfToken, 0) + 1
self._fail_counts[token.idfToken] = fails
live = fails < GRACE_PASSES
if bool(token.is_live) != live:
token.is_live = live
logger.info("token %s is_live -> %s (%s)", token.idfToken, live, token.iconUrl)
session.commit()
finally:
session.close()
+3 -3
View File
@@ -2,8 +2,8 @@ services:
idrs-api:
image: git.proit.id/proitlab/idrs-api:latest
environment:
ADMIN_USERNAME: admin
ADMIN_PASSWORD: Pro4dm1n
ADMIN_USERNAME:
ADMIN_PASSWORD:
IDRS_PUBLIC_URL: https://idrs.databisnis.id
FORWARDED_ALLOW_IPS: "*"
volumes:
@@ -27,4 +27,4 @@ services:
networks:
traefik-net:
external: true
external: true