#!/usr/bin/env python3 """Drive a fresh Baikal container through its install wizard. Baikal has no unattended-install path, so the integration tests would otherwise need a hand-prepared image. This walks the same web forms a person would, leaving a server with Basic authentication, one user, and one calendar. Usage: podman run -d --rm --name runway-baikal -p 8800:80 docker.io/ckulka/baikal:nginx python3 setup.py http://localhost:8800 testuser testpassword """ import re import sys import urllib.error import urllib.parse import urllib.request import http.cookiejar ADMIN_PASSWORD = "runway-integration-admin" def make_opener(): jar = http.cookiejar.CookieJar() return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) def get(opener, url): with opener.open(url, timeout=30) as response: return response.read().decode("utf-8", "replace") def post(opener, url, fields): body = urllib.parse.urlencode(fields).encode() request = urllib.request.Request( url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"} ) with opener.open(request, timeout=30) as response: return response.read().decode("utf-8", "replace") def csrf(html): """The page's CSRF token. Not every form carries one -- the admin login does not -- so callers that may see either use `maybe_csrf`.""" token = maybe_csrf(html) if token is None: raise SystemExit("no CSRF token in page; Baikal's forms have changed") return token def maybe_csrf(html): match = re.search(r'name="CSRF_TOKEN"\s+value="([^"]+)"', html) return match.group(1) if match else None def install(base, username, password): """Walk the wizard until it stops offering forms. The wizard serves every step from the same URL and decides which form to show from its own stored state, so this is driven by what comes back rather than by a fixed sequence of URLs -- which is also what keeps it working across Baikal versions that add or reorder a step. """ opener = make_opener() for _ in range(6): page = get(opener, f"{base}/admin/install/") if "Baikal_Model_Config_Standard::submitted" in page: post(opener, f"{base}/admin/install/", system_fields(csrf(page))) elif "Baikal_Model_Config_Database::submitted" in page: post(opener, f"{base}/admin/install/", database_fields(csrf(page))) else: break else: raise SystemExit("the install wizard did not finish") # Log in to the admin interface. This form has no CSRF token of its own. page = get(opener, f"{base}/admin/") if 'name="auth"' in page: fields = {"auth": "1", "login": "admin", "password": ADMIN_PASSWORD} token = maybe_csrf(page) if token: fields["CSRF_TOKEN"] = token post(opener, f"{base}/admin/", fields) # Create the test user. Baikal gives every new user a default calendar. page = get(opener, f"{base}/admin/?/users/new/1/") post( opener, f"{base}/admin/?/users/new/1/", { "Baikal_Model_User::submitted": "1", "refreshed": "0", "CSRF_TOKEN": csrf(page), "data[username]": username, "witness[username]": "1", "data[displayname]": "Integration Test", "witness[displayname]": "1", "data[email]": f"{username}@example.org", "witness[email]": "1", "data[password]": password, "witness[password]": "1", "data[passwordconfirm]": password, "witness[passwordconfirm]": "1", }, ) users = get(opener, f"{base}/admin/?/users/") if username not in users: raise SystemExit(f"user {username} was not created") print(f"ready: {base}/dav.php/ as {username}") def system_fields(token): """Step one. Basic authentication, because that is what the client sends; Baikal defaults to Digest.""" return { "Baikal_Model_Config_Standard::submitted": "1", "refreshed": "0", "CSRF_TOKEN": token, "data[timezone]": "UTC", "witness[timezone]": "1", "data[card_enabled]": "1", "witness[card_enabled]": "1", "data[cal_enabled]": "1", "witness[cal_enabled]": "1", "data[invite_from]": "noreply@example.org", "witness[invite_from]": "1", "data[dav_auth_type]": "Basic", "witness[dav_auth_type]": "1", "data[admin_passwordhash]": ADMIN_PASSWORD, "witness[admin_passwordhash]": "1", "data[admin_passwordhash_confirm]": ADMIN_PASSWORD, "witness[admin_passwordhash_confirm]": "1", } def database_fields(token): """Step two. SQLite, at the path the form arrives pre-filled with.""" return { "Baikal_Model_Config_Database::submitted": "1", "refreshed": "0", "CSRF_TOKEN": token, "data[backend]": "sqlite", "witness[backend]": "1", "data[sqlite_file]": "/var/www/baikal/Specific/db/db.sqlite", "witness[sqlite_file]": "1", } if __name__ == "__main__": if len(sys.argv) != 4: raise SystemExit(__doc__) install(sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3])