diff --git a/process_exp.py b/process_exp.py new file mode 100644 index 0000000..f388295 --- /dev/null +++ b/process_exp.py @@ -0,0 +1,1225 @@ +#!/usr/bin/env python3 +"""By Didier Aeschimann 2026-10-03 + +Takes a SonicWall .exp config file and, next to it, produces: + .html styled standalone HTML version of the report + +Usage: python3 process_exp.py +""" + +import argparse +import base64 +import collections +import html +import os +import re +import sys +from pathlib import Path +from urllib.parse import unquote + + +# ======================================================= step 1: exp -> text + +def exp_to_txt(exp_path: Path) -> str: + content = exp_path.read_text() + content = content.rstrip("\n") + if content.endswith("&&"): + content = content[:-2] + decoded = base64.b64decode(content).decode("latin-1") + decoded = decoded.replace("&", "\n") + return unquote(decoded) + + +# =========================================================== step 2: text -> md + +def gen_report(src_text: str, source_name: str) -> str: + idx = collections.defaultdict(dict) # base -> {i: value} + flat = {} # key -> value + for line in src_text.splitlines(): + if '=' not in line: + continue + k, v = line.split('=', 1) + m = re.match(r'^(.*)_(\d+)$', k) + if m: + idx[m.group(1)][int(m.group(2))] = v + else: + flat[k] = v + + def G(base, i, default=''): + return idx.get(base, {}).get(i, default) + + def F(key, default=''): + return flat.get(key, default) + + def maxi(base): + d = idx.get(base) + return max(d) if d else -1 + + def bits(n, names): + out = [] + for i, nm in enumerate(names): + if n & (1 << i): + out.append(nm) + return out + + def mask2prefix(mask): + if not mask or mask == '0.0.0.0': + return None + try: + b = sum(bin(int(o)).count('1') for o in mask.split('.')) + return b + except ValueError: + return None + + def ipstr(o): + t = o['type'] + if t == 1: + return o['ip1'] + if t == 2: + return '%s - %s' % (o['ip1'], o['ip2']) + if t == 4: + p = mask2prefix(o['ip2']) + return '%s/%d' % (o['ip1'], p) if p else '%s (%s)' % (o['ip1'], o['ip2']) + return None + + L = [] + def w(s=''): + L.append(s) + + # ---------- build object maps ---------- + ADDR_TYPES = {1: 'IP', 2: 'IP Range', 4: 'Subnet', 8: 'Object Group', 0: 'empty'} + zones = [] + for i in range(maxi('zoneObjId') + 1): + zones.append({'id': i, 'name': G('zoneObjId', i), 'type': int(G('zoneObjZoneType', i) or 0)}) + ZONE_TYPES = {0: 'WAN', 1: 'LAN', 2: 'DMZ', 3: 'Reserved', 4: 'WLAN', 5: 'VPN', 6: 'Multicast'} + + addr_objs = {} + addr_order = [] + for i in range(maxi('addrObjId') + 1): + name = G('addrObjId', i) + if not name: + continue + o = {'id': i, 'name': name, 'disp': G('addrObjIdDisp', i), + 'type': int(G('addrObjType', i) or 0), 'zone': G('addrObjZone', i), + 'ip1': G('addrObjIp1', i), 'ip2': G('addrObjIp2', i)} + if name not in addr_objs: + addr_objs[name] = o + addr_order.append(name) + + fqdn_objs = {} + for i in range(maxi('addrObjFqdnId') + 1): + name = G('addrObjFqdnId', i) + if name: + fqdn_objs[name] = {'id': i, 'name': name, 'val': G('addrObjFqdn', i), + 'zone': G('addrObjFqdnZone', i), 'type': G('addrObjFqdnType', i)} + + addr_groups = collections.OrderedDict() + for i in range(maxi('addro_grpToGrp') + 1): + grp = G('addro_grpToGrp', i) + mem = G('addro_atomToGrp', i) + if grp and mem: + addr_groups.setdefault(grp, []).append(mem) + + IPPROTO = {0: 'any', 1: 'ICMP', 2: 'IGMP', 6: 'TCP', 17: 'UDP', 47: 'GRE', + 50: 'ESP', 51: 'AH', 108: 'IPComp', 58: 'ICMPv6'} + + svcs = {} + for i in range(maxi('svcObjId') + 1): + name = G('svcObjId', i) + if not name: + continue + s = {'id': i, 'name': name, + 'type': int(G('svcObjType', i) or 0), + 'proto': int(G('svcObjIpType', i) or 0), + 'p1': int(G('svcObjPort1', i) or 0), 'p2': int(G('svcObjPort2', i) or 0), + 'props': int(G('svcObjProperties', i) or 0), + 'mgmt': int(G('svcObjManagement', i) or 0)} + svcs.setdefault(name, s) + + svc_groups = collections.OrderedDict() + for i in range(maxi('so_grpToGrp') + 1): + grp = G('so_grpToGrp', i) + mem = G('so_atomToGrp', i) + if grp and mem: + svc_groups.setdefault(grp, []).append(mem) + + def svc_desc(name): + s = svcs.get(name) + if not s: + return name + if s['type'] == 2: + proto = IPPROTO.get(s['proto'], str(s['proto'])) + if s['proto'] == 0: + return '%s [combined, raw %d-%d]' % (name, s['p1'], s['p2']) + return '%s [%s %d-%d]' % (name, proto, s['p1'], s['p2']) + proto = IPPROTO.get(s['proto'], str(s['proto'])) + if s['proto'] == 1: + return '%s [ICMP type %d]' % (name, s['p1']) + if s['p1'] == s['p2']: + return '%s [%s/%d]' % (name, proto, s['p1']) + return '%s [%s %d-%d]' % (name, proto, s['p1'], s['p2']) + + sched_atoms = [] + for i in range(maxi('schedObjId') + 1): + nm = G('schedObjId', i) + if not nm: + continue + t = int(G('schedObjType', i) or 0) + dow = int(G('schedObjDaysOfWeek', i) or 0) + b = int(G('schedObjTimeBegin', i) or 0) + e = int(G('schedObjTimeEnd', i) or 0) + days = [] + if t == 1: + names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + days = [names[j] for j in range(7) if dow & (1 << j)] + desc = ', '.join(days) + ' %02d:%02d-%02d:%02d' % (b // 60, b % 60, e // 60, e % 60) + else: + desc = 'group' + sched_atoms.append({'id': i, 'name': nm, 'type': t, 'desc': desc}) + + sched_groups = collections.OrderedDict() + for i in range(maxi('sched_atomToGrp') + 1): + atom = G('sched_atomToGrp', i) + grp = G('sched_grpToGrp', i) + if atom and grp: + sched_groups.setdefault(grp, []).append(atom) + + users = [] + for i in range(maxi('userObjId') + 1): + nm = G('userObjId', i) + if nm: + users.append({'id': i, 'name': nm, 'type': int(G('userObjType', i) or 0), + 'comment': G('userObjComment', i), 'priv': int(G('userObjPrivMask', i) or 0)}) + + user_groups = [] + for i in range(maxi('userGroupObjId') + 1): + nm = G('userGroupObjId', i) + if nm: + user_groups.append({'id': i, 'name': nm, 'type': int(G('userGroupObjType', i) or 0), + 'comment': G('userGroupObjComment', i), + 'cfl': G('userGroupObjCfspId', i), 'priv': int(G('userGroupObjPrivMask', i) or 0)}) + + user_members = [] + for i in range(maxi('uo_atomToGrp') + 1): + u = G('uo_atomToGrp', i) + g = G('uo_grpToGrp', i) + if u and g: + user_members.append((u, g)) + + ldap_users = [G('ldapUsrsTree', i) for i in range(maxi('ldapUsrsTree') + 1) if G('ldapUsrsTree', i)] + ldap_groups = [G('ldapUsrGrpsTree', i) for i in range(maxi('ldapUsrGrpsTree') + 1) if G('ldapUsrGrpsTree', i)] + + rules = [] + for i in range(maxi('policyAction') + 1): + r = {f: G('policy' + f, i) for f in + ['Action', 'SrcZone', 'DstZone', 'SrcNet', 'DstNet', 'DstSvc', 'Whom', + 'Time', 'Comment', 'Log', 'Enabled', 'Frag', 'Props', 'PriType', + 'ConnPercent', 'Inactivity', 'InactivityUdp', 'Management', 'BwEnabled', + 'BwGuar', 'BwMax', 'BwPri', 'IbwEnabled', 'IbwGuar', 'IbwMax', 'IbwPri', + 'QosDscpAction', 'QosDscpTagValue']} + r['id'] = i + rules.append(r) + + nats = [] + for i in range(maxi('natPolicyOrigSrc') + 1): + nats.append({k: G('natPolicy' + k, i) for k in + ['OrigSrc', 'OrigDst', 'OrigSvc', 'TransSrc', 'TransDst', 'TransSvc', + 'SrcIface', 'DstIface', 'Enabled', 'Reflexive', 'Comment', 'NonDeletable']} | + {'id': i}) + + # ---------- write report ---------- + w('# SonicWall PRO 5060 — Readable Configuration Report') + w() + w('*Source file:* `%s` (%d key/value lines)' % (os.path.basename(source_name), len(idx) and sum(len(v) for v in idx.values()) + len(flat))) + w() + w('## 1. Device Overview') + w() + w('| Setting | Value |') + w('|---|---|') + w('| Firewall name | %s |' % F('firewallName')) + w('| Model | PRO 5060 (from filename) |') + w('| Firmware | %s (version id %s) |' % (F('buildNum'), F('currentFirmwareVers'))) + w('| Previous firmware | %s |' % (F('oldFirmwareVers') if F('oldFirmwareVers') not in ('0', '') else '—')) + w('| Serial number | %s |' % F('serialNumber')) + w('| Hardware ID | %s |' % (F('hardwareId') if F('hardwareId') not in ('0', '') else '—')) + w('| FireWall ID (IKE) | %s |' % F('firewallId')) + w('| Timezone | %s |' % F('timezone')) + w('| Admin domain | %s |' % F('fwinfoDomain')) + w('| Domain (DHCP) | %s |' % F('dhcp_domainname')) + w('| Periodic reboot | %s (every %s min) |' % (F('enablePeriodicReboot'), F('periodicRebootMinutes'))) + w('| HA enabled | %s |' % F('enableHA')) + w() + w('> Note: `avEncryptKey`, `ciaAuthEncKey`, `ldapServerBindPwd` and similar fields contain') + w('> credential/secret values. They have been **omitted** from this report on purpose.') + w() + + w('## 2. Network Interfaces') + w() + w('| If | Name | Type | IP | Mask | Gateway | Zone | Mgmt (HTTP/HTTPS/SNMP/SSH) |') + w('|---|---|---|---|---|---|---|---|') + IF_TYPES = {0: 'LAN', 1: 'WAN', 2: 'DMZ', 3: 'VLAN', 4: 'PPPoE', 5: 'Bridged', 6: 'LAN (X0/X5 style)'} + for i in range(maxi('iface_name') + 1): + name = G('iface_name', i) + itype = G('iface_type', i) + lan_ip, lan_mask = G('iface_lan_ip', i), G('iface_lan_mask', i) + st_ip, st_mask = G('iface_static_ip', i), G('iface_static_mask', i) + gw = G('iface_static_gateway', i) + if gw == '0.0.0.0': + gw = '' + ip = lan_ip if lan_ip not in ('0.0.0.0', '') else st_ip + mask = lan_mask if lan_ip not in ('0.0.0.0', '') else st_mask + zone = G('interface_Zone', i) + mgmt = [] + if G('iface_http_mgmt', i) == '1': mgmt.append('http') + if G('iface_https_mgmt', i) == '1': mgmt.append('https') + if G('iface_snmp_mgmt', i) == '1': mgmt.append('snmp') + if G('iface_ssh_mgmt', i) == '1': mgmt.append('ssh') + if G('iface_ping_mgmt', i) == '1': mgmt.append('ping') + w('| X%d | %s | %s | %s | %s | %s | %s | %s |' % + (i, name, itype, ip, mask, gw or '—', zone or '—', ','.join(mgmt) or '—')) + w() + sec_wan = F('secondary_wan_if') + w('Primary WAN: **X%s** · Secondary WAN: %s' % (F('primary_wan_if'), 'X%s' % sec_wan if sec_wan not in ('4294967295', '') else 'none')) + w() + w('Interface extras:') + w('- X4 (Layer 2 uplink): static %s/%s, gw %s' % (G('iface_static_ip', 4), mask2prefix(G('iface_static_mask', 4)), G('iface_static_gateway', 4))) + w('- Bridged interfaces: %s' % (G('iface_bridged_other', 0) or 'none')) + w() + + w('## 3. Zones') + w() + w('| Zone | Type | Intra-zone comms | CFL profile | Group VPN | SonicPoint | WPA allowed | Max guests |') + w('|---|---|---|---|---|---|---|---|') + for z in zones: + i = z['id'] + w('| %s | %s | %s | %s | %s | %s | %s | %s |' % ( + z['name'], ZONE_TYPES.get(z['type'], z['type']), + 'yes' if G('zoneObjIntraZoneCom', i) == '1' else 'no', + G('zoneObjCfspId', i) or 'Default', + 'yes' if G('zoneObjGroupVpn', i) == '1' else 'no', + G('zoneObjSonicPointProfHandle', i) or '—', + 'yes' if G('zoneObjAllowWPA', i) == '1' else 'no', + G('zoneObjMaxGuests', i) or '—')) + w() + w('WLAN zone: SonicPoint-only = %s, VPN travel restriction = %s, guest services = %s' % ( + G('zoneObjSonicPointOnly', 5), G('zoneObjRestrictVpnTrav', 5), G('zoneObjEnableGuestServices', 5))) + w() + + w('## 4. Schedules') + w() + w('Atomic schedules:') + w() + w('| Name | Days / Time |') + w('|---|---|') + for s in sched_atoms: + w('| %s | %s |' % (s['name'], s['desc'])) + w() + w('Schedule groups:') + w() + w('| Group | Members |') + w('|---|---|') + for g, ms in sched_groups.items(): + w('| %s | %s |' % (g, ', '.join(ms))) + w() + + w('## 5. Authentication & Users') + w() + w('### LDAP') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['ldapServerName', 'ldapServerPort', 'ldapUsrDomain', 'ldapUsrTree', 'ldapUsrGrpAttr', + 'ldapUsrObjClass', 'ldapUsrGrpObjClass', 'ldapProtocolVer', 'ldapUseTls', + 'ldapServerBindName', 'ldapServerBindTree']: + if F(k): + w('| %s | %s |' % (k, F(k))) + w() + w('LDAP user tree entries: %d (%s)' % (len(ldap_users), ', '.join(ldap_users) if ldap_users else 'none')) + w('LDAP group tree entries: %d (%s)' % (len(ldap_groups), ', '.join(ldap_groups) if ldap_groups else 'none')) + w() + w('### RADIUS') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['Rad_prm_IP', 'Rad_prm_port', 'Rad_sec_IP', 'Rad_sec_port', 'Radius_timeout', 'Radius_retries', + 'radiusDfltUserGroup', 'radiusUserGroupMethod']: + v = F(k) + if k.endswith('_secret'): + continue + w('| %s | %s |' % (k, v or '—')) + w() + w('### Local users & groups') + w() + w('| User | Type | Privilege mask | Comment |') + w('|---|---|---|---|') + for u in users: + w('| %s | %s | %s | %s |' % (u['name'], u['type'], u['priv'], u['comment'])) + w() + w('| Group | Type | CFL profile | Privilege mask | Comment |') + w('|---|---|---|---|---|') + for g in user_groups: + w('| %s | %s | %s | %s | %s |' % (g['name'], g['type'], g['cfl'], g['priv'], g['comment'])) + w() + w('User → group membership: ' + (', '.join('%s → %s' % (a, b) for a, b in user_members) or 'none')) + w() + + w('## 6. DHCP') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['enableDHCP', 'dhcp_lease', 'centralDhcpIp', 'dhcp_domainname', 'dhcp_propagateSettingsToLan', + 'dhcp_dns0', 'dhcp_dns1', 'dhcp_dns2', 'dhcp_wins0', 'dhcp_wins1', 'dhcpIpHelper', 'dhcpPersEnable']: + w('| %s | %s |' % (k, F(k) or '—')) + w() + w('DHCP options served:') + w() + w('| Option | Tag | Value |') + w('|---|---|---|') + OPT_NAMES = {1: 'Subnet mask', 3: 'Router', 6: 'DNS servers', 15: 'Domain name', 44: 'WINS', 66: 'TFTP server'} + for i in range(maxi('dhcpOptionObjTagNumber') + 1): + tag = G('dhcpOptionObjTagNumber', i) + w('| dhcpOption%d | %s | %s |' % (i, OPT_NAMES.get(int(tag or 0), tag), G('dhcpOptionObjTagValue', i))) + w() + w('Remote (VPN client) DHCP: spoof=%s, mgmt IP=%s, group-VPN IP=%s, static IP=%s, temp lease=%s' % ( + F('dhcprSpoof'), F('dhcprMgmtIp'), F('dhcprGroupVpnIp'), F('dhcprStaticIp'), F('dhcprTempLease'))) + w() + + w('## 7. DNS') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['dns_use_static_servers', 'dns_server_one', 'dns_server_two', 'dns_server_three', + 'dnsSrvAddr', 'dnsServer2', 'dnsServer3', 'dns_use_static_servers']: + if F(k) and k not in ('dns_use_static_servers',): + w('| %s | %s |' % (k, F(k))) + w('| dns_use_static_servers | %s |' % F('dns_use_static_servers')) + w() + + w('## 8. NAT Policies') + w() + w('| # | Enabled | Source | Dest | Service | Translated src/dst/svc | Src→Dst iface | Comment |') + w('|---|---|---|---|---|---|---|---|') + for n in nats: + w('| %d | %s | %s | %s | %s | %s / %s / %s | %s→%s | %s |' % ( + n['id'], 'yes' if n['Enabled'] == '1' else 'no', + n['OrigSrc'] or 'any', n['OrigDst'] or 'any', n['OrigSvc'] or 'any', + n['TransSrc'] or '—', n['TransDst'] or '—', n['TransSvc'] or '—', + n['SrcIface'], n['DstIface'], n['Comment'] or '—')) + w() + + w('## 9. Policy-Based Routing') + w() + w('| Field | Value |') + w('|---|---|') + for i in range(maxi('pbrObjId') + 1): + if not G('pbrObjId', i): + continue + for k in ['pbrObjId', 'pbrObjSrc', 'pbrObjSvc', 'pbrObjDst', 'pbrObjGw', 'pbrObjIface', 'pbrObjMetric', 'pbrObjPriority']: + v = G(k, i) + w('| %s | %s |' % (k, v or '—')) + w() + + w('## 10. VPN / IPSec Tunnels') + w() + PH1 = {1: 'DES', 2: '3DES', 3: 'AES-128', 4: 'AES-192', 5: 'AES-256'} + PH2A = {1: 'DES', 2: '3DES', 3: 'AES-128', 4: 'AES-256'} + PH2P = {50: 'ESP', 51: 'AH', 0: 'ESP+AH'} + DHGR = {1: '768', 2: '1024', 5: '1536', 14: '2048'} + for i in range(maxi('ipsecName') + 1): + nm = G('ipsecName', i) + if not nm: + continue + w('### %s' % nm) + w() + w('| Field | Value |') + w('|---|---|') + sa_disabled = G('ipsecSaDisabled', i) + w('| Enabled | %s |' % ('no (SA disabled)' if sa_disabled == 'on' else 'yes' if F('ipsecEnable') == 'on' else 'no (IPSec globally off)')) + w('| Gateway | %s |' % (G('ipsecGwAddr', i) or '—')) + w('| Local network | %s |' % (G('ipsecLocalNetwork', i) or 'any/remote clients')) + w('| Remote network | %s |' % (G('ipsecRemoteNetwork', i) or '—')) + w('| Remote clients | %s |' % G('ipsecRemoteClients', i)) + w('| Phase 1 | %s, DH %s, auth %s, lifetime %ss |' % ( + PH1.get(int(G('ipsecPh1CryptAlg', i) or 0), G('ipsecPh1CryptAlg', i)), + DHGR.get(int(G('ipsecP1DHGrp', i) or 0), G('ipsecP1DHGrp', i)), + G('ipsecPh1AuthAlg', i), G('ipsecP1LifeSecs', i))) + w('| Phase 2 | %s, %s, lifetime %ss |' % ( + PH2P.get(int(G('ipsecPh2Protocol', i) or 0), G('ipsecPh2Protocol', i)), + PH2A.get(int(G('ipsecPh2CryptAlg', i) or 0), G('ipsecPh2CryptAlg', i)), + G('ipsecLifeSecs', i))) + w('| Keepalive / DPD | %s / %s |' % (G('ipsecKeepAlive', i), F('vpnDpdInterval'))) + w() + + w('## 11. Access Control Rules (%d rules)' % len(rules)) + w() + ACTION = {0: 'Permit', 1: 'Permit (no NAT)', 2: 'Deny', 4: 'Reject'} + w() + w('| # | Action | Src Zone | Src Net | Dst Zone | Dst Net | Service | User | Time | Log | On | Comment |') + w('|---|---|---|---|---|---|---|---|---|---|---|---|') + for r in rules: + w('| %d | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s |' % ( + r['id'], ACTION.get(int(r['Action'] or 0), r['Action']), + r['SrcZone'] or 'any', r['SrcNet'] or 'any', r['DstZone'] or 'any', + r['DstNet'] or 'any', r['DstSvc'] or 'any', + 'Anyone' if r['Whom'] in ('0', '') else r['Whom'], + r['Time'] or 'always', 'yes' if r['Log'] == '1' else 'no', + 'yes' if r['Enabled'] == '1' else 'no', r['Comment'] or '—')) + w() + np = collections.Counter(ACTION.get(int(r['Action'] or 0), r['Action']) for r in rules) + w('Totals: %s' % ', '.join('%s: %d' % kv for kv in np.most_common())) + w() + + w('## 12. Address Objects (%d + %d FQDN)' % (len(addr_objs), len(fqdn_objs))) + w() + w('### FQDN objects') + w() + w('| Name | Value | Zone |') + w('|---|---|---|') + for n, o in fqdn_objs.items(): + w('| %s | %s | %s |' % (o['name'], o['val'] or '—', o['zone'] or '—')) + w() + w('### IP objects (non-group)') + w() + w('| Name | Zone | Type | Address |') + w('|---|---|---|---|') + for n in addr_order: + o = addr_objs[n] + if o['type'] == 8: + continue + w('| %s | %s | %s | %s |' % (o['name'], o['zone'] or '—', ADDR_TYPES.get(o['type'], o['type']), ipstr(o) or '—')) + w() + w('### Object groups') + w() + w('| Group | Zone | Members |') + w('|---|---|---|') + for n in addr_order: + o = addr_objs[n] + if o['type'] != 8: + continue + members = addr_groups.get(n, []) + w('| %s | %s | %s |' % (o['name'], o['zone'] or '—', ', '.join(members) or '—')) + w() + extra = [g for g in addr_groups if g not in addr_objs] + if extra: + w() + w('Referenced groups not defined as objects: %s' % ', '.join(extra)) + w() + + w('## 13. Service Objects (%d)' % len(svcs)) + w() + w('### Groups of services') + w() + w('| Group | Members |') + w('|---|---|') + for g, ms in svc_groups.items(): + w('| %s | %s |' % (g, ', '.join(ms))) + w() + w('### Individual services') + w() + w('| Name | Protocols / Ports | Notes |') + w('|---|---|---|') + for s in sorted(svcs.values(), key=lambda x: x['id']): + name = s['name'] + if s['type'] == 2 and s['proto'] == 0: + desc = 'combined (TCP+UDP, encoded %d-%d)' % (s['p1'], s['p2']) + elif s['proto'] == 1: + desc = 'ICMP type %d' % s['p1'] + elif s['p1'] == s['p2']: + desc = '%s/%d' % (IPPROTO.get(s['proto'], str(s['proto'])), s['p1']) + else: + desc = '%s %d-%d' % (IPPROTO.get(s['proto'], str(s['proto'])), s['p1'], s['p2']) + notes = [] + if s['mgmt']: + notes.append('mgmt') + if s['props'] & 512: + notes.append('mgmt-flag') + w('| %s | %s | %s |' % (name, desc, ', '.join(notes))) + w() + + w('## 14. Content Filtering & Security Services') + w() + w('### Content Filter Profile (applied to zones)') + w() + w('| Profile | In use | Options mask | Category mask |') + w('|---|---|---|---|') + for i in range(maxi('cfspName') + 1): + w('| %s | %s | %s | %s |' % (G('cfspName', i), G('cfspInUse', i), G('cfspOptionMask', i), G('cfspCatMask', i))) + w() + w('### WebCFS (Web Category Filtering)') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['webcfsSrvAddr', 'webcfsSrvPort', 'webcfsUserName', 'webcfsBlockOnFail', + 'webcfsBlockBlockedSites', 'webcfsLogBlockedSites', 'webcfsUseDRTR', 'webcfsFilterHTTPSSites']: + w('| %s | %s |' % (k, F(k) or '—')) + w() + w('### N2H2 (URL Reputational Filtering)') + w() + for k in ['n2h2SrvAddr', 'n2h2SrvPort', 'n2h2UserName', 'n2h2BlockOnFail', 'n2h2LogBlockedSites']: + w('- %s = %s' % (k, F(k) or '—')) + w() + w('### Web Security (WSE)') + w() + for k in ['wseSrvAddr', 'wseSrvPort', 'wseUserName', 'wseProbeEnabled', 'wseBlockOnFail']: + w('- %s = %s' % (k, F(k) or '—')) + w() + w('### Site Blocking (SBI / keyword & URL block)') + w() + w('- Block on fail: %s, log blocked: %s' % (F('sbi_blockOnlyLog') or '—', 'see raw')) + for i in range(maxi('sbi_urlBlockMask') + 1): + v = G('sbi_urlBlockMask', i) + if v: + w('- urlBlockMask_%d = %s' % (i, v)) + w('- Block ActiveX: %s, Java: %s, Cookies: %s, Custom: %s' % ( + F('sbi_blockActiveX'), F('sbi_blockJava'), F('sbi_blockCookies'), F('sbi_blockCustom') or 'none')) + if F('sbi_blockURLKeywords') in ('on', '1'): + w('- URL keyword blocking: on (no keyword list present in this config)') + if F('TOD_useTOD') == '1': + w('- Time-of-day URL filtering: on, %s:%s to %s:%s, days %s-%s' % ( + F('TOD_startHour'), F('TOD_startMin'), F('TOD_endHour'), F('TOD_endMin'), + F('TOD_startDay'), F('TOD_endDay'))) + w() + w('### RBL (DNS-based block lists)') + w() + for k in ['rblMode', 'rblSpecifyServers', 'rblDnsServer1', 'rblDnsServer2', 'rblDnsServer3']: + w('- %s = %s' % (k, F(k) or '—')) + for i in range(maxi('rblDomainName') + 1): + if G('rblDomainName', i): + w('- domain %s (enabled %s)' % (G('rblDomainName', i), G('rblDomainEnabled', i))) + w() + w('### Antivirus / Anti-Spyware / GAV / IDP') + w() + w('| Engine | Enabled | Notes |') + w('|---|---|---|') + w('| Antivirus | %s | scanning: %s, HTTP: %s, SMTP: %s, FTP: %s |' % ( + F('avEnable'), F('avEnableScanning'), F('avEnableHTTP'), F('avEnableSMTP'), F('avEnableFTP'))) + w('| Anti-spyware | %s | HTTP: %s, SMTP: %s |' % (F('asEnableScanning'), F('asEnableHTTP'), F('asEnableSMTP'))) + w('| GAV | %s | HTTP: %s, SMTP: %s |' % (F('gavEnableScanning'), F('gavEnableHTTP'), F('gavEnableSMTP'))) + w('| IDP (Intrusion Prevention) | %s | signatures DB: %s entries in file |' % (F('isIDPEnabled'), len(idx.get('idpSigId', {})))) + w() + + w('## 15. WLAN / SonicPoint') + w() + SP_AUTH = {0: 'Open', 1: 'WEP', 2: 'WPA-PSK', 3: 'WPA-Enterprise', 4: 'WPA2-PSK', + 5: 'WPA2-Enterprise', 6: 'WPA/WPA2-PSK', 7: 'WPA/WPA2-Enterprise'} + sp_max = maxi('sonicPointObjId') + if sp_max < 0: + w('No SonicPoint objects defined.') + else: + for i in range(sp_max + 1): + nm = G('sonicPointObjId', i) + if not nm: + continue + w('### SonicPoint: %s' % nm) + w() + w('| Field | Value |') + w('|---|---|') + w('| Enabled | %s |' % ('yes' if G('sonicPointEnable', i) == '1' else 'no')) + w('| Interface | X%s |' % (G('sonicPointIfNum', i) or '—')) + w('| MAC | %s |' % (G('sonicPointMac', i) or '—')) + w('| IP / Mask | %s / %s |' % (G('sonicPointIp', i) or '—', G('sonicPointSubnetMask', i) or '—')) + w('| Country | %s |' % (G('sonicPointCountryCode', i) or '—')) + w() + w('| Radio | SSID | Enabled | Radio mode | Channel | Auth | Hide SSID | Max clients | PSK |') + w('|---|---|---|---|---|---|---|---|---|') + for band in ('A', 'G', 'N'): + psk = G('sonicPoint' + band + 'WpaPsk', i) + w('| 802.11%s | %s | %s | %s | %s | %s | %s | %s | %s |' % ( + band, G('sonicPoint' + band + 'Ssid', i) or '—', + 'yes' if G('sonicPoint' + band + 'Enable', i) == '1' else 'no', + G('sonicPoint' + band + 'RadioMode', i) or '—', + G('sonicPoint' + band + 'Channel', i) or 'auto', + SP_AUTH.get(int(G('sonicPoint' + band + 'AuthType', i) or 0), G('sonicPoint' + band + 'AuthType', i)), + 'yes' if G('sonicPoint' + band + 'HideSsid', i) == '1' else 'no', + G('sonicPoint' + band + 'MaxClients', i) or '—', + '*** (set)' if psk else '—')) + w() + + w('## 16. Routing') + w() + w('- Advanced routing (OSPF/RIP) enabled: **%s**' % F('useAdvancedRouting')) + w('- WAN link: %s, LAN link: %s, DMZ link: %s' % (F('wanLinkAbility'), F('lanLinkAbility'), F('dmzLinkAbility'))) + w() + w('### OSPF (per zone)') + w() + w('| Zone if | Mode | Router ID | Hello | Dead | Metric |') + w('|---|---|---|---|---|---|') + for i in range(6): + m = G('ZOspfMode', i) + if m not in ('', '0'): + w('| %s | %s | %s | %s | %s | %s |' % (G('swZOspfIfnum', i), m, G('ZOspfRouterId', i), + G('ZOspfHelloInterval', i), G('ZOspfDeadInterval', i), G('ZOspfIfCost', i))) + w() + w('### RIP (zone + SW)') + w() + w('| Zone if | Mode | Password used |') + w('|---|---|---|') + for i in range(6): + m = G('ZRipMode', i) + if m not in ('', '0'): + w('| %s | %s | %s |' % (G('swZRipIfnum', i), m, 'yes' if G('ZRipUsePassword', i) == '1' else 'no')) + w() + + w('## 17. Logging') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['syslogEnable' , 'syslogServerName', 'syslogServerPort', 'syslogStandbyServerName', + 'syslogFacility', 'logNameResolution', 'logResolutionSpecifyServers', + 'logResolutionServer1', 'logResolutionServer2', 'logResolutionServer3', + 'logSmtpPort', 'smtpServerName', 'mailAuthName', 'prefs_reportsOn']: + v = F(k) + if k == 'mailAuthPassword': + continue + if v: + w('| %s | %s |' % (k, v)) + w() + + w('## 18. System Administration & Password Policy') + w() + w('| Setting | Value |') + w('|---|---|') + for k in ['adminLoginTimeout', 'adminPreemptAction', 'adminPreemptInactivity', + 'loginFailLockoutEnable', 'loginFailLockoutTime', 'loginFailMaxRate', + 'minPasswordLength', 'passwordComplexity', 'passwordUpdtPeriod', 'passwordUniqueNum', + 'userLoginEnforceUnique', 'userInactivity', 'userMaxTime', 'userNamesCaseSensitive', + 'userDisconnectDetectEnable', 'httpsCertName', 'userRedirectToHttp']: + v = F(k) + if v != '': + w('| %s | %s |' % (k, v)) + w() + w('AUP (acceptable use policy): enabled=%s, shown on LAN/WAN/WLAN/VPN/DMZ=%s/%s/%s/%s/%s' % ( + F('aupEnable'), F('userAUPShowLAN'), F('userAUPShowWAN'), F('userAUPShowWLAN'), + F('userAUPShowVPN'), F('userAUPShowDMZ'))) + w() + + w('## 19. Security Hardening Flags') + w() + w('| Setting | Value |') + w('|---|---|') + hard = ['DropFtpBounceAttacks', 'dropSrcRoute', 'strictTcpStateful', 'enforceTcpHandshake', + 'EnforceICMPChkV', 'EnforceIPChkV', 'EnforceUDPChkV', 'IPSpoofChecking', + 'tcpChecksumValidation', 'tcpHeaderFixup', 'tcpPacketTagging', 'randomizeIds', + 'randomizeTcpSeq', 'DecrementTTL', 'clearFragBit', 'sockstressProtection', + 'synFloodBListOn', 'synFloodBListExMgmt', 'synFloodBListExWan', 'synFloodMSS', + 'synFloodPMode', 'synFloodPSACK', 'noPortScanDetection', 'EnforceUDPChkV', + 'EnforceIPChkV', 'EnforceICMPChkV', 'enableStealthMode', 'enableSnapHead', + 'watchdogOn', 'combufSanity', 'bootTimePcapStart', 'dropPktAtDbaseLoad', + 'failedArpCacheEnable', 'oldArpBehavior', 'arpBridge', 'arpTimeout', + 'enablePeriodicReboot', 'enablePeriodicGratArps', 'DecrementTTL'] + seen = set() + for k in hard: + if k in seen: + continue + seen.add(k) + w('| %s | %s |' % (k, F(k) or '—')) + w() + + w('## 20. Other Settings (raw, grouped)') + w() + COVERED_PREFIXES = ['zoneObj', 'addrObj', 'addro_', 'svcObj', 'so_', 'policy', 'natPolicy', + 'schedObj', 'sched_', 'cfsp', 'ipsec', 'pbrObj', 'dhcpOption', 'userObj', + 'userGroupObj', 'guestProfileObj', 'ldapUsrsTree', 'ldapUsrGrpsTree', + 'idpSig', 'uo_', 'if_', 'eth_', 'iface_', 'ha', 'ZOspf', 'ZRip', 'swRip', + 'swZOspf', 'swZRip', 'qos', 'rbl', 'sbi_', 'UnBlockedUrl', + 'applyPwdConstraints', 'ikePreemptSecGw', 'ipsecpeerCertID', 'extension_add', + 'forbiddenURLs_add', 'upgradeKey', 'bridged_iface', 'LRI', 'TOD'] + other = collections.OrderedDict() + covered = set() + for k in list(flat.keys()): + if any(k.startswith(p.rstrip('_')) or k == p.rstrip('_') for p in COVERED_PREFIXES): + continue + if k in covered: + continue + other.setdefault('Misc', []).append(k) + covered.add(k) + + GROUPS = [ + ('Content filter / CFS', ['cfs', 'cf_method', 'cfMinHttpHeaderSize', 'cfTablePageSize', 'cflSubExpNoticeVers']), + ('WebCFS', ['webcfs']), + ('N2H2', ['n2h2']), + ('WSE', ['wse']), + ('SBI / site blocking', ['sbi_']), + ('Antivirus', ['av']), + ('Anti-spyware', ['as']), + ('GAV', ['gav']), + ('IDP', ['idp', 'isIDP', 'isDPIDisabled', 'rfm']), + ('LDAP / auth', ['ldap', 'Rad_', 'Radius', 'radius', 'ciaAuth', 'xauth', 'autoLogn']), + ('SMTP / mail', ['smtp', 'mailAuth', 'logSmtpPort', 'popServerName']), + ('Syslog / logging', ['syslog', 'logPrefs', 'logRes', 'auxSyslog', 'alertCategoryFilterBitmask', 'guiCategoryFilterBitmask', 'eventRateLimitMode', 'maxEventsPerSecondThreshold', 'maxSyslogBytesPerSecond']), + ('WLAN / SonicPoint / RF', ['wlan', 'wlb', 'wseProbe', 'spUpdate', 'spnSupported', 'sonicPoint']), + ('DHCP', ['dhcp', 'dhcpr', 'useDhcps', 'DoTempLease', 'doTempLease', 'iph']), + ('DNS', ['dns', 'rbl']), + ('Routing / HA', ['electionDelay', 'routeHolddown', 'enableHA', 'ha', 'enableSync', 'OutboundLb', 'RatioLb', 'useAdvancedRouting']), + ('VPN / IPSec global', ['ike', 'ipsec', 'vpnDpd', 'vpnKeepAlive', 'L2TP', 'L2tps', 'L2tpsUserGroup', 'L2tpsLocalIpPool', 'xauth', 'NatTraversal', 'spnSupported', 'h323', 'sip', 'voipEndpoint', 'ras', 'enableRTSPSupport', 'enableSipSupport', 'scanMsnStreams', 'scanOracleStreams', 'sdpSupportedVer']), + ('Multicast', ['enableMcast', 'enableMulticastFiltering', 'igmp', 'mcast']), + ('SNMP / admin', ['snmp', 'admin', 'httpsCertName', 'useRemoteMgtName', 'useInternational', 'onlineHelpUrl', 'guiStartingPage', 'firmwareNotice', 'hfwauFw', 'cflSubExpNoticeVers', 'isEvalUpgradeViewed', 'isWarningPageViewed', 'isUseCustomerDownloadUrl', 'isCustomerDownloadUrlSp11N', 'showGuestLoginStatusWin', 'showLoginStatusWin', 'statusMsgOnly', 'prefs_', 'tooltip', 'sfid', 'userIV', 'passwordUniqueNum', 'local_registerCode', 'registrationCode', 'cryptoLevelTag', 'serial', 'hardwareId', 'firewallId', 'firewallName', 'fwinfoDomain', 'timezone', 'buildNum', 'currentFirmwareVers', 'oldFirmwareVers', 'updateRtVersion', 'checksum']), + ('Firewall behavior', ['afPolicy', 'policeDmz', 'proxyIncludesDmz', 'proxyPcMacOnWan', 'pcMacAddressForWan', 'bypassFailedProxy', 'webProxy', 'consistentNat', 'dfltTrafficBW', 'wanBwMgmt', 'policeDmz', 'cacheTimeout', 'cnntThreshold', 'enableCnntLmt', 'blockPartialMsg', 'useRuleWizard', 'useSaWizard', 'UsePIForPassThru', 'AllowStaticIPToVA', 'Vpn1ArmedEnable', 'VpnNoTcpMssAdjust', 'preserveIkePorts', 'ipsecDHInteropMode', 'ipsecDnsTtlSaClean', 'ipsecFragmentAfterEsp', 'ipsecIgnoreDfBit', 'ipsecSendTriggerPacket', 'ipsecTryAllKeepAlive', 'ipsecAllowMcast', 'ipsecAllowNetBIOS', 'ipsec_apply', 'ftpAlgSvcHandle', 'ftpTransformations', 'nbt_enable', 'DHCP_CONFLICT_DETECT', 'DecrementTTL', 'flushOnRtEnable', 'Mcast', 'efFindShortNametag', 'hostTagSearchEnable', 'isNakWrongNetwork', 'isRemoteGw', 'globalMgt', 'gms', 'paramGmsMode', 'sgmsLoginOverrideAdmin', 'standby_', 'MAFiA', 'fipsMode', 'OcspResponderUrl', 'EnableOcspCheck', 'certblock']), + ('WLAN guest / AUP', ['guestProfile', 'userAUP', 'aup', 'pendingGuestCleanupFreq', 'wlanGuestRedirectInterval', 'wlanVpnDownload', 'wlanVids', 'wlanWids', 'wlanVapBssid', 'spSetup']), + ] + buckets = collections.OrderedDict() + for title, prefixes in GROUPS: + buckets[title] = [] + for p in prefixes: + for k in list(other.get('Misc', [])): + if k.startswith(p): + buckets[title].append(k) + other['Misc'].remove(k) + for k in other.get('Misc', []): + buckets.setdefault('Everything else', []).append(k) + + for title, keys in buckets.items(): + if not keys: + continue + w('### %s' % title) + w() + w('| Key | Value |') + w('|---|---|') + for k in sorted(keys): + v = F(k) + if re.search(r'(?i)(psk|wepkey|secret|_pwd|password|encryptkey|bindpwd)', k): + continue + w('| %s | %s |' % (k, v or '—')) + w() + + return '\n'.join(L) + '\n' + + +# ============================================================== step 3: md -> html + +CSS = """ +:root { + --bg: #0f1117; + --panel: #161a23; + --panel-2: #1c212e; + --text: #d7dce5; + --muted: #8b93a7; + --accent: #4da3ff; + --accent-2: #7ee0a3; + --border: #2a3142; + --code-bg: #11141c; + --row-alt: #191e29; + --quote-bg: #1a2233; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.65; +} +.page { + max-width: 1080px; + margin: 0 auto; + padding: 40px 28px 80px; +} +header.doc-header { + background: linear-gradient(135deg, #1b2334 0%, #141824 100%); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 32px; + margin-bottom: 32px; +} +header.doc-header h1 { + margin: 0 0 8px; + font-size: 30px; + letter-spacing: 0.2px; + color: #fff; +} +header.doc-header .subtitle { + color: var(--muted); + font-size: 14px; +} +h1, h2, h3, h4, h5, h6 { + color: #fff; + line-height: 1.3; + margin: 1.6em 0 0.6em; + scroll-margin-top: 24px; +} +h1 { font-size: 28px; } +h2 { + font-size: 22px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +h3 { font-size: 18px; color: var(--accent-2); } +h4 { font-size: 16px; } +p { margin: 0.7em 0; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code { + font-family: "SF Mono", SFMono-Regular, ui-monospace, "Cascadia Mono", + Consolas, Menlo, monospace; + font-size: 13px; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1px 6px; + color: #ffd9a0; +} +pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + overflow-x: auto; +} +pre code { + background: none; + border: none; + padding: 0; + color: var(--text); + font-size: 13px; +} +table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + font-size: 13.5px; + background: var(--panel); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 0 0 1px var(--border); +} +th, td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +th { + background: var(--panel-2); + color: #fff; + font-weight: 600; + white-space: nowrap; +} +tbody tr:nth-child(even) { background: var(--row-alt); } +tbody tr:hover { background: #22304a; } +tr:last-child td { border-bottom: none; } +blockquote { + margin: 1em 0; + padding: 10px 16px; + background: var(--quote-bg); + border-left: 4px solid var(--accent); + border-radius: 0 8px 8px 0; + color: var(--text); +} +blockquote p { margin: 0.3em 0; } +ul, ol { padding-left: 24px; margin: 0.7em 0; } +li { margin: 0.25em 0; } +hr { + border: none; + border-top: 1px solid var(--border); + margin: 2em 0; +} +nav.toc { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px 28px; + margin-bottom: 36px; +} +nav.toc .toc-title { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--muted); + margin: 0 0 10px; +} +nav.toc ul { list-style: none; padding: 0; margin: 0; columns: 2; column-gap: 32px; } +nav.toc li { margin: 4px 0; break-inside: avoid; font-size: 14px; } +nav.toc li.toc-l3 { padding-left: 18px; font-size: 13px; color: var(--muted); } +nav.toc a { color: var(--text); } +nav.toc a:hover { color: var(--accent); text-decoration: none; } +footer.doc-footer { + margin-top: 48px; + padding-top: 16px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 12.5px; + text-align: center; +} +@media print { + :root { + --bg: #fff; --panel: #fff; --panel-2: #f0f2f5; + --text: #222; --muted: #666; --border: #ccc; + --code-bg: #f5f5f5; --row-alt: #f7f8fa; --quote-bg: #f0f6ff; + } + body { font-size: 12px; } + .page { padding: 0; max-width: none; } + header.doc-header, nav.toc { box-shadow: none; } + pre, table { box-shadow: none; } +} +@media (max-width: 720px) { + nav.toc ul { columns: 1; } + .page { padding: 24px 14px 60px; } +} +""" + +# ---------------------------------------------------------------- inline ---- + +def inline(text: str) -> str: + """Render inline markdown (code, bold, italic, links, strikethrough).""" + text = html.escape(text, quote=False) + + # protect inline code spans first + parts: list[str] = [] + pos = 0 + for m in re.finditer(r"`([^`]+)`", text): + parts.append(text[pos:m.start()]) + parts.append("%s" % m.group(1)) + pos = m.end() + parts.append(text[pos:]) + text = "".join(parts) + + # links: [label](url) + text = re.sub( + r"\[([^\]]+)\]\(([^)\s]+)\)", + lambda m: '%s' % (m.group(2), m.group(1)), + text, + ) + # bold + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + # italic + text = re.sub(r"(?\1", text) + text = re.sub(r"(?\1", text) + # strikethrough + text = re.sub(r"~~(.+?)~~", r"\1", text) + return text + +# ------------------------------------------------------------- headings ----- + +def slugify(text: str) -> str: + s = re.sub(r"[`*_]", "", text) + s = re.sub(r"[^\w\s-]", "", s).strip().lower() + return re.sub(r"[\s]+", "-", s) + + +def split_row(line: str) -> list[str]: + line = line.strip() + if line.startswith("|"): + line = line[1:] + if line.endswith("|"): + line = line[:-1] + return [c.strip() for c in line.split("|")] + + +def is_table_sep(line: str) -> bool: + return bool(re.fullmatch(r"\s*\|?\s*:?-{2,}:?(?:\s*\|\s*:?-{2,}:?)*\|?\s*", line)) + + +# ------------------------------------------------------------- parse -------- + +def parse(markdown: str) -> tuple[str, list[tuple[int, str, str]]]: + """Return (body_html, toc_items[(level, slug, text)]).""" + lines = markdown.replace("\r\n", "\n").split("\n") + out: list[str] = [] + toc: list[tuple[int, str, str]] = [] + i = 0 + n = len(lines) + + while i < n: + line = lines[i] + + # fenced code block + fence = re.match(r"^```(\w*)\s*$", line) + if fence: + lang = fence.group(1) + code = [] + i += 1 + while i < n and not re.match(r"^```\s*$", lines[i]): + code.append(lines[i]) + i += 1 + i += 1 # closing fence + cls = ' class="language-%s"' % html.escape(lang) if lang else "" + out.append( + "
%s
" + % (cls, html.escape("\n".join(code), quote=False)) + ) + continue + + # heading + h = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", line) + if h: + level = len(h.group(1)) + text = h.group(2) + slug = slugify(text) + toc.append((level, slug, html.unescape(inline(text)))) + out.append('%s' % (level, slug, inline(text), level)) + i += 1 + continue + + # horizontal rule + if re.fullmatch(r"\s*([-*_])\s*(\1\s*){2,}", line): + out.append("
") + i += 1 + continue + + # blockquote + if line.lstrip().startswith(">"): + quote = [] + while i < n and lines[i].lstrip().startswith(">"): + quote.append(re.sub(r"^\s*>\s?", "", lines[i])) + i += 1 + inner = parse("\n".join(quote))[0] + out.append("
%s
" % inner) + continue + + # table + if ( + line.lstrip().startswith("|") + and i + 1 < n + and lines[i + 1].lstrip().startswith("|") + and is_table_sep(lines[i + 1]) + ): + header = split_row(line) + i += 2 + rows = [] + while i < n and lines[i].lstrip().startswith("|"): + rows.append(split_row(lines[i])) + i += 1 + thead = "".join( + "%s" % inline(c) for c in header + ) + tbody_rows = [] + for row in rows: + tds = "".join( + "%s" % inline(c) for c in (row + [""] * len(header))[: len(header)] + ) + tbody_rows.append("%s" % tds) + out.append( + '%s%s
' + % (thead, "".join(tbody_rows)) + ) + continue + + # unordered list + if re.match(r"^\s*[-*+]\s+", line): + items = [] + while i < n and re.match(r"^\s*[-*+]\s+", lines[i]): + items.append(re.sub(r"^\s*[-*+]\s+", "", lines[i])) + i += 1 + out.append( + "" % "".join("
  • %s
  • " % inline(x) for x in items) + ) + continue + + # ordered list + if re.match(r"^\s*\d+[.)]\s+", line): + items = [] + while i < n and re.match(r"^\s*\d+[.)]\s+", lines[i]): + items.append(re.sub(r"^\s*\d+[.)]\s+", "", lines[i])) + i += 1 + out.append( + "
      %s
    " % "".join("
  • %s
  • " % inline(x) for x in items) + ) + continue + + # blank line + if not line.strip(): + i += 1 + continue + + # paragraph: gather consecutive plain lines + para = [line] + i += 1 + while i < n and lines[i].strip() and not ( + re.match(r"^(#{1,6})\s", lines[i]) + or lines[i].lstrip().startswith(("|", ">")) + or re.match(r"^\s*[-*+]\s+", lines[i]) + or re.match(r"^\s*\d+[.)]\s+", lines[i]) + or re.match(r"^```", lines[i]) + or re.fullmatch(r"\s*([-*_])\s*(\1\s*){2,}", lines[i]) + ): + para.append(lines[i]) + i += 1 + out.append("

    %s

    " % "
    ".join(inline(p) for p in para)) + + return "\n".join(out), toc + + +# --------------------------------------------------------------- render ----- + +def render(markdown: str, source_name: str) -> str: + body, toc = parse(markdown) + + title_m = re.search(r"^#\s+(.+)$", markdown, re.M) + title = html.escape(title_m.group(1).strip()) if title_m else source_name + + toc_html = "" + if toc: + items = [] + for level, slug, text in toc: + cls = ' class="toc-l%d"' % level if level >= 3 else "" + items.append('%s' % (cls, slug, text)) + toc_html = '' % "".join(items) + + return """ + + + + +{title} + + + +
    +
    +

    {title}

    +
    Generated from {source}
    +
    +{toc} +
    +{body} +
    + +
    + + +""".format( + title=title, + css=CSS, + source=html.escape(source_name, quote=False), + toc=toc_html, + body=body, + ) + + +# -------------------------------------------------------------------- main -- + +def convert(exp_path: Path) -> Path: + txt_text = exp_to_txt(exp_path) + md_text = gen_report(txt_text, str(exp_path)) + html_doc = render(md_text, str(exp_path)) + html_path = exp_path.with_suffix(".html") + html_path.write_text(html_doc, encoding="utf-8") + return html_path + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Convert a SonicWall .exp file to a styled HTML report.") + ap.add_argument("exp", help="path to the .exp file") + args = ap.parse_args() + + exp_path = Path(args.exp) + if not exp_path.is_file(): + print("error: no such file: %s" % exp_path, file=sys.stderr) + return 1 + + html_path = convert(exp_path) + print("wrote %s" % html_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/process_exp_gui.py b/process_exp_gui.py new file mode 100644 index 0000000..cae01f7 --- /dev/null +++ b/process_exp_gui.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Graphical front-end for process_exp.py. + +Pick a SonicWall .exp file, click Run, and get a styled HTML report. + +Usage: python3 process_exp_gui.py [file.exp] +""" + +import queue +import subprocess +import sys +import threading +import tkinter as tk +import webbrowser +from pathlib import Path +from tkinter import filedialog, ttk + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import process_exp + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("SonicWall .exp to HTML converter") + self.geometry("600x360") + self.minsize(480, 300) + + self.exp_path = None + self.busy = False + self.q = queue.Queue() + + pad = {"padx": 10, "pady": 6} + + top = ttk.Frame(self) + top.pack(fill="x", **pad) + ttk.Label(top, text="EXP file:").pack(side="left") + self.file_var = tk.StringVar(value="(none selected)") + self.file_label = ttk.Label( + top, textvariable=self.file_var, foreground="#666", anchor="w") + self.file_label.pack(side="left", fill="x", expand=True, padx=(8, 8)) + ttk.Button(top, text="Browse...", command=self.choose_file).pack(side="right") + + self.run_btn = ttk.Button(self, text="Run conversion", + command=self.run, state="disabled") + self.run_btn.pack(fill="x", **pad) + + self.status_var = tk.StringVar(value="Ready. Pick a .exp file and press Run.") + ttk.Label(self, textvariable=self.status_var, anchor="w", + foreground="#333").pack(fill="x", padx=12) + + self.open_html_btn = ttk.Button(self, text="Open HTML in browser", + command=self.open_html, state="disabled") + self.open_html_btn.pack(anchor="e", padx=12, pady=(0, 8)) + + self.log = tk.Text(self, height=10, state="disabled", wrap="word", + background="#f7f8fa") + self.log.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + self.after(100, self.poll_queue) + + # ------------------------------------------------------------ file pick + + def choose_file(self): + if self.busy: + return + path = filedialog.askopenfilename( + title="Choose a SonicWall .exp file", + filetypes=[("EXP files", "*.exp"), ("All files", "*.*")], + ) + if path: + self.set_file(Path(path)) + + def set_file(self, path: Path): + if not path.is_file(): + self.append_log("error: no such file: %s" % path) + return + self.exp_path = path + self.file_var.set(str(path)) + self.run_btn.configure(state="normal") + self.open_html_btn.configure(state="disabled") + self.status_var.set("Ready to convert %s" % path.name) + + # ------------------------------------------------------------ conversion + + def run(self): + if self.busy or self.exp_path is None: + return + self.busy = True + self.run_btn.configure(state="disabled") + self.status_var.set("Converting %s ..." % self.exp_path.name) + threading.Thread(target=self._worker, daemon=True).start() + + def _worker(self): + try: + html_path = process_exp.convert(self.exp_path) + self.q.put(("ok", html_path, None)) + except Exception as exc: + self.q.put(("err", None, exc)) + + def poll_queue(self): + try: + while True: + kind, html_path, exc = self.q.get_nowait() + if kind == "ok": + self.open_html_btn.configure(state="normal") + self.open_html_btn.configure( + command=lambda p=html_path: webbrowser.open(str(p))) + self.status_var.set("Done: %s" % html_path) + self.append_log("wrote %s" % html_path) + else: + self.status_var.set("Failed: %s" % exc) + self.append_log("error: %s" % exc) + self.busy = False + self.run_btn.configure(state="normal") + except queue.Empty: + pass + self.after(100, self.poll_queue) + + # -------------------------------------------------------------- helpers + + def open_html(self): + if self.exp_path is not None: + webbrowser.open(str(self.exp_path.with_suffix(".html"))) + + def append_log(self, text: str): + self.log.configure(state="normal") + self.log.insert("end", text + "\n") + self.log.see("end") + self.log.configure(state="disabled") + + +def main() -> int: + app = App() + if len(sys.argv) > 1: + app.after(100, lambda: app.set_file(Path(sys.argv[1]))) + app.mainloop() + return 0 + + +if __name__ == "__main__": + sys.exit(main())