Files
2026-10-03 13:27:13 -04:00

1226 lines
46 KiB
Python

#!/usr/bin/env python3
"""By Didier Aeschimann 2026-10-03
Takes a SonicWall .exp config file and, next to it, produces:
<name>.html styled standalone HTML version of the report
Usage: python3 process_exp.py <file.exp>
"""
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("<code>%s</code>" % m.group(1))
pos = m.end()
parts.append(text[pos:])
text = "".join(parts)
# links: [label](url)
text = re.sub(
r"\[([^\]]+)\]\(([^)\s]+)\)",
lambda m: '<a href="%s">%s</a>' % (m.group(2), m.group(1)),
text,
)
# bold
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text = re.sub(r"__(.+?)__", r"<strong>\1</strong>", text)
# italic
text = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", r"<em>\1</em>", text)
text = re.sub(r"(?<!_)_([^_]+?)_(?!\w)", r"<em>\1</em>", text)
# strikethrough
text = re.sub(r"~~(.+?)~~", r"<del>\1</del>", 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(
"<pre><code%s>%s</code></pre>"
% (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('<h%d id="%s">%s</h%d>' % (level, slug, inline(text), level))
i += 1
continue
# horizontal rule
if re.fullmatch(r"\s*([-*_])\s*(\1\s*){2,}", line):
out.append("<hr>")
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("<blockquote>%s</blockquote>" % 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(
"<th>%s</th>" % inline(c) for c in header
)
tbody_rows = []
for row in rows:
tds = "".join(
"<td>%s</td>" % inline(c) for c in (row + [""] * len(header))[: len(header)]
)
tbody_rows.append("<tr>%s</tr>" % tds)
out.append(
'<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>'
% (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(
"<ul>%s</ul>" % "".join("<li>%s</li>" % 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(
"<ol>%s</ol>" % "".join("<li>%s</li>" % 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("<p>%s</p>" % "<br>".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('<li%s><a href="#%s">%s</a></li>' % (cls, slug, text))
toc_html = '<nav class="toc"><p class="toc-title">Contents</p><ul>%s</ul></nav>' % "".join(items)
return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
{css}
</style>
</head>
<body>
<div class="page">
<header class="doc-header">
<h1>{title}</h1>
<div class="subtitle">Generated from <code>{source}</code></div>
</header>
{toc}
<main>
{body}
</main>
<footer class="doc-footer">
{title} &middot; generated with process_exp.py
</footer>
</div>
</body>
</html>
""".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())