Updates
This commit is contained in:
parent
b5a8dcd220
commit
d8b0889d7d
12 changed files with 513 additions and 102 deletions
20
canary.sh
20
canary.sh
|
@ -1,4 +1,16 @@
|
|||
python canary.py
|
||||
git add - A .
|
||||
git commit -m "New canary"
|
||||
git push origin main
|
||||
#!/bin/bash
|
||||
|
||||
# Run the canary script
|
||||
python3 sw1tch/canary.py
|
||||
|
||||
# Check if the canary.txt was generated successfully
|
||||
if [ $? -eq 0 ] && [ -f sw1tch/data/canary.txt ]; then
|
||||
# Stage all changes, commit, and push
|
||||
git add sw1tch/data/canary.txt
|
||||
git commit -m "Update warrant canary - $(date +%Y-%m-%d)"
|
||||
git push origin main
|
||||
echo "Warrant canary updated and pushed to repository."
|
||||
else
|
||||
echo "Failed to generate or find canary.txt. Git operations aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
@ -2,16 +2,14 @@ import os
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sw1tch import BASE_DIR, CustomLoggingMiddleware
|
||||
from sw1tch.routes.public import router as public_router
|
||||
from sw1tch.routes.admin import router as admin_router
|
||||
from sw1tch.routes.canary import router as canary_router
|
||||
from sw1tch.routes import admin, canary, public
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(CustomLoggingMiddleware)
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
|
||||
app.include_router(public_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(canary_router)
|
||||
|
||||
app.include_router(admin.router)
|
||||
app.include_router(public.router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
|
92
sw1tch/canary.py
Executable file → Normal file
92
sw1tch/canary.py
Executable file → Normal file
|
@ -12,32 +12,52 @@ from pathlib import Path
|
|||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# File paths
|
||||
CONFIG_FILE = "config.yaml"
|
||||
OUTPUT_FILE = "canary.txt"
|
||||
TEMP_MESSAGE_FILE = "temp_canary_message.txt"
|
||||
# File paths relative to the sw1tch/ directory
|
||||
BASE_DIR = Path(__file__).parent
|
||||
CONFIG_FILE = BASE_DIR / "config" / "config.yaml"
|
||||
ATTESTATIONS_FILE = BASE_DIR / "config" / "attestations.txt"
|
||||
OUTPUT_FILE = BASE_DIR / "data" / "canary.txt"
|
||||
TEMP_MESSAGE_FILE = BASE_DIR / "data" / "temp_canary_message.txt"
|
||||
|
||||
def load_config():
|
||||
"""Load configuration from YAML file."""
|
||||
try:
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
if not CONFIG_FILE.exists():
|
||||
print(f"Error: Configuration file '{CONFIG_FILE}' not found.")
|
||||
sys.exit(1)
|
||||
with open(CONFIG_FILE, 'r') as file:
|
||||
config = yaml.safe_load(file)
|
||||
required = [('gpg', 'key_id'), ('canary', 'organization'), ('canary', 'attestations')]
|
||||
for section, field in required:
|
||||
if section not in config or field not in config[section]:
|
||||
print(f"Error: Missing required field '{section}.{field}' in config.")
|
||||
sys.exit(1)
|
||||
# Adjust to match config.yaml structure
|
||||
required = [
|
||||
('canary', 'organization'),
|
||||
('canary', 'gpg_key_id'),
|
||||
('canary', 'credentials', 'username'),
|
||||
('canary', 'credentials', 'password'),
|
||||
('canary', 'room')
|
||||
]
|
||||
for path in required:
|
||||
current = config
|
||||
for key in path:
|
||||
if key not in current:
|
||||
print(f"Error: Missing required field '{'.'.join(path)}' in config.")
|
||||
sys.exit(1)
|
||||
current = current[key]
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def get_current_date():
|
||||
"""Return the current date in YYYY-MM-DD format."""
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
def load_attestations():
|
||||
"""Load attestations from attestations.txt."""
|
||||
try:
|
||||
if not ATTESTATIONS_FILE.exists():
|
||||
print(f"Error: Attestations file '{ATTESTATIONS_FILE}' not found.")
|
||||
sys.exit(1)
|
||||
with open(ATTESTATIONS_FILE, 'r') as f:
|
||||
return [line.strip() for line in f if line.strip()]
|
||||
except Exception as e:
|
||||
print(f"Error loading attestations: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def get_nist_time():
|
||||
"""Get the current time from NIST or fallback servers."""
|
||||
|
@ -66,7 +86,7 @@ def get_nist_time():
|
|||
def get_rss_headline(config):
|
||||
"""Get the latest headline and link from the configured RSS feed."""
|
||||
try:
|
||||
rss_config = config.get('rss', {})
|
||||
rss_config = config['canary'].get('rss', {})
|
||||
rss_url = rss_config.get('url', 'https://www.democracynow.org/democracynow.rss')
|
||||
feed = feedparser.parse(rss_url)
|
||||
if feed.entries and len(feed.entries) > 0:
|
||||
|
@ -100,11 +120,12 @@ def get_bitcoin_latest_block():
|
|||
return None
|
||||
|
||||
def collect_attestations(config):
|
||||
"""Prompt user for each attestation from config."""
|
||||
"""Prompt user for each attestation from attestations.txt."""
|
||||
attestations = load_attestations()
|
||||
selected_attestations = []
|
||||
org = config['canary']['organization']
|
||||
print("\nPlease confirm each attestation separately:")
|
||||
for i, attestation in enumerate(config['canary']['attestations'], 1):
|
||||
for i, attestation in enumerate(attestations, 1):
|
||||
while True:
|
||||
response = input(f"Confirm: '{org} {attestation}' (y/n): ").lower()
|
||||
if response in ['y', 'n']:
|
||||
|
@ -121,7 +142,6 @@ def get_optional_note():
|
|||
|
||||
def create_warrant_canary_message(config):
|
||||
"""Create the warrant canary message with updated formatting."""
|
||||
current_date = get_current_date()
|
||||
nist_time = get_nist_time()
|
||||
rss_data = get_rss_headline(config)
|
||||
bitcoin_block = get_bitcoin_latest_block()
|
||||
|
@ -129,7 +149,7 @@ def create_warrant_canary_message(config):
|
|||
if not all([nist_time, rss_data, bitcoin_block]):
|
||||
missing = []
|
||||
if not nist_time: missing.append("NIST time")
|
||||
if not rss_data: missing.append(f"{config['rss'].get('name', 'RSS')} headline")
|
||||
if not rss_data: missing.append(f"{config['canary']['rss'].get('name', 'RSS')} headline")
|
||||
if not bitcoin_block: missing.append("Bitcoin block data")
|
||||
print(f"Error: Could not fetch: {', '.join(missing)}")
|
||||
return None
|
||||
|
@ -145,9 +165,7 @@ def create_warrant_canary_message(config):
|
|||
org = config['canary']['organization']
|
||||
admin_name = config['canary'].get('admin_name', 'Admin')
|
||||
admin_title = config['canary'].get('admin_title', 'administrator')
|
||||
rss_name = config['rss'].get('name', 'RSS Feed')
|
||||
|
||||
# No leading \n; GPG adds one blank line after Hash: SHA512
|
||||
message = f"{org} Warrant Canary · {nist_time}\n"
|
||||
message += f"I, {admin_name}, the {admin_title} of {org}, state this {datetime.datetime.now().strftime('%dth day of %B, %Y')}:\n"
|
||||
for i, attestation in enumerate(attestations, 1):
|
||||
|
@ -162,12 +180,12 @@ def create_warrant_canary_message(config):
|
|||
message += f" BTC block: #{bitcoin_block['height']}, {bitcoin_block['time']}\n"
|
||||
message += f" Block hash: {bitcoin_block['hash']}\n"
|
||||
|
||||
return message.rstrip() + "\n" # Single newline before signature
|
||||
return message.rstrip() + "\n"
|
||||
|
||||
def sign_with_gpg(message, gpg_key_id):
|
||||
"""Sign the warrant canary message with GPG, ensuring no extra newline after signature header."""
|
||||
"""Sign the warrant canary message with GPG."""
|
||||
try:
|
||||
with open(TEMP_MESSAGE_FILE, "w", newline='\n') as f: # Unix line endings
|
||||
with open(TEMP_MESSAGE_FILE, "w", newline='\n') as f:
|
||||
f.write(message)
|
||||
cmd = ["gpg", "--clearsign", "--default-key", gpg_key_id, TEMP_MESSAGE_FILE]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
@ -175,13 +193,11 @@ def sign_with_gpg(message, gpg_key_id):
|
|||
signed_message = f.read()
|
||||
os.remove(TEMP_MESSAGE_FILE)
|
||||
os.remove(f"{TEMP_MESSAGE_FILE}.asc")
|
||||
# Fix GPG's extra newline after -----BEGIN PGP SIGNATURE-----
|
||||
lines = signed_message.splitlines()
|
||||
signature_idx = next(i for i, line in enumerate(lines) if line == "-----BEGIN PGP SIGNATURE-----")
|
||||
if lines[signature_idx + 1] == "":
|
||||
lines.pop(signature_idx + 1) # Remove blank line
|
||||
signed_message = "\n".join(lines)
|
||||
return signed_message
|
||||
lines.pop(signature_idx + 1)
|
||||
return "\n".join(lines)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"GPG signing error: {e}")
|
||||
return None
|
||||
|
@ -202,13 +218,10 @@ def save_warrant_canary(signed_message):
|
|||
|
||||
async def post_to_matrix(config, signed_message):
|
||||
"""Post the signed warrant canary to Matrix room."""
|
||||
if not config.get('matrix', {}).get('enabled', False):
|
||||
print("Matrix posting is disabled in config")
|
||||
return False
|
||||
try:
|
||||
from nio import AsyncClient, LoginResponse
|
||||
matrix = config['matrix']
|
||||
client = AsyncClient(matrix['homeserver'], matrix['username'])
|
||||
from nio import AsyncClient
|
||||
matrix = config['canary']['credentials']
|
||||
client = AsyncClient(config['base_url'], matrix['username'])
|
||||
await client.login(matrix['password'])
|
||||
|
||||
full_message = (
|
||||
|
@ -227,7 +240,7 @@ async def post_to_matrix(config, signed_message):
|
|||
f"<pre>{signed_message}</pre>"
|
||||
)
|
||||
}
|
||||
await client.room_send(matrix['room_id'], "m.room.message", content)
|
||||
await client.room_send(config['canary']['room'], "m.room.message", content)
|
||||
await client.logout()
|
||||
await client.close()
|
||||
print("Posted to Matrix successfully")
|
||||
|
@ -253,14 +266,17 @@ def main():
|
|||
print("Operation cancelled")
|
||||
sys.exit(0)
|
||||
|
||||
signed_message = sign_with_gpg(message, config['gpg']['key_id'])
|
||||
signed_message = sign_with_gpg(message, config['canary']['gpg_key_id'])
|
||||
if not signed_message:
|
||||
print("Failed to sign message")
|
||||
sys.exit(1)
|
||||
|
||||
if save_warrant_canary(signed_message) and config.get('matrix', {}).get('enabled', False):
|
||||
if input("Post to Matrix? (y/n): ").lower() == 'y':
|
||||
asyncio.run(post_to_matrix(config, signed_message))
|
||||
if not save_warrant_canary(signed_message):
|
||||
print("Failed to save canary")
|
||||
sys.exit(1)
|
||||
|
||||
if input("Post to Matrix? (y/n): ").lower() == 'y':
|
||||
asyncio.run(post_to_matrix(config, signed_message))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
@ -1,16 +1,73 @@
|
|||
from fastapi import APIRouter, Form, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Form, Depends, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
import re
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
from sw1tch import config, logger, load_registrations, save_registrations, verify_admin_auth
|
||||
from sw1tch import BASE_DIR, config, logger, load_registrations, save_registrations, verify_admin_auth
|
||||
from sw1tch.utilities.matrix import get_matrix_users, deactivate_user
|
||||
|
||||
router = APIRouter(prefix="/_admin", dependencies=[Depends(verify_admin_auth)])
|
||||
router = APIRouter(prefix="/_admin")
|
||||
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_panel(request: Request, auth_token: str = Depends(verify_admin_auth)):
|
||||
return templates.TemplateResponse("admin.html", {"request": request, "authenticated": True})
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def admin_login_page(request: Request):
|
||||
return templates.TemplateResponse("admin.html", {"request": request, "authenticated": False})
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
async def admin_login(request: Request, password: str = Form(...)):
|
||||
expected_password = config["matrix_admin"].get("password", "")
|
||||
hashed_password = hashlib.sha256(password.encode()).hexdigest()
|
||||
if hashed_password == hashlib.sha256(expected_password.encode()).hexdigest():
|
||||
return HTMLResponse(
|
||||
content=f"""
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;url=/_admin/?auth_token={hashed_password}">
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to admin panel...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
return templates.TemplateResponse("admin.html", {"request": request, "authenticated": False, "error": "Invalid password"})
|
||||
|
||||
@router.get("/view_unfulfilled", response_class=HTMLResponse)
|
||||
async def view_unfulfilled_registrations(request: Request, auth_token: str = Depends(verify_admin_auth)):
|
||||
registrations = load_registrations()
|
||||
unfulfilled = []
|
||||
if registrations:
|
||||
current_time = datetime.utcnow()
|
||||
async with httpx.AsyncClient() as client:
|
||||
for entry in registrations:
|
||||
username = entry["requested_name"]
|
||||
reg_date = datetime.fromisoformat(entry["datetime"])
|
||||
age = current_time - reg_date
|
||||
url = f"{config['base_url']}/_matrix/client/v3/register/available?username={username}"
|
||||
try:
|
||||
response = await client.get(url, timeout=5)
|
||||
if response.status_code == 200 and response.json().get("available", False):
|
||||
unfulfilled.append({
|
||||
"username": username,
|
||||
"email": entry["email"],
|
||||
"registration_date": entry["datetime"],
|
||||
"age_hours": age.total_seconds() / 3600
|
||||
})
|
||||
except httpx.RequestError as ex:
|
||||
logger.error(f"Error checking username {username}: {ex}")
|
||||
return templates.TemplateResponse("unfulfilled_registrations.html", {"request": request, "registrations": unfulfilled})
|
||||
|
||||
@router.post("/purge_unfulfilled_registrations", response_class=JSONResponse)
|
||||
async def purge_unfulfilled_registrations(min_age_hours: int = Form(default=24)):
|
||||
async def purge_unfulfilled_registrations(min_age_hours: int = Form(default=24), auth_token: str = Depends(verify_admin_auth)):
|
||||
registrations = load_registrations()
|
||||
if not registrations:
|
||||
return JSONResponse({"message": "No registrations found to clean up"})
|
||||
|
@ -62,8 +119,21 @@ async def purge_unfulfilled_registrations(min_age_hours: int = Form(default=24))
|
|||
logger.info(f"Cleanup complete: {result}")
|
||||
return JSONResponse(result)
|
||||
|
||||
@router.get("/view_undocumented", response_class=HTMLResponse)
|
||||
async def view_undocumented_users(request: Request, auth_token: str = Depends(verify_admin_auth)):
|
||||
registrations = load_registrations()
|
||||
matrix_users = await get_matrix_users()
|
||||
registered_usernames = {entry["requested_name"].lower() for entry in registrations}
|
||||
homeserver = config["homeserver"].lower()
|
||||
undocumented_users = [
|
||||
user for user in matrix_users
|
||||
if user.lower().startswith("@") and user[1:].lower().split(":", 1)[1] == homeserver
|
||||
and user[1:].lower().split(":", 1)[0] not in registered_usernames
|
||||
]
|
||||
return templates.TemplateResponse("undocumented_users.html", {"request": request, "users": undocumented_users})
|
||||
|
||||
@router.post("/deactivate_undocumented_users", response_class=JSONResponse)
|
||||
async def deactivate_undocumented_users():
|
||||
async def deactivate_undocumented_users(auth_token: str = Depends(verify_admin_auth)):
|
||||
registrations = load_registrations()
|
||||
matrix_users = await get_matrix_users()
|
||||
registered_usernames = {entry["requested_name"].lower() for entry in registrations}
|
||||
|
@ -93,7 +163,7 @@ async def deactivate_undocumented_users():
|
|||
return JSONResponse(result)
|
||||
|
||||
@router.post("/retroactively_document_users", response_class=JSONResponse)
|
||||
async def retroactively_document_users():
|
||||
async def retroactively_document_users(auth_token: str = Depends(verify_admin_auth)):
|
||||
registrations = load_registrations()
|
||||
matrix_users = await get_matrix_users()
|
||||
registered_usernames = {entry["requested_name"].lower() for entry in registrations}
|
||||
|
|
|
@ -13,7 +13,7 @@ from urllib3.util.retry import Retry
|
|||
from sw1tch import BASE_DIR, config, logger, verify_admin_auth
|
||||
from sw1tch.utilities.matrix import AsyncClient
|
||||
|
||||
router = APIRouter(prefix="/_admin/warrant_canary", dependencies=[Depends(verify_admin_auth)])
|
||||
router = APIRouter(prefix="/_admin/canary")
|
||||
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||||
|
||||
ATTESTATIONS_FILE = os.path.join(BASE_DIR, "config", "attestations.txt")
|
||||
|
@ -45,8 +45,8 @@ def get_nist_time():
|
|||
return data["dateTime"] + " UTC"
|
||||
elif "utc_datetime" in data:
|
||||
return data["utc_datetime"] + " UTC"
|
||||
except requests.RequestException:
|
||||
pass
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch NIST time from {url}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch NIST time")
|
||||
|
||||
def get_rss_headline():
|
||||
|
@ -60,18 +60,19 @@ def get_rss_headline():
|
|||
def get_bitcoin_latest_block():
|
||||
try:
|
||||
response = requests.get("https://blockchain.info/latestblock", timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
block_response = requests.get(f"https://blockchain.info/rawblock/{data['hash']}", timeout=10)
|
||||
if block_response.status_code == 200:
|
||||
block_data = block_response.json()
|
||||
hash_str = data["hash"].lstrip("0") or "0"
|
||||
return {
|
||||
"height": data["height"],
|
||||
"hash": hash_str,
|
||||
"time": datetime.datetime.fromtimestamp(block_data["time"]).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
}
|
||||
except Exception:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
block_response = requests.get(f"https://blockchain.info/rawblock/{data['hash']}", timeout=10)
|
||||
block_response.raise_for_status()
|
||||
block_data = block_response.json()
|
||||
hash_str = data["hash"].lstrip("0") or "0"
|
||||
return {
|
||||
"height": data["height"],
|
||||
"hash": hash_str,
|
||||
"time": datetime.datetime.fromtimestamp(block_data["time"]).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch Bitcoin block data: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch Bitcoin block data")
|
||||
|
||||
def create_warrant_canary_message(attestations: List[str], note: str):
|
||||
|
@ -99,7 +100,7 @@ def sign_with_gpg(message: str, gpg_key_id: str, passphrase: str):
|
|||
with open(TEMP_CANARY_FILE, "w", newline='\n') as f:
|
||||
f.write(message)
|
||||
cmd = ["gpg", "--batch", "--yes", "--passphrase", passphrase, "--clearsign", "--default-key", gpg_key_id, TEMP_CANARY_FILE]
|
||||
subprocess.run(cmd, check=True)
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
with open(f"{TEMP_CANARY_FILE}.asc", "r") as f:
|
||||
signed_message = f.read()
|
||||
os.remove(TEMP_CANARY_FILE)
|
||||
|
@ -110,7 +111,11 @@ def sign_with_gpg(message: str, gpg_key_id: str, passphrase: str):
|
|||
lines.pop(signature_idx + 1)
|
||||
return "\n".join(lines)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise HTTPException(status_code=500, detail=f"GPG signing failed: {e}")
|
||||
logger.error(f"GPG signing failed: {e.stderr}")
|
||||
raise HTTPException(status_code=500, detail=f"GPG signing failed: {e.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during GPG signing: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error during GPG signing: {e}")
|
||||
|
||||
async def post_to_matrix(signed_message: str):
|
||||
try:
|
||||
|
@ -132,36 +137,62 @@ async def post_to_matrix(signed_message: str):
|
|||
f"<pre>{signed_message}</pre>"
|
||||
)
|
||||
}
|
||||
await client.room_send(config['matrix_admin']['room'], "m.room.message", content)
|
||||
await client.room_send(config['canary']['room'], "m.room.message", content)
|
||||
await client.logout()
|
||||
await client.close()
|
||||
logger.info("Warrant canary posted to Matrix successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error posting to Matrix: {e}")
|
||||
return False
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def warrant_canary_form(request: Request):
|
||||
async def warrant_canary_form(request: Request, auth_token: str = Depends(verify_admin_auth)):
|
||||
attestations = load_attestations()
|
||||
return templates.TemplateResponse("canary_form.html", {
|
||||
return templates.TemplateResponse("canary.html", {
|
||||
"request": request,
|
||||
"attestations": attestations,
|
||||
"organization": config["canary"]["organization"]
|
||||
})
|
||||
|
||||
@router.post("/preview", response_class=HTMLResponse)
|
||||
async def warrant_canary_preview(request: Request, attestations: List[str] = Form(...), note: str = Form(default="")):
|
||||
message = create_warrant_canary_message(attestations, note)
|
||||
return templates.TemplateResponse("canary_preview.html", {"request": request, "message": message})
|
||||
async def warrant_canary_preview(
|
||||
request: Request,
|
||||
selected_attestations: List[str] = Form(...),
|
||||
note: str = Form(default=""),
|
||||
auth_token: str = Depends(verify_admin_auth)
|
||||
):
|
||||
message = create_warrant_canary_message(selected_attestations, note)
|
||||
return templates.TemplateResponse("canary_preview.html", {
|
||||
"request": request,
|
||||
"message": message,
|
||||
"selected_attestations": selected_attestations,
|
||||
"note": note
|
||||
})
|
||||
|
||||
@router.post("/sign", response_class=HTMLResponse)
|
||||
async def warrant_canary_sign(request: Request, message: str = Form(...), passphrase: str = Form(...)):
|
||||
async def warrant_canary_sign(
|
||||
request: Request,
|
||||
message: str = Form(...),
|
||||
passphrase: str = Form(...),
|
||||
auth_token: str = Depends(verify_admin_auth)
|
||||
):
|
||||
signed_message = sign_with_gpg(message, config["canary"]["gpg_key_id"], passphrase)
|
||||
with open(CANARY_OUTPUT_FILE, "w") as f:
|
||||
f.write(signed_message)
|
||||
return templates.TemplateResponse("canary_success.html", {"request": request, "signed_message": signed_message})
|
||||
logger.info(f"Warrant canary saved to {CANARY_OUTPUT_FILE}")
|
||||
return templates.TemplateResponse("canary_success.html", {
|
||||
"request": request,
|
||||
"signed_message": signed_message
|
||||
})
|
||||
|
||||
@router.post("/post", response_class=JSONResponse)
|
||||
async def warrant_canary_post(signed_message: str = Form(...)):
|
||||
async def warrant_canary_post(
|
||||
signed_message: str = Form(...),
|
||||
auth_token: str = Depends(verify_admin_auth)
|
||||
):
|
||||
success = await post_to_matrix(signed_message)
|
||||
return JSONResponse({"message": "Posted to Matrix" if success else "Failed to post to Matrix"})
|
||||
return JSONResponse({
|
||||
"message": "Posted to Matrix successfully" if success else "Failed to post to Matrix",
|
||||
"success": success
|
||||
})
|
||||
|
|
|
@ -33,7 +33,7 @@ body {
|
|||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
max-width: 80%; /* Changed from 24rem to match results-container */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
@ -136,3 +136,81 @@ button:hover, .button:hover {
|
|||
.message p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
margin-top: 0; /* Remove top margin since it’s now in results-container */
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
overflow-wrap: break-word; /* Ensure long text wraps */
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--input-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
/* Add to the end of sw1tch/static/styles.css */
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.15s ease;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.canary-preview {
|
||||
background-color: var(--input-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 1rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
|
99
sw1tch/templates/admin.html
Normal file
99
sw1tch/templates/admin.html
Normal file
|
@ -0,0 +1,99 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Panel</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<style>
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.admin-button {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.results-container {
|
||||
width: 80%;
|
||||
margin: 0 auto 2rem auto;
|
||||
max-height: none; /* Allow height to adjust to content */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo-container">
|
||||
<img src="/static/logo.png" alt="Logo" class="logo">
|
||||
</div>
|
||||
|
||||
{% if not authenticated %}
|
||||
<div class="login-container">
|
||||
<form id="login-form" method="post" action="/_admin/login">
|
||||
<input type="password" name="password" placeholder="Admin Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{% if error %}
|
||||
<p class="message" style="color: var(--error-color);">{{ error }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid-container">
|
||||
<button onclick="viewUnfulfilled('{{ request.query_params.auth_token }}')" class="admin-button">View Unfulfilled Registrations</button>
|
||||
<button onclick="viewUndocumented('{{ request.query_params.auth_token }}')" class="admin-button">View Undocumented Users</button>
|
||||
</div>
|
||||
<div id="results" class="results-container"></div>
|
||||
<div class="grid-container">
|
||||
<button onclick="purgeUnfulfilled('{{ request.query_params.auth_token }}')" class="admin-button">Purge Unfulfilled Registrations</button>
|
||||
<button onclick="deactivateUndocumented('{{ request.query_params.auth_token }}')" class="admin-button">Deactivate Undocumented Users</button>
|
||||
</div>
|
||||
<p class="info-text">Logged in as admin</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function viewUnfulfilled(auth_token) {
|
||||
const response = await fetch(`/_admin/view_unfulfilled?auth_token=${auth_token}`);
|
||||
const html = await response.text();
|
||||
document.getElementById('results').innerHTML = html;
|
||||
}
|
||||
|
||||
async function purgeUnfulfilled(auth_token) {
|
||||
if (confirm("Are you sure you want to purge unfulfilled registrations?")) {
|
||||
const response = await fetch(`/_admin/purge_unfulfilled_registrations?auth_token=${auth_token}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "min_age_hours=24"
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(`Cleanup complete:\nKept existing: ${result.kept_existing}\nKept recent: ${result.kept_recent}\nRemoved: ${result.removed}`);
|
||||
viewUnfulfilled(auth_token); // Refresh the list
|
||||
}
|
||||
}
|
||||
|
||||
async function viewUndocumented(auth_token) {
|
||||
const response = await fetch(`/_admin/view_undocumented?auth_token=${auth_token}`);
|
||||
const html = await response.text();
|
||||
document.getElementById('results').innerHTML = html;
|
||||
}
|
||||
|
||||
async function deactivateUndocumented(auth_token) {
|
||||
if (confirm("Are you sure you want to deactivate undocumented users?")) {
|
||||
const response = await fetch(`/_admin/deactivate_undocumented_users?auth_token=${auth_token}`, {
|
||||
method: "POST"
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(`${result.message}\nFailed deactivations: ${result.failed_deactivations || 'None'}`);
|
||||
viewUndocumented(auth_token); // Refresh the list
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
32
sw1tch/templates/canary.html
Normal file
32
sw1tch/templates/canary.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warrant Canary</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo-container">
|
||||
<img src="/static/logo.png" alt="Logo" class="logo">
|
||||
</div>
|
||||
<h2>Create Warrant Canary</h2>
|
||||
<form method="post" action="/_admin/canary/preview?auth_token={{ request.query_params.auth_token }}">
|
||||
<fieldset>
|
||||
<legend>Select Attestations for {{ organization }}</legend>
|
||||
{% for attestation in attestations %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="selected_attestations" value="{{ attestation }}">
|
||||
{{ organization }} {{ attestation }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<label for="note">Optional Note:</label>
|
||||
<textarea id="note" name="note" rows="3" placeholder="Add an optional note"></textarea>
|
||||
<button type="submit">Preview Canary</button>
|
||||
</form>
|
||||
<p class="info-text"><a href="/_admin/?auth_token={{ request.query_params.auth_token }}">Back to Admin Panel</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,16 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Warrant Canary Preview</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Preview Warrant Canary</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Warrant Canary Preview</h1>
|
||||
<pre>{{ message }}</pre>
|
||||
<form method="post" action="/_admin/warrant_canary/sign">
|
||||
<input type="hidden" name="message" value="{{ message|escape }}">
|
||||
<input type="password" name="passphrase" placeholder="GPG Passphrase" required>
|
||||
<input type="hidden" name="auth_token" value="{{ request.query_params.auth_token }}">
|
||||
<button type="submit">Sign</button>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="logo-container">
|
||||
<img src="/static/logo.png" alt="Logo" class="logo">
|
||||
</div>
|
||||
<h2>Preview Warrant Canary</h2>
|
||||
<pre class="canary-preview">{{ message }}</pre>
|
||||
<form method="post" action="/_admin/canary/sign?auth_token={{ request.query_params.auth_token }}">
|
||||
<input type="hidden" name="message" value="{{ message | escape }}">
|
||||
<label for="passphrase">GPG Passphrase:</label>
|
||||
<input type="password" id="passphrase" name="passphrase" required>
|
||||
<button type="submit">Sign and Save</button>
|
||||
</form>
|
||||
<p class="info-text"><a href="/_admin/canary/?auth_token={{ request.query_params.auth_token }}">Back to Form</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,15 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warrant Canary Signed</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Warrant Canary Signed</h1>
|
||||
<pre>{{ signed_message }}</pre>
|
||||
<form method="post" action="/_admin/warrant_canary/post">
|
||||
<input type="hidden" name="signed_message" value="{{ signed_message|escape }}">
|
||||
<input type="hidden" name="auth_token" value="{{ request.query_params.auth_token }}">
|
||||
<button type="submit">Post to Matrix</button>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="logo-container">
|
||||
<img src="/static/logo.png" alt="Logo" class="logo">
|
||||
</div>
|
||||
<h2>Warrant Canary Signed</h2>
|
||||
<pre class="canary-preview">{{ signed_message }}</pre>
|
||||
<button onclick="postToMatrix('{{ signed_message | escape }}', '{{ request.query_params.auth_token }}')">Post to Matrix</button>
|
||||
<p id="post-result" class="message"></p>
|
||||
<p class="info-text"><a href="/_admin/?auth_token={{ request.query_params.auth_token }}">Back to Admin Panel</a></p>
|
||||
</div>
|
||||
<script>
|
||||
async function postToMatrix(signed_message, auth_token) {
|
||||
const response = await fetch(`/_admin/canary/post?auth_token=${auth_token}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `signed_message=${encodeURIComponent(signed_message)}`
|
||||
});
|
||||
const result = await response.json();
|
||||
document.getElementById("post-result").textContent = result.message;
|
||||
document.getElementById("post-result").style.color = result.success ? "var(--success-color)" : "var(--error-color)";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
21
sw1tch/templates/undocumented_users.html
Normal file
21
sw1tch/templates/undocumented_users.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
<div class="list-container">
|
||||
<h2>Undocumented Users</h2>
|
||||
{% if users %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="message">No undocumented users found.</p>
|
||||
{% endif %}
|
||||
</div>
|
27
sw1tch/templates/unfulfilled_registrations.html
Normal file
27
sw1tch/templates/unfulfilled_registrations.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
<div class="list-container">
|
||||
<h2>Unfulfilled Registrations</h2>
|
||||
{% if registrations %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Registration Date</th>
|
||||
<th>Age (Hours)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reg in registrations %}
|
||||
<tr>
|
||||
<td>{{ reg.username }}</td>
|
||||
<td>{{ reg.email }}</td>
|
||||
<td>{{ reg.registration_date }}</td>
|
||||
<td>{{ reg.age_hours | round(1) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="message">No unfulfilled registrations found.</p>
|
||||
{% endif %}
|
||||
</div>
|
Loading…
Add table
Reference in a new issue