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);