1226 lines
46 KiB
Python
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} · 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())
|