v2: Vault via host-side sync + disk cache, no bw CLI in container
This commit is contained in:
parent
3b9d54231b
commit
8b97dea0e7
5 changed files with 74 additions and 152 deletions
12
Dockerfile
12
Dockerfile
|
|
@ -1,17 +1,7 @@
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl unzip && \
|
|
||||||
curl -sL 'https://vault.bitwarden.com/download/?app=cli&platform=linux' -o /tmp/bw.zip && \
|
|
||||||
unzip /tmp/bw.zip -d /usr/local/bin/ && chmod +x /usr/local/bin/bw && \
|
|
||||||
rm /tmp/bw.zip && apt-get purge -y curl unzip && apt-get autoremove -y && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
|
|
||||||
COPY entrypoint.sh .
|
|
||||||
RUN chmod +x entrypoint.sh
|
|
||||||
EXPOSE 8888
|
EXPOSE 8888
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8888"]
|
||||||
|
|
|
||||||
169
app.py
169
app.py
|
|
@ -1,101 +1,46 @@
|
||||||
"""Homelab Butler – Unified API proxy for Pfannkuchen homelab.
|
"""Homelab Butler – Unified API proxy for Pfannkuchen homelab.
|
||||||
Reads credentials from Vaultwarden (Bitwarden CLI) on startup, caches locally."""
|
Reads credentials from Vaultwarden cache (synced by host cron) with flat-file fallback."""
|
||||||
|
|
||||||
import os, json, asyncio, subprocess, logging
|
import os, json, asyncio, logging
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Request, HTTPException, Depends
|
from fastapi import FastAPI, Request, HTTPException, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
log = logging.getLogger("butler")
|
log = logging.getLogger("butler")
|
||||||
VAULT_REFRESH_MINUTES = int(os.environ.get("VAULT_REFRESH_MINUTES", "30"))
|
|
||||||
|
|
||||||
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
||||||
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
|
|
||||||
BW_PASSWORD = os.environ.get("BW_PASSWORD", "")
|
|
||||||
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
|
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
|
||||||
|
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
|
||||||
|
|
||||||
# --- Vault sync ---
|
# --- Credential cache ---
|
||||||
|
|
||||||
_vault_cache: dict[str, str] = {}
|
_vault_cache: dict[str, str] = {}
|
||||||
|
|
||||||
# Mapping: vault item name -> local file name
|
def _load_vault_cache():
|
||||||
VAULT_TO_FILE = {
|
"""Load vault items from disk cache (written by host-side vault-sync.sh)."""
|
||||||
"HA_TOKEN": "homeassistent",
|
|
||||||
"N8N_API_KEY": "n8n",
|
|
||||||
"OUTLINE_API_KEY": "outline",
|
|
||||||
"HETZNER_DNS_TOKEN": "hetzner-dns",
|
|
||||||
"TG_HARALD_TOKEN": "tg-harald",
|
|
||||||
"TG_SASCHA_CHAT": "tg-sascha-chat",
|
|
||||||
"SEERR_URL": None, # skip URL-only items
|
|
||||||
}
|
|
||||||
|
|
||||||
def _sync_vault():
|
|
||||||
"""Pull all items from Vaultwarden via bw CLI and cache them."""
|
|
||||||
global _vault_cache
|
global _vault_cache
|
||||||
if not BW_PASSWORD:
|
if not os.path.isdir(VAULT_CACHE_DIR):
|
||||||
log.info("No BW_PASSWORD set, skipping vault sync")
|
log.info(f"No vault cache at {VAULT_CACHE_DIR}")
|
||||||
return False
|
|
||||||
try:
|
|
||||||
env = {**os.environ, "BW_PASSWORD": BW_PASSWORD}
|
|
||||||
session = subprocess.run(
|
|
||||||
["bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw"],
|
|
||||||
capture_output=True, text=True, env=env, timeout=30
|
|
||||||
).stdout.strip()
|
|
||||||
if not session:
|
|
||||||
log.warning("Vault unlock failed")
|
|
||||||
return False
|
|
||||||
subprocess.run(["bw", "sync", "--session", session],
|
|
||||||
capture_output=True, env=env, timeout=30)
|
|
||||||
result = subprocess.run(
|
|
||||||
["bw", "list", "items", "--session", session],
|
|
||||||
capture_output=True, text=True, env=env, timeout=30
|
|
||||||
)
|
|
||||||
items = json.loads(result.stdout)
|
|
||||||
new_cache = {}
|
|
||||||
for item in items:
|
|
||||||
name = item.get("name", "")
|
|
||||||
notes = item.get("notes") or ""
|
|
||||||
if name and notes:
|
|
||||||
new_cache[name] = notes.strip()
|
|
||||||
_vault_cache = new_cache
|
|
||||||
# Write to disk as cache (fallback if vault unreachable later)
|
|
||||||
os.makedirs(VAULT_CACHE_DIR, exist_ok=True)
|
|
||||||
for name, value in new_cache.items():
|
|
||||||
safe = name.lower().replace(" ", "-")
|
|
||||||
with open(f"{VAULT_CACHE_DIR}/{safe}", "w") as f:
|
|
||||||
f.write(value)
|
|
||||||
log.info(f"Vault sync: {len(new_cache)} items cached")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"Vault sync failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _load_disk_cache():
|
|
||||||
"""Load cached vault items from disk (fallback)."""
|
|
||||||
global _vault_cache
|
|
||||||
cache_dir = VAULT_CACHE_DIR
|
|
||||||
if not os.path.isdir(cache_dir):
|
|
||||||
return
|
return
|
||||||
for f in os.listdir(cache_dir):
|
new = {}
|
||||||
path = os.path.join(cache_dir, f)
|
for f in os.listdir(VAULT_CACHE_DIR):
|
||||||
|
path = os.path.join(VAULT_CACHE_DIR, f)
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
_vault_cache[f] = open(path).read().strip()
|
new[f] = open(path).read().strip()
|
||||||
log.info(f"Loaded {len(_vault_cache)} items from disk cache")
|
_vault_cache = new
|
||||||
|
log.info(f"Loaded {len(new)} vault items from cache")
|
||||||
|
|
||||||
async def _periodic_vault_sync():
|
async def _periodic_cache_reload():
|
||||||
|
"""Reload vault cache every 5 minutes (host cron writes new files)."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(VAULT_REFRESH_MINUTES * 60)
|
await asyncio.sleep(300)
|
||||||
log.info("Periodic vault refresh...")
|
_load_vault_cache()
|
||||||
_sync_vault()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: try vault sync in background, use file fallback immediately
|
_load_vault_cache()
|
||||||
_load_disk_cache()
|
task = asyncio.create_task(_periodic_cache_reload())
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_in_executor(None, _sync_vault) # non-blocking
|
|
||||||
task = asyncio.create_task(_periodic_vault_sync())
|
|
||||||
yield
|
yield
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
|
@ -104,12 +49,13 @@ app = FastAPI(title="Homelab Butler", version="2.0.0", lifespan=lifespan)
|
||||||
# --- Credential reading (vault-first, file-fallback) ---
|
# --- Credential reading (vault-first, file-fallback) ---
|
||||||
|
|
||||||
def _read(name):
|
def _read(name):
|
||||||
"""Read a credential: vault cache first, then flat file."""
|
"""Read credential: vault cache first, then flat file."""
|
||||||
# Check vault cache by exact name
|
# Vault cache uses lowercase-hyphenated names
|
||||||
if name in _vault_cache:
|
vault_name = name.lower().replace("_", "-")
|
||||||
return _vault_cache[name]
|
if vault_name in _vault_cache:
|
||||||
# Check vault cache by uppercase convention
|
return _vault_cache[vault_name]
|
||||||
upper = name.upper().replace("-", "_")
|
# Try uppercase convention
|
||||||
|
upper = name.upper().replace("-", "_").lower().replace("_", "-")
|
||||||
if upper in _vault_cache:
|
if upper in _vault_cache:
|
||||||
return _vault_cache[upper]
|
return _vault_cache[upper]
|
||||||
# Fallback to flat file
|
# Fallback to flat file
|
||||||
|
|
@ -136,21 +82,6 @@ def _parse_url_key(name):
|
||||||
lines = [l.strip() for l in raw.splitlines() if l.strip()]
|
lines = [l.strip() for l in raw.splitlines() if l.strip()]
|
||||||
return (lines[0] if lines else None, lines[1] if len(lines) > 1 else None)
|
return (lines[0] if lines else None, lines[1] if len(lines) > 1 else None)
|
||||||
|
|
||||||
# --- Vault-aware credential getters ---
|
|
||||||
|
|
||||||
def _get_seerr_key():
|
|
||||||
return _read("seer") or _vault_cache.get("SEERR_API_KEY", "")
|
|
||||||
|
|
||||||
def _get_sonarr_key(variant=""):
|
|
||||||
if variant == "1080p":
|
|
||||||
return _read("sonarr1080p") or _vault_cache.get("SONARR_FHD_KEY", "")
|
|
||||||
return _read("sonarr") or _vault_cache.get("SONARR_UHD_KEY", "")
|
|
||||||
|
|
||||||
def _get_radarr_key(variant=""):
|
|
||||||
if variant == "1080p":
|
|
||||||
return _read("radarr1080p") or _vault_cache.get("RADARR_FHD_KEY", "")
|
|
||||||
return _read("radarr") or _vault_cache.get("RADARR_UHD_KEY", "")
|
|
||||||
|
|
||||||
# --- Service configs ---
|
# --- Service configs ---
|
||||||
|
|
||||||
SERVICES = {
|
SERVICES = {
|
||||||
|
|
@ -161,26 +92,25 @@ SERVICES = {
|
||||||
"radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"},
|
"radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"},
|
||||||
"seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer"},
|
"seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer"},
|
||||||
"outline": {"url": "http://10.1.1.100:3000", "auth": "bearer", "key_file": "outline",
|
"outline": {"url": "http://10.1.1.100:3000", "auth": "bearer", "key_file": "outline",
|
||||||
"vault_key": "OUTLINE_API_KEY"},
|
"vault_key": "outline_api_key"},
|
||||||
"n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n",
|
"n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n",
|
||||||
"vault_key": "N8N_API_KEY"},
|
"vault_key": "n8n_api_key"},
|
||||||
"proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"},
|
"proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"},
|
||||||
"homeassistant": {"url": "http://10.10.1.20:8123", "auth": "bearer", "key_file": "homeassistent",
|
"homeassistant": {"url": "http://10.10.1.20:8123", "auth": "bearer", "key_file": "homeassistent",
|
||||||
"vault_key": "HA_TOKEN"},
|
"vault_key": "ha_token"},
|
||||||
"grafana": {"url": "http://10.1.1.111:3000", "auth": "bearer", "key_file": "grafana"},
|
"grafana": {"url": "http://10.1.1.111:3000", "auth": "bearer", "key_file": "grafana"},
|
||||||
"uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime"},
|
"uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Dockhand session cache ---
|
# --- Dockhand session ---
|
||||||
|
|
||||||
_dockhand_cookie = None
|
_dockhand_cookie = None
|
||||||
|
|
||||||
async def _dockhand_login(client):
|
async def _dockhand_login(client):
|
||||||
global _dockhand_cookie
|
global _dockhand_cookie
|
||||||
pw = _read("dockhand")
|
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{SERVICES['dockhand']['url']}/api/auth/login",
|
f"{SERVICES['dockhand']['url']}/api/auth/login",
|
||||||
json={"username": "admin", "password": pw or ""},
|
json={"username": "admin", "password": _read("dockhand") or ""},
|
||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
_dockhand_cookie = dict(r.cookies)
|
_dockhand_cookie = dict(r.cookies)
|
||||||
|
|
@ -196,7 +126,6 @@ def _verify(request: Request):
|
||||||
raise HTTPException(401, "Invalid token")
|
raise HTTPException(401, "Invalid token")
|
||||||
|
|
||||||
def _get_key(cfg):
|
def _get_key(cfg):
|
||||||
"""Get API key from vault (preferred) or file (fallback)."""
|
|
||||||
vault_key = cfg.get("vault_key")
|
vault_key = cfg.get("vault_key")
|
||||||
if vault_key and vault_key in _vault_cache:
|
if vault_key and vault_key in _vault_cache:
|
||||||
return _vault_cache[vault_key]
|
return _vault_cache[vault_key]
|
||||||
|
|
@ -206,20 +135,17 @@ def _get_key(cfg):
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {
|
return {"service": "homelab-butler", "version": "2.0.0",
|
||||||
"service": "homelab-butler", "version": "2.0.0",
|
"services": list(SERVICES.keys()), "vault_items": len(_vault_cache)}
|
||||||
"services": list(SERVICES.keys()),
|
|
||||||
"vault_items": len(_vault_cache),
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "vault_items": len(_vault_cache)}
|
return {"status": "ok", "vault_items": len(_vault_cache)}
|
||||||
|
|
||||||
@app.post("/vault/refresh")
|
@app.post("/vault/reload")
|
||||||
async def vault_refresh(_=Depends(_verify)):
|
async def vault_reload(_=Depends(_verify)):
|
||||||
ok = _sync_vault()
|
_load_vault_cache()
|
||||||
return {"refreshed": ok, "items": len(_vault_cache)}
|
return {"reloaded": True, "items": len(_vault_cache)}
|
||||||
|
|
||||||
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||||
|
|
@ -237,22 +163,17 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||||
|
|
||||||
if auth_type == "apikey":
|
if auth_type == "apikey":
|
||||||
headers["X-Api-Key"] = _get_key(cfg) or ""
|
headers["X-Api-Key"] = _get_key(cfg) or ""
|
||||||
|
|
||||||
elif auth_type == "apikey_urlfile":
|
elif auth_type == "apikey_urlfile":
|
||||||
url, key = _parse_url_key(cfg["key_file"])
|
url, key = _parse_url_key(cfg["key_file"])
|
||||||
base_url = url.rstrip("/") if url else ""
|
base_url = url.rstrip("/") if url else ""
|
||||||
headers["X-Api-Key"] = key or ""
|
headers["X-Api-Key"] = key or ""
|
||||||
|
|
||||||
elif auth_type == "bearer":
|
elif auth_type == "bearer":
|
||||||
headers["Authorization"] = f"Bearer {_get_key(cfg)}"
|
headers["Authorization"] = f"Bearer {_get_key(cfg)}"
|
||||||
|
|
||||||
elif auth_type == "n8n":
|
elif auth_type == "n8n":
|
||||||
headers["X-N8N-API-KEY"] = _get_key(cfg) or ""
|
headers["X-N8N-API-KEY"] = _get_key(cfg) or ""
|
||||||
|
|
||||||
elif auth_type == "proxmox":
|
elif auth_type == "proxmox":
|
||||||
pv = _parse_kv("proxmox")
|
pv = _parse_kv("proxmox")
|
||||||
headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}"
|
headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}"
|
||||||
|
|
||||||
elif auth_type == "session":
|
elif auth_type == "session":
|
||||||
global _dockhand_cookie
|
global _dockhand_cookie
|
||||||
if not _dockhand_cookie:
|
if not _dockhand_cookie:
|
||||||
|
|
@ -264,22 +185,16 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||||
resp = await client.request(
|
resp = await client.request(method=request.method, url=target,
|
||||||
method=request.method, url=target,
|
headers=headers, cookies=cookies, content=body)
|
||||||
headers=headers, cookies=cookies, content=body,
|
|
||||||
)
|
|
||||||
if auth_type == "session" and resp.status_code == 401:
|
if auth_type == "session" and resp.status_code == 401:
|
||||||
_dockhand_cookie = None
|
_dockhand_cookie = None
|
||||||
await _dockhand_login(client)
|
await _dockhand_login(client)
|
||||||
cookies = _dockhand_cookie or {}
|
resp = await client.request(method=request.method, url=target,
|
||||||
resp = await client.request(
|
headers=headers, cookies=_dockhand_cookie or {}, content=body)
|
||||||
method=request.method, url=target,
|
|
||||||
headers=headers, cookies=cookies, content=body,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = resp.text
|
data = resp.text
|
||||||
|
|
||||||
return JSONResponse(content=data, status_code=resp.status_code)
|
return JSONResponse(content=data, status_code=resp.status_code)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ services:
|
||||||
- API_KEY_DIR=/data/api
|
- API_KEY_DIR=/data/api
|
||||||
- VAULT_CACHE_DIR=/data/vault-cache
|
- VAULT_CACHE_DIR=/data/vault-cache
|
||||||
- BUTLER_TOKEN=${BUTLER_TOKEN}
|
- BUTLER_TOKEN=${BUTLER_TOKEN}
|
||||||
- BW_PASSWORD=${BW_PASSWORD}
|
|
||||||
- VAULT_REFRESH_MINUTES=30
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
vault-cache:
|
vault-cache:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configure bw CLI for Vaultwarden on first run
|
|
||||||
if [ ! -f /root/.config/Bitwarden\ CLI/data.json ] || ! bw status 2>/dev/null | grep -q '"status":"locked"'; then
|
|
||||||
bw config server https://vault.sascha-lutz.de 2>/dev/null || true
|
|
||||||
BW_CLIENTID="user.d0a1f14f-fcbb-436a-b18c-426987704df5" \
|
|
||||||
BW_CLIENTSECRET="wwr4O0pf7mWTOSmTslNP3jqzl5OERp" \
|
|
||||||
bw login --apikey 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec uvicorn app:app --host 0.0.0.0 --port 8888
|
|
||||||
31
vault-sync.sh
Normal file
31
vault-sync.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# vault-sync.sh - Sync Vaultwarden items to Butler cache volume
|
||||||
|
# Run via cron: */30 * * * * /app-config/homelab-butler/vault-sync.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export BW_PASSWORD="8yRG5LADfoTLHdC1Oj"
|
||||||
|
CACHE_DIR=$(sudo docker inspect homelab-butler --format '{{range .Mounts}}{{if eq .Destination "/data/vault-cache"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
|
||||||
|
|
||||||
|
[ -z "$CACHE_DIR" ] && echo "Butler container not found" && exit 1
|
||||||
|
|
||||||
|
SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null)
|
||||||
|
[ -z "$SESSION" ] && echo "Vault unlock failed" && exit 1
|
||||||
|
|
||||||
|
bw sync --session "$SESSION" >/dev/null 2>&1
|
||||||
|
|
||||||
|
bw list items --session "$SESSION" 2>/dev/null | sudo python3 -c "
|
||||||
|
import sys, json, os
|
||||||
|
items = json.load(sys.stdin)
|
||||||
|
cache_dir = '$CACHE_DIR'
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
count = 0
|
||||||
|
for item in items:
|
||||||
|
name = item.get('name', '')
|
||||||
|
notes = item.get('notes') or ''
|
||||||
|
if name and notes:
|
||||||
|
safe = name.lower().replace(' ', '-')
|
||||||
|
with open(f'{cache_dir}/{safe}', 'w') as f:
|
||||||
|
f.write(notes.strip())
|
||||||
|
count += 1
|
||||||
|
print(f'vault-sync: {count} items written')
|
||||||
|
"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue