Cybersecurity-Projects/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v

324 lines
9.5 KiB
V

/*
©AngelaMos | 2026
generator.v
Hardened ruleset generation and cross-format export
generate_hardened builds a complete firewall ruleset from scratch using
CIS-aligned defaults: default-deny INPUT/FORWARD, loopback acceptance,
conntrack early in the chain, RFC 1918 anti-spoofing, rate-limited ICMP
and SSH, and a LOG rule before the final drop. Services are resolved
through config.service_ports so DNS gets dual tcp/udp rules and NTP gets
udp-only. export_ruleset converts an existing parsed Ruleset into the
opposite format by serializing each Rule through rule_to_iptables or
rule_to_nftables, preserving table and chain structure including
multi-table layouts (filter + nat).
Key exports:
generate_hardened - Builds a hardened ruleset string for given services and format
export_ruleset - Converts a Ruleset to iptables or nftables string output
Connects to:
config/config.v - reads service_ports, private_ranges, rate-limit strings, log prefixes
models/models.v - imports Rule, Ruleset, RuleSource
main.v - called from cmd_harden and cmd_export
*/
module generator
import src.config
import src.models { Rule, RuleSource, Ruleset }
pub fn generate_hardened(services []string, iface string, fmt RuleSource) string {
return match fmt {
.iptables { generate_iptables_hardened(services, iface) }
.nftables { generate_nftables_hardened(services, iface) }
}
}
fn generate_iptables_hardened(services []string, iface string) string {
mut lines := []string{}
lines << '*filter'
lines << ':INPUT DROP [0:0]'
lines << ':FORWARD DROP [0:0]'
lines << ':OUTPUT ACCEPT [0:0]'
lines << ''
lines << '-A INPUT -i lo -j ACCEPT'
lines << '-A OUTPUT -o lo -j ACCEPT'
lines << ''
lines << '-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT'
lines << '-A INPUT -m conntrack --ctstate INVALID -j DROP'
lines << ''
for cidr in config.private_ranges {
lines << '-A INPUT -i ${iface} -s ${cidr} -j DROP'
}
lines << ''
lines << '-A INPUT -p icmp --icmp-type echo-request -m limit --limit ${config.icmp_rate_limit} --limit-burst ${config.icmp_rate_burst} -j ACCEPT'
lines << '-A INPUT -p icmp --icmp-type echo-reply -j ACCEPT'
lines << '-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT'
lines << '-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT'
lines << ''
for svc in services {
port := config.service_ports[svc] or { continue }
if svc == 'ssh' {
lines << '-A INPUT -p tcp --dport ${port} -m conntrack --ctstate NEW -m limit --limit ${config.ssh_rate_limit} --limit-burst ${config.ssh_rate_burst} -j ACCEPT'
} else if svc == 'dns' {
lines << '-A INPUT -p tcp --dport ${port} -j ACCEPT'
lines << '-A INPUT -p udp --dport ${port} -j ACCEPT'
} else if svc == 'ntp' {
lines << '-A INPUT -p udp --dport ${port} -j ACCEPT'
} else {
lines << '-A INPUT -p tcp --dport ${port} -j ACCEPT'
}
}
lines << ''
lines << '-A INPUT -m limit --limit 5/minute -j LOG --log-prefix "${config.log_prefix_dropped}"'
lines << '-A INPUT -j DROP'
lines << ''
lines << 'COMMIT'
return lines.join('\n')
}
fn generate_nftables_hardened(services []string, iface string) string {
mut lines := []string{}
lines << 'table inet filter {'
lines << ' chain input {'
lines << ' type filter hook input priority 0; policy drop;'
lines << ''
lines << ' iifname "lo" accept'
lines << ''
lines << ' ct state established,related accept'
lines << ' ct state invalid drop'
lines << ''
for cidr in config.private_ranges {
lines << ' iifname "${iface}" ip saddr ${cidr} drop'
}
lines << ''
lines << ' icmp type echo-request limit rate ${config.icmp_rate_limit} burst ${config.icmp_rate_burst} packets accept'
lines << ' icmp type { echo-reply, destination-unreachable, time-exceeded } accept'
lines << ''
for svc in services {
port := config.service_ports[svc] or { continue }
if svc == 'ssh' {
lines << ' tcp dport ${port} ct state new limit rate ${config.ssh_rate_limit} burst ${config.ssh_rate_burst} packets accept'
} else if svc == 'dns' {
lines << ' tcp dport ${port} accept'
lines << ' udp dport ${port} accept'
} else if svc == 'ntp' {
lines << ' udp dport ${port} accept'
} else {
lines << ' tcp dport ${port} accept'
}
}
lines << ''
lines << ' limit rate 5/minute log prefix "${config.log_prefix_dropped}"'
lines << ' drop'
lines << ' }'
lines << ''
lines << ' chain forward {'
lines << ' type filter hook forward priority 0; policy drop;'
lines << ' }'
lines << ''
lines << ' chain output {'
lines << ' type filter hook output priority 0; policy accept;'
lines << ' }'
lines << '}'
return lines.join('\n')
}
pub fn export_ruleset(rs Ruleset, fmt RuleSource) string {
return match fmt {
.iptables { export_as_iptables(rs) }
.nftables { export_as_nftables(rs) }
}
}
fn export_as_iptables(rs Ruleset) string {
mut lines := []string{}
mut tables_seen := map[string]bool{}
for rule in rs.rules {
tbl := rule.table.str()
if tbl !in tables_seen {
if tables_seen.len > 0 {
lines << 'COMMIT'
lines << ''
}
lines << '*${tbl}'
tables_seen[tbl] = true
for chain_name, policy in rs.policies {
lines << ':${chain_name} ${policy.str()} [0:0]'
}
}
lines << rule_to_iptables(rule)
}
if tables_seen.len > 0 {
lines << 'COMMIT'
}
return lines.join('\n')
}
fn export_as_nftables(rs Ruleset) string {
mut lines := []string{}
mut table_chains := map[string]map[string][]int{}
for i, rule in rs.rules {
tbl := rule.table.str()
if tbl !in table_chains {
table_chains[tbl] = map[string][]int{}
}
table_chains[tbl][rule.chain] << i
}
for tbl, chains in table_chains {
lines << 'table inet ${tbl} {'
for chain_name, indices in chains {
policy_str := if p := rs.policies[chain_name] {
p.str().to_lower()
} else {
'accept'
}
chain_lower := chain_name.to_lower()
lines << ' chain ${chain_lower} {'
hook := match chain_name {
'INPUT' { 'input' }
'OUTPUT' { 'output' }
'FORWARD' { 'forward' }
'PREROUTING' { 'prerouting' }
'POSTROUTING' { 'postrouting' }
else { '' }
}
if hook.len > 0 {
lines << ' type filter hook ${hook} priority 0; policy ${policy_str};'
}
for idx in indices {
lines << ' ${rule_to_nftables(rs.rules[idx])}'
}
lines << ' }'
}
lines << '}'
}
return lines.join('\n')
}
fn rule_to_iptables(r Rule) string {
mut parts := []string{}
parts << '-A ${r.chain}'
if r.criteria.protocol != .all {
parts << '-p ${r.criteria.protocol.str()}'
}
if src := r.criteria.source {
if src.negated {
parts << '! -s ${src.address}/${src.cidr}'
} else {
parts << '-s ${src.address}/${src.cidr}'
}
}
if dst := r.criteria.destination {
if dst.negated {
parts << '! -d ${dst.address}/${dst.cidr}'
} else {
parts << '-d ${dst.address}/${dst.cidr}'
}
}
if iface := r.criteria.in_iface {
parts << '-i ${iface}'
}
if oface := r.criteria.out_iface {
parts << '-o ${oface}'
}
if !r.criteria.states.is_empty() {
mut state_strs := []string{}
if r.criteria.states.has(.new_conn) {
state_strs << 'NEW'
}
if r.criteria.states.has(.established) {
state_strs << 'ESTABLISHED'
}
if r.criteria.states.has(.related) {
state_strs << 'RELATED'
}
if r.criteria.states.has(.invalid) {
state_strs << 'INVALID'
}
parts << '-m conntrack --ctstate ${state_strs.join(',')}'
}
if r.criteria.dst_ports.len == 1 {
parts << '--dport ${r.criteria.dst_ports[0].str()}'
} else if r.criteria.dst_ports.len > 1 {
port_strs := r.criteria.dst_ports.map(it.str())
parts << '-m multiport --dports ${port_strs.join(',')}'
}
if rate := r.criteria.limit_rate {
parts << '-m limit --limit ${rate}'
if burst := r.criteria.limit_burst {
parts << '--limit-burst ${burst}'
}
}
parts << '-j ${r.action.str()}'
if r.target_args.len > 0 {
parts << r.target_args
}
return parts.join(' ')
}
fn rule_to_nftables(r Rule) string {
mut parts := []string{}
if iface := r.criteria.in_iface {
parts << 'iifname "${iface}"'
}
if oface := r.criteria.out_iface {
parts << 'oifname "${oface}"'
}
if src := r.criteria.source {
prefix := if src.negated { '!= ' } else { '' }
parts << 'ip saddr ${prefix}${src.address}/${src.cidr}'
}
if dst := r.criteria.destination {
prefix := if dst.negated { '!= ' } else { '' }
parts << 'ip daddr ${prefix}${dst.address}/${dst.cidr}'
}
if !r.criteria.states.is_empty() {
mut state_strs := []string{}
if r.criteria.states.has(.new_conn) {
state_strs << 'new'
}
if r.criteria.states.has(.established) {
state_strs << 'established'
}
if r.criteria.states.has(.related) {
state_strs << 'related'
}
if r.criteria.states.has(.invalid) {
state_strs << 'invalid'
}
parts << 'ct state ${state_strs.join(',')}'
}
if r.criteria.protocol != .all {
proto := r.criteria.protocol.str()
if r.criteria.dst_ports.len == 1 {
parts << '${proto} dport ${r.criteria.dst_ports[0].start}'
} else if r.criteria.dst_ports.len > 1 {
port_strs := r.criteria.dst_ports.map('${it.start}')
parts << '${proto} dport { ${port_strs.join(', ')} }'
} else {
parts << 'ip protocol ${proto}'
}
}
if rate := r.criteria.limit_rate {
parts << 'limit rate ${rate}'
}
action_str := match r.action {
.accept { 'accept' }
.drop { 'drop' }
.reject { 'reject' }
.log { 'log' }
.masquerade { 'masquerade' }
.return_action { 'return' }
else { r.action.str().to_lower() }
}
if r.action == .log && r.target_args.len > 0 {
parts << 'log ${r.target_args}'
} else {
parts << action_str
}
return parts.join(' ')
}