feat: add homelab topology documentation, docker deployments, and authentik sso configuration scripts
This commit is contained in:
314
scripts/setup_zabbix_authentik_sso.py
Normal file
314
scripts/setup_zabbix_authentik_sso.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Automate end-to-end SAML SSO integration between Authentik and Zabbix 7.0 LTS.
|
||||
|
||||
Performs:
|
||||
1. Authentik SAML Provider creation with ACS and Entity IDs.
|
||||
2. Authentik Application creation linked to the provider.
|
||||
3. Certificate retrieval from Authentik.
|
||||
4. Zabbix SAML User Directory configuration with JIT (Just-In-Time) user provisioning.
|
||||
5. Zabbix Authentication settings update to enable SAML SSO.
|
||||
6. Validation of the live SSO login endpoint.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def authentik_request(base_url: str, token: str, endpoint: str, method: str = "GET", data: dict = None):
|
||||
url = f"{base_url.rstrip('/')}/api/v3/{endpoint.lstrip('/')}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(data).encode("utf-8") if data else None,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
|
||||
content = resp.read().decode("utf-8")
|
||||
return json.loads(content) if content else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
err_msg = e.read().decode("utf-8", errors="ignore")
|
||||
raise RuntimeError(f"Authentik API Error [{e.code}]: {err_msg}")
|
||||
|
||||
|
||||
def zabbix_rpc(url: str, method: str, params: dict, auth_token: str = None):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": 1,
|
||||
}
|
||||
if auth_token:
|
||||
payload["auth"] = auth_token
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json-rpc"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
|
||||
res = json.loads(resp.read().decode("utf-8"))
|
||||
if "error" in res:
|
||||
raise RuntimeError(f"Zabbix API Error ({res['error'].get('message')}): {res['error'].get('data')}")
|
||||
return res.get("result")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Configure Authentik SAML SSO for Zabbix 7.0")
|
||||
parser.add_argument("--authentik-url", default="https://authentik.snarfnet.net", help="Authentik Base URL")
|
||||
parser.add_argument("--authentik-token", required=True, help="Authentik API Token (Bearer)")
|
||||
parser.add_argument("--zabbix-url", default="http://140.44.4.60:8888/api_jsonrpc.php", help="Zabbix API URL")
|
||||
parser.add_argument("--zabbix-user", default="Admin", help="Zabbix Admin Username")
|
||||
parser.add_argument("--zabbix-pass", default="zabbix", help="Zabbix Admin Password")
|
||||
parser.add_argument("--sp-url", default="https://zabbix.snarfnet.net", help="Zabbix Public Base URL")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=========================================================")
|
||||
print("=== STEP 1: AUTHENTIK SAML CONFIGURATION ===")
|
||||
print("=========================================================")
|
||||
|
||||
# 1. Fetch Signing Certificates from Authentik
|
||||
print(f"Fetching certificates from {args.authentik_url}...")
|
||||
try:
|
||||
certs = authentik_request(args.authentik_url, args.authentik_token, "crypto/certificatekeypairs/")
|
||||
results = certs.get("results", [])
|
||||
if not results:
|
||||
print("ERROR: No certificates found in Authentik!")
|
||||
sys.exit(1)
|
||||
cert = results[0]
|
||||
for c in results:
|
||||
if "authentik" in c.get("name", "").lower() or "snarfnet" in c.get("name", "").lower():
|
||||
cert = c
|
||||
break
|
||||
cert_uuid = cert["pk"]
|
||||
print(f"Using Authentik Signing Certificate: '{cert['name']}' ({cert_uuid})")
|
||||
except Exception as e:
|
||||
print(f"Failed to communicate with Authentik: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Discover Flows
|
||||
print("Resolving Authentik Authorization & Invalidation flows...")
|
||||
flows = authentik_request(args.authentik_url, args.authentik_token, "flows/instances/").get("results", [])
|
||||
auth_flow_pk = None
|
||||
inval_flow_pk = None
|
||||
|
||||
for f in flows:
|
||||
slug = f.get("slug", "")
|
||||
desig = f.get("designation", "")
|
||||
if "implicit-consent" in slug or (desig == "authorization" and not auth_flow_pk):
|
||||
auth_flow_pk = f["pk"]
|
||||
if "default-provider-invalidation" in slug or (desig == "invalidation" and not inval_flow_pk):
|
||||
inval_flow_pk = f["pk"]
|
||||
|
||||
print(f" * Authorization Flow: {auth_flow_pk}")
|
||||
print(f" * Invalidation Flow: {inval_flow_pk}")
|
||||
|
||||
# 3. Discover SAML Property Mappings
|
||||
print("Resolving SAML property mappings...")
|
||||
pm_resp = authentik_request(args.authentik_url, args.authentik_token, "propertymappings/provider/saml/").get("results", [])
|
||||
property_mappings = []
|
||||
for pm in pm_resp:
|
||||
name = pm.get("name", "")
|
||||
if any(attr in name for attr in ["Username", "Name", "Email", "Groups"]):
|
||||
property_mappings.append(pm["pk"])
|
||||
print(f" * Selected {len(property_mappings)} property mappings.")
|
||||
|
||||
# 4. Check or Create SAML Provider in Authentik
|
||||
acs_url = f"{args.sp_url.rstrip('/')}/index_sso.php?acs"
|
||||
entity_id = args.sp_url.rstrip("/")
|
||||
|
||||
print("Checking existing SAML providers in Authentik...")
|
||||
providers = authentik_request(args.authentik_url, args.authentik_token, "providers/saml/")
|
||||
existing_provider = None
|
||||
for p in providers.get("results", []):
|
||||
if p.get("name") == "Zabbix SAML":
|
||||
existing_provider = p
|
||||
break
|
||||
|
||||
provider_payload = {
|
||||
"name": "Zabbix SAML",
|
||||
"acs_url": acs_url,
|
||||
"issuer": entity_id,
|
||||
"sp_binding": "post",
|
||||
"audience": entity_id,
|
||||
"signing_kp": cert_uuid,
|
||||
"authorization_flow": auth_flow_pk,
|
||||
"invalidation_flow": inval_flow_pk,
|
||||
"property_mappings": property_mappings,
|
||||
"sign_assertion": True,
|
||||
"sign_response": True,
|
||||
}
|
||||
|
||||
if existing_provider:
|
||||
provider_id = existing_provider["pk"]
|
||||
print(f"Updating existing Authentik SAML Provider (ID {provider_id})...")
|
||||
authentik_request(args.authentik_url, args.authentik_token, f"providers/saml/{provider_id}/", method="PATCH", data=provider_payload)
|
||||
print("SAML Provider updated.")
|
||||
else:
|
||||
print("Creating new SAML Provider in Authentik...")
|
||||
new_provider = authentik_request(args.authentik_url, args.authentik_token, "providers/saml/", method="POST", data=provider_payload)
|
||||
provider_id = new_provider["pk"]
|
||||
print(f"Created SAML Provider with ID: {provider_id}")
|
||||
|
||||
# 5. Check or Create Application in Authentik
|
||||
print("Checking Authentik Applications...")
|
||||
apps = authentik_request(args.authentik_url, args.authentik_token, "core/applications/")
|
||||
existing_app = None
|
||||
for a in apps.get("results", []):
|
||||
if a.get("slug") == "zabbix":
|
||||
existing_app = a
|
||||
break
|
||||
|
||||
app_payload = {
|
||||
"name": "Zabbix",
|
||||
"slug": "zabbix",
|
||||
"provider": provider_id,
|
||||
"meta_launch_url": args.sp_url,
|
||||
}
|
||||
|
||||
if existing_app:
|
||||
print(f"Updating existing Authentik Application: {existing_app['slug']}...")
|
||||
authentik_request(args.authentik_url, args.authentik_token, f"core/applications/{existing_app['slug']}/", method="PATCH", data=app_payload)
|
||||
print("Application updated.")
|
||||
else:
|
||||
print("Creating new Authentik Application 'Zabbix' (slug: zabbix)...")
|
||||
authentik_request(args.authentik_url, args.authentik_token, "core/applications/", method="POST", data=app_payload)
|
||||
print("Created Application 'Zabbix'.")
|
||||
|
||||
# SSO & SLO URLs from Authentik standard paths
|
||||
idp_entity_id = entity_id
|
||||
idp_sso_url = f"{args.authentik_url.rstrip('/')}/application/saml/zabbix/sso/binding/redirect/"
|
||||
idp_slo_url = f"{args.authentik_url.rstrip('/')}/application/saml/zabbix/slo/binding/redirect/"
|
||||
|
||||
print("\nAuthentik SAML Endpoints:")
|
||||
print(f" * IdP Entity ID: {idp_entity_id}")
|
||||
print(f" * IdP SSO URL: {idp_sso_url}")
|
||||
print(f" * IdP SLO URL: {idp_slo_url}")
|
||||
print(f" * SP Entity ID: {entity_id}")
|
||||
print(f" * ACS URL: {acs_url}")
|
||||
|
||||
print("\n=========================================================")
|
||||
print("=== STEP 2: ZABBIX 7.0 SAML CONFIGURATION ===")
|
||||
print("=========================================================")
|
||||
|
||||
# 6. Authenticate to Zabbix API
|
||||
print(f"Authenticating to Zabbix at {args.zabbix_url}...")
|
||||
try:
|
||||
auth_res = zabbix_rpc(args.zabbix_url, "user.login", {"username": args.zabbix_user, "password": args.zabbix_pass})
|
||||
zbx_token = auth_res
|
||||
print("Authenticated successfully to Zabbix API.")
|
||||
except Exception as e:
|
||||
print(f"Failed to authenticate to Zabbix: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 7. Lookup Admin Role and Group in Zabbix
|
||||
roles = zabbix_rpc(args.zabbix_url, "role.get", {"filter": {"name": "Super admin role"}}, auth_token=zbx_token)
|
||||
role_id = roles[0]["roleid"] if roles else "3"
|
||||
|
||||
groups = zabbix_rpc(args.zabbix_url, "usergroup.get", {"filter": {"name": "Zabbix administrators"}}, auth_token=zbx_token)
|
||||
group_id = groups[0]["usrgrpid"] if groups else "7"
|
||||
|
||||
dis_groups = zabbix_rpc(args.zabbix_url, "usergroup.get", {"filter": {"name": "Disabled"}}, auth_token=zbx_token)
|
||||
dis_group_id = dis_groups[0]["usrgrpid"] if dis_groups else "9"
|
||||
|
||||
print(f"Resolved JIT provision target -> Role: Super admin (ID {role_id}), Group: Zabbix administrators (ID {group_id}), Disabled: (ID {dis_group_id})")
|
||||
|
||||
# 8. Check existing userdirectories in Zabbix
|
||||
userdirs = zabbix_rpc(args.zabbix_url, "userdirectory.get", {"output": "extend"}, auth_token=zbx_token)
|
||||
existing_saml_dir = None
|
||||
for ud in userdirs:
|
||||
if ud.get("idp_type") == "2": # SAML
|
||||
existing_saml_dir = ud
|
||||
break
|
||||
|
||||
saml_dir_payload = {
|
||||
"name": "Authentik SAML",
|
||||
"idp_type": 2,
|
||||
"idp_entityid": idp_entity_id,
|
||||
"sso_url": idp_sso_url,
|
||||
"slo_url": idp_slo_url,
|
||||
"username_attribute": "http://schemas.goauthentik.io/2021/02/saml/username",
|
||||
"group_name": "http://schemas.xmlsoap.org/claims/Group",
|
||||
"user_username": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
"user_lastname": "",
|
||||
"sp_entityid": entity_id,
|
||||
"nameid_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
||||
"sign_assertions": "0",
|
||||
"sign_authn_requests": "0",
|
||||
"provision_status": "1", # JIT Enabled
|
||||
"provision_groups": [
|
||||
{
|
||||
"name": "authentik Admins",
|
||||
"roleid": role_id,
|
||||
"user_groups": [{"usrgrpid": group_id}],
|
||||
},
|
||||
{
|
||||
"name": "*", # Wildcard fallback to match all authenticated Authentik groups
|
||||
"roleid": role_id,
|
||||
"user_groups": [{"usrgrpid": group_id}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if existing_saml_dir:
|
||||
dir_id = existing_saml_dir["userdirectoryid"]
|
||||
print(f"Updating existing Zabbix SAML userdirectory (ID {dir_id})...")
|
||||
saml_dir_payload["userdirectoryid"] = dir_id
|
||||
zabbix_rpc(args.zabbix_url, "userdirectory.update", saml_dir_payload, auth_token=zbx_token)
|
||||
print("User directory updated successfully.")
|
||||
else:
|
||||
print("Creating new Zabbix SAML userdirectory...")
|
||||
res = zabbix_rpc(args.zabbix_url, "userdirectory.create", saml_dir_payload, auth_token=zbx_token)
|
||||
dir_id = res["userdirectoryids"][0]
|
||||
print(f"Created SAML user directory with ID: {dir_id}")
|
||||
|
||||
# 9. Update Authentication settings to enable SAML
|
||||
print("Enabling SAML authentication in Zabbix...")
|
||||
auth_update_payload = {
|
||||
"saml_auth_enabled": 1,
|
||||
"saml_jit_status": 1,
|
||||
"saml_case_sensitive": 0,
|
||||
"disabled_usrgrpid": dis_group_id,
|
||||
}
|
||||
zabbix_rpc(args.zabbix_url, "authentication.update", auth_update_payload, auth_token=zbx_token)
|
||||
print("SAML authentication enabled successfully!")
|
||||
|
||||
print("\n=========================================================")
|
||||
print("=== STEP 3: LIVE VERIFICATION ===")
|
||||
print("=========================================================")
|
||||
|
||||
# 10. Check login page
|
||||
print(f"Checking login page at {args.sp_url}...")
|
||||
try:
|
||||
req = urllib.request.Request(args.sp_url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=5, context=ctx) as resp:
|
||||
content = resp.read().decode("utf-8", errors="ignore")
|
||||
if "index_sso.php" in content or "SAML" in content or "Single Sign-On" in content:
|
||||
print("SUCCESS: 'Sign in with Single Sign-On (SAML)' is active on the Zabbix login screen!")
|
||||
else:
|
||||
print("NOTE: Login page loaded. Please check the login page for the SSO button.")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not fetch public page directly ({e}).")
|
||||
|
||||
print("\n=========================================================")
|
||||
print("INTEGRATION COMPLETE!")
|
||||
print(f"You can now test signing in via Authentik at: {args.sp_url}")
|
||||
print("Local Admin login remains available at: https://zabbix.snarfnet.net/index.php")
|
||||
print("=========================================================")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user