From 21df1b57a102f64460cf607d662020f33c808dc2 Mon Sep 17 00:00:00 2001 From: tommy gingras Date: Fri, 27 Feb 2026 22:37:29 -0500 Subject: [PATCH] fix: bash import shell variables, zone inference, and dual-command toggle Reverse parser: reject shell variables ($VAR/${VAR}), skip non-modifying commands gracefully, infer default zone "public" for zone-less commands, broaden reload matching. Generator: add runtime mode that emits paired permanent+runtime commands without --reload. UI: Runtime toggle checkbox with localStorage persistence. Co-Authored-By: Claude Sonnet 4.6 --- css/styles.css | 20 ++++++++ index.html | 3 ++ js/app.mjs | 15 +++++- js/generator.mjs | 114 +++++++++++++++++++++--------------------- js/reverse-parser.mjs | 43 +++++++++++++--- 5 files changed, 129 insertions(+), 66 deletions(-) diff --git a/css/styles.css b/css/styles.css index fceacfa..e8434bd 100644 --- a/css/styles.css +++ b/css/styles.css @@ -324,6 +324,26 @@ body { border-block-end-color: var(--text); } +/* Toggle */ + +.toggle-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 450; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.toggle-label input[type="checkbox"] { + accent-color: var(--text); + margin: 0; + cursor: pointer; +} + /* Output */ #output-container { diff --git a/index.html b/index.html index 5231824..eed3b34 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,9 @@

Firegen

+
diff --git a/js/app.mjs b/js/app.mjs index 30cec32..c02900b 100644 --- a/js/app.mjs +++ b/js/app.mjs @@ -19,8 +19,12 @@ function init() { const btnExport = document.getElementById("btn-export"); const btnPasteCommands = document.getElementById("btn-paste-commands"); const btnCopy = document.getElementById("btn-copy"); + const optRuntime = document.getElementById("opt-runtime"); const fileImport = document.getElementById("file-import"); + // Restore runtime toggle preference + optRuntime.checked = localStorage.getItem("firegen-runtime") === "true"; + // Error bar toggle errorToggle.addEventListener("click", () => { const expanded = errorDetails.hidden; @@ -90,6 +94,12 @@ function init() { exportYaml(yaml); }); + // Runtime toggle + optRuntime.addEventListener("change", () => { + localStorage.setItem("firegen-runtime", optRuntime.checked); + handleYamlChange(editor.getValue()); + }); + // Copy active tab btnCopy.addEventListener("click", () => { const content = getActiveTabContent(); @@ -158,8 +168,9 @@ function init() { } // Generate commands - const applyLines = generateApply(config); - const removeLines = generateRemove(config); + const genOpts = { runtime: optRuntime.checked }; + const applyLines = generateApply(config, genOpts); + const removeLines = generateRemove(config, genOpts); setTabContent("apply", warningHeader + applyLines.join("\n")); setTabContent("remove", warningHeader + removeLines.join("\n")); diff --git a/js/generator.mjs b/js/generator.mjs index 217a660..a407c5d 100644 --- a/js/generator.mjs +++ b/js/generator.mjs @@ -83,98 +83,98 @@ function buildRichRule(rule) { } /** - * Generate zone commands for a single zone. - * @param {string} zoneName - * @param {object} zone - zone config - * @param {string} op - "add" or "remove" - * @returns {string[]} commands + * Emit a command, optionally adding a runtime (non-permanent) duplicate. + * @param {string[]} commands - output array + * @param {string} cmd - the permanent command + * @param {boolean} runtime - whether to also emit the runtime variant + * @param {boolean} permanentOnly - if true, skip runtime even when runtime=true */ -function generateZoneCommands(zoneName, zone, op) { +function emitCommand(commands, cmd, runtime, permanentOnly) { + commands.push(cmd); + if (runtime && !permanentOnly) { + commands.push(cmd.replace(" --permanent", "")); + } +} + +/** + * Generate zone commands with optional runtime duplication. + */ +function generateZoneCommandsDual(zoneName, zone, op, runtime) { const commands = []; const flag = `--${op}`; const perm = "--permanent"; const z = `--zone=${zoneName}`; - // Target (only for add, no remove equivalent) + // Target (only for add, no remove equivalent — permanent-only) if (op === "add" && zone.target !== undefined) { - commands.push(`${CMD} ${perm} ${z} --set-target=${zone.target}`); + emitCommand(commands, `${CMD} ${perm} ${z} --set-target=${zone.target}`, runtime, true); } - // Interfaces if (Array.isArray(zone.interfaces)) { for (const iface of zone.interfaces) { const val = typeof iface === "object" ? iface.value : iface; - commands.push(`${CMD} ${perm} ${z} ${flag}-interface=${val}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-interface=${val}`, runtime, false); } } - // Sources if (Array.isArray(zone.sources)) { for (const src of zone.sources) { const val = typeof src === "object" ? src.value : src; - commands.push(`${CMD} ${perm} ${z} ${flag}-source=${val}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-source=${val}`, runtime, false); } } - // Services if (Array.isArray(zone.services)) { for (const svc of zone.services) { const val = typeof svc === "object" ? svc.value : svc; - commands.push(`${CMD} ${perm} ${z} ${flag}-service=${val}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-service=${val}`, runtime, false); } } - // Ports if (Array.isArray(zone.ports)) { for (const p of zone.ports) { if (typeof p === "object" && p.port !== undefined) { const proto = p.protocol || "tcp"; - commands.push(`${CMD} ${perm} ${z} ${flag}-port=${p.port}/${proto}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-port=${p.port}/${proto}`, runtime, false); } else { - commands.push(`${CMD} ${perm} ${z} ${flag}-port=${p}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-port=${p}`, runtime, false); } } } - // Protocols if (Array.isArray(zone.protocols)) { for (const proto of zone.protocols) { const val = typeof proto === "object" ? proto.value : proto; - commands.push(`${CMD} ${perm} ${z} ${flag}-protocol=${val}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-protocol=${val}`, runtime, false); } } - // Source ports if (Array.isArray(zone.source_ports)) { for (const sp of zone.source_ports) { if (typeof sp === "object" && sp.port !== undefined) { const proto = sp.protocol || "tcp"; - commands.push(`${CMD} ${perm} ${z} ${flag}-source-port=${sp.port}/${proto}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-source-port=${sp.port}/${proto}`, runtime, false); } else { - commands.push(`${CMD} ${perm} ${z} ${flag}-source-port=${sp}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-source-port=${sp}`, runtime, false); } } } - // Rich rules if (Array.isArray(zone.rich_rules)) { for (const rule of zone.rich_rules) { const richStr = buildRichRule(rule); - commands.push(`${CMD} ${perm} ${z} ${flag}-rich-rule='${richStr}'`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-rich-rule='${richStr}'`, runtime, false); } } - // Forward (IP forwarding) if (zone.forward === true) { - commands.push(`${CMD} ${perm} ${z} ${flag}-forward`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-forward`, runtime, false); } - // Masquerade if (zone.masquerade === true) { - commands.push(`${CMD} ${perm} ${z} ${flag}-masquerade`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-masquerade`, runtime, false); } - // Forward ports if (Array.isArray(zone.forward_ports)) { for (const fp of zone.forward_ports) { let val = `port=${fp.port}:proto=${fp.protocol || "tcp"}`; @@ -184,61 +184,53 @@ function generateZoneCommands(zoneName, zone, op) { if (fp.to_addr) { val += `:toaddr=${fp.to_addr}`; } - commands.push(`${CMD} ${perm} ${z} ${flag}-forward-port=${val}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-forward-port=${val}`, runtime, false); } } - // ICMP blocks if (Array.isArray(zone.icmp_blocks)) { for (const icmp of zone.icmp_blocks) { const val = typeof icmp === "object" ? icmp.value : icmp; - commands.push(`${CMD} ${perm} ${z} ${flag}-icmp-block=${val}`); + emitCommand(commands, `${CMD} ${perm} ${z} ${flag}-icmp-block=${val}`, runtime, false); } } - // ICMP block inversion (only for add) if (op === "add" && zone.icmp_block_inversion === true) { - commands.push(`${CMD} ${perm} ${z} --add-icmp-block-inversion`); + emitCommand(commands, `${CMD} ${perm} ${z} --add-icmp-block-inversion`, runtime, false); } else if (op === "remove" && zone.icmp_block_inversion === true) { - commands.push(`${CMD} ${perm} ${z} --remove-icmp-block-inversion`); + emitCommand(commands, `${CMD} ${perm} ${z} --remove-icmp-block-inversion`, runtime, false); } return commands; } /** - * Generate direct rule commands. - * @param {object} direct - direct config block - * @param {string} op - "add" or "remove" - * @returns {string[]} + * Generate direct rule commands with optional runtime duplication. */ -function generateDirectCommands(direct, op) { +function generateDirectCommandsDual(direct, op, runtime) { const commands = []; const perm = "--permanent"; - // Chains (deduplicate — rule_groups can produce duplicates) if (Array.isArray(direct.chains)) { const seen = new Set(); for (const c of direct.chains) { const key = `${c.ipv} ${c.table} ${c.chain}`; if (!seen.has(key)) { seen.add(key); - commands.push(`${CMD} ${perm} --direct --${op}-chain ${c.ipv} ${c.table} ${c.chain}`); + emitCommand(commands, `${CMD} ${perm} --direct --${op}-chain ${c.ipv} ${c.table} ${c.chain}`, runtime, false); } } } - // Rules if (Array.isArray(direct.rules)) { for (const r of direct.rules) { - commands.push(`${CMD} ${perm} --direct --${op}-rule ${r.ipv} ${r.table} ${r.chain} ${r.priority} ${r.args}`); + emitCommand(commands, `${CMD} ${perm} --direct --${op}-rule ${r.ipv} ${r.table} ${r.chain} ${r.priority} ${r.args}`, runtime, false); } } - // Passthroughs if (Array.isArray(direct.passthroughs)) { for (const pt of direct.passthroughs) { - commands.push(`${CMD} ${perm} --direct --${op}-passthrough ${pt.ipv} ${pt.args}`); + emitCommand(commands, `${CMD} ${perm} --direct --${op}-passthrough ${pt.ipv} ${pt.args}`, runtime, false); } } @@ -247,61 +239,71 @@ function generateDirectCommands(direct, op) { /** * Generate the Apply script: add all rules then reload. + * @param {object} config + * @param {{ runtime?: boolean }} [options] */ -export function generateApply(config) { +export function generateApply(config, options) { if (!config) { return []; } + const runtime = options?.runtime ?? false; const lines = ["#!/bin/bash", "# Generated by Firegen", "# Apply script: adds all configured rules", ""]; if (config.zones) { for (const [zoneName, zone] of Object.entries(config.zones)) { lines.push(`# Zone: ${zoneName}`); - lines.push(...generateZoneCommands(zoneName, zone, "add")); + lines.push(...generateZoneCommandsDual(zoneName, zone, "add", runtime)); lines.push(""); } } if (config.direct) { lines.push("# Direct rules"); - lines.push(...generateDirectCommands(config.direct, "add")); + lines.push(...generateDirectCommandsDual(config.direct, "add", runtime)); lines.push(""); } - lines.push("# Reload firewalld"); - lines.push(`${CMD} --reload`); + if (!runtime) { + lines.push("# Reload firewalld"); + lines.push(`${CMD} --reload`); + } return lines; } /** * Generate the Remove script: remove all configured rules then reload. + * @param {object} config + * @param {{ runtime?: boolean }} [options] */ -export function generateRemove(config) { +export function generateRemove(config, options) { if (!config) { return []; } + const runtime = options?.runtime ?? false; const lines = ["#!/bin/bash", "# Generated by Firegen", "# Remove script: removes all configured rules", ""]; // Remove direct rules first (reverse order from apply) if (config.direct) { lines.push("# Remove direct rules"); - lines.push(...generateDirectCommands(config.direct, "remove")); + lines.push(...generateDirectCommandsDual(config.direct, "remove", runtime)); lines.push(""); } if (config.zones) { for (const [zoneName, zone] of Object.entries(config.zones)) { lines.push(`# Zone: ${zoneName}`); - lines.push(...generateZoneCommands(zoneName, zone, "remove")); + lines.push(...generateZoneCommandsDual(zoneName, zone, "remove", runtime)); lines.push(""); } } - lines.push("# Reload firewalld"); - lines.push(`${CMD} --reload`); + if (!runtime) { + lines.push("# Reload firewalld"); + lines.push(`${CMD} --reload`); + } return lines; } diff --git a/js/reverse-parser.mjs b/js/reverse-parser.mjs index dd3c039..0a81eb1 100644 --- a/js/reverse-parser.mjs +++ b/js/reverse-parser.mjs @@ -651,12 +651,6 @@ export function parseCommands(text) { continue; } - // Skip reload commands - if (trimmed === "firewall-cmd --reload" || trimmed === "sudo firewall-cmd --reload") { - skipped.push(`Line ${i + 1}: ${trimmed}`); - continue; - } - // Strip sudo prefix let line = trimmed; if (line.startsWith("sudo ")) { @@ -669,12 +663,36 @@ export function parseCommands(text) { continue; } + // Reject lines containing shell variables + if (/\$(?:\w|\{|\()/.test(line)) { + errors.push(`Line ${i + 1}: shell variables not supported (expand variables before importing)`); + continue; + } + const tokens = tokenizeLine(line); const { flags } = parseFlags(tokens); // Remove --permanent (irrelevant for parsing) delete flags.permanent; + // Skip reload commands (any firewall-cmd with --reload or --complete-reload) + if (flags["reload"] || flags["complete-reload"]) { + skipped.push(`Line ${i + 1}: reload command`); + continue; + } + + // Skip non-modifying / query commands gracefully + const skipFlags = ["runtime-to-permanent", "check-config", "state", "version"]; + const skipPrefixes = ["get-", "list-", "query-"]; + const flagKeys = Object.keys(flags); + const isSkippable = flagKeys.some( + (k) => skipFlags.includes(k) || skipPrefixes.some((p) => k.startsWith(p)) + ); + if (isSkippable) { + skipped.push(`Line ${i + 1}: non-modifying command (${trimmed})`); + continue; + } + // Direct commands if (flags.direct) { if (!processDirectCommand(config, flags, tokens)) { @@ -683,8 +701,17 @@ export function parseCommands(text) { continue; } - // Zone commands - const zoneName = flags.zone; + // Zone commands — infer "public" when no --zone and command has modifying flags + let zoneName = flags.zone; + if (!zoneName) { + const hasModifying = flagKeys.some( + (k) => k.startsWith("add-") || k.startsWith("remove-") || k === "set-target" + ); + if (hasModifying) { + zoneName = "public"; + } + } + if (zoneName) { delete flags.zone; const zone = ensureZone(config, zoneName);