From 94e400a4020e6096e264624b05656bc9cd23e756 Mon Sep 17 00:00:00 2001 From: tommy gingras Date: Fri, 27 Feb 2026 23:14:30 -0500 Subject: [PATCH] feat: add merge mode for bash import When the Merge checkbox is checked in the Paste Commands modal, imported commands are deep-merged with the existing editor YAML instead of replacing it. Zones are merged per-zone with array deduplication. Direct rules are merged with chain/rule/passthrough dedup. Variables and rule_groups from existing config are preserved. Co-Authored-By: Claude Sonnet 4.6 --- index.html | 3 + js/app.mjs | 4 +- js/import-export.mjs | 175 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index eed3b34..18d2808 100644 --- a/index.html +++ b/index.html @@ -86,6 +86,9 @@

Firegen

diff --git a/js/app.mjs b/js/app.mjs index c02900b..6bfd6d4 100644 --- a/js/app.mjs +++ b/js/app.mjs @@ -82,10 +82,12 @@ function init() { btnImport: document.getElementById("bash-import-submit"), btnCancel: document.getElementById("bash-import-cancel"), status: document.getElementById("bash-import-status"), + mergeCheckbox: document.getElementById("bash-import-merge"), }, (yaml) => { editor.setValue(yaml); - } + }, + () => editor.getValue() ); // Export diff --git a/js/import-export.mjs b/js/import-export.mjs index 52af539..6be95f6 100644 --- a/js/import-export.mjs +++ b/js/import-export.mjs @@ -2,6 +2,156 @@ import { parseCommands } from "./reverse-parser.mjs"; +/** + * Deduplicate an array using a key function. + * Preserves order, keeps first occurrence. + */ +function dedup(arr, keyFn) { + const seen = new Set(); + return arr.filter((item) => { + const key = keyFn(item); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** + * Key function for port objects ({ port, protocol }) or plain strings. + */ +function portKey(p) { + if (typeof p === "object" && p !== null) { + return `${p.port}/${p.protocol || "tcp"}`; + } + return String(p); +} + +/** + * Key function for forward-port objects. + */ +function forwardPortKey(fp) { + return `${fp.port}/${fp.protocol || "tcp"}/${fp.to_port || ""}/${fp.to_addr || ""}`; +} + +/** + * Key function for string values or objects with a .value property. + */ +function stringKey(v) { + return typeof v === "object" && v !== null ? String(v.value) : String(v); +} + +/** + * Serialize a rich rule object to a comparable string. + */ +function richRuleKey(rule) { + return JSON.stringify(rule); +} + +/** + * Merge two zone objects. Arrays are concatenated and deduplicated. + * Scalar values (target, forward, masquerade) from incoming overwrite existing. + */ +function mergeZone(existing, incoming) { + const merged = { ...existing }; + + // Scalars: incoming wins + if (incoming.target !== undefined) merged.target = incoming.target; + if (incoming.forward !== undefined) merged.forward = incoming.forward; + if (incoming.masquerade !== undefined) merged.masquerade = incoming.masquerade; + if (incoming.icmp_block_inversion !== undefined) merged.icmp_block_inversion = incoming.icmp_block_inversion; + + // Array fields with their dedup key functions + const arrayFields = [ + ["interfaces", stringKey], + ["sources", stringKey], + ["services", stringKey], + ["ports", portKey], + ["protocols", stringKey], + ["source_ports", portKey], + ["rich_rules", richRuleKey], + ["forward_ports", forwardPortKey], + ["icmp_blocks", stringKey], + ]; + + for (const [field, keyFn] of arrayFields) { + const a = existing[field] || []; + const b = incoming[field] || []; + if (a.length > 0 || b.length > 0) { + merged[field] = dedup([...a, ...b], keyFn); + } + } + + return merged; +} + +/** + * Merge two direct blocks. Arrays are concatenated and deduplicated. + */ +function mergeDirect(existing, incoming) { + const merged = {}; + + const chainKey = (c) => `${c.ipv}|${c.table}|${c.chain}`; + const ruleKey = (r) => `${r.ipv}|${r.table}|${r.chain}|${r.priority}|${r.args}`; + const ptKey = (p) => `${p.ipv}|${p.args}`; + + const eChains = existing.chains || []; + const iChains = incoming.chains || []; + if (eChains.length > 0 || iChains.length > 0) { + merged.chains = dedup([...eChains, ...iChains], chainKey); + } + + const eRules = existing.rules || []; + const iRules = incoming.rules || []; + if (eRules.length > 0 || iRules.length > 0) { + merged.rules = dedup([...eRules, ...iRules], ruleKey); + } + + const ePt = existing.passthroughs || []; + const iPt = incoming.passthroughs || []; + if (ePt.length > 0 || iPt.length > 0) { + merged.passthroughs = dedup([...ePt, ...iPt], ptKey); + } + + // Preserve rule_groups from existing (import never produces them) + if (existing.rule_groups) { + merged.rule_groups = existing.rule_groups; + } + + return merged; +} + +/** + * Deep-merge two firewalld config objects. + * Zones are merged per-zone. Direct blocks are merged. + * Top-level keys from existing that aren't in incoming are preserved (e.g., variables). + */ +export function mergeConfigs(existing, incoming) { + const merged = { ...existing }; + + // Merge zones + if (incoming.zones) { + if (!merged.zones) merged.zones = {}; + for (const [name, zone] of Object.entries(incoming.zones)) { + if (merged.zones[name]) { + merged.zones[name] = mergeZone(merged.zones[name], zone); + } else { + merged.zones[name] = zone; + } + } + } + + // Merge direct + if (incoming.direct) { + if (merged.direct) { + merged.direct = mergeDirect(merged.direct, incoming.direct); + } else { + merged.direct = incoming.direct; + } + } + + return merged; +} + /** * Download a string as a .yaml file. * @param {string} content - YAML string to export @@ -53,10 +203,12 @@ export function setupImport(fileInput, onLoad) { * @param {HTMLButtonElement} elements.btnImport - the import/submit button * @param {HTMLButtonElement} elements.btnCancel - the cancel/close button * @param {HTMLElement} elements.status - status message area + * @param {HTMLInputElement} elements.mergeCheckbox - merge toggle checkbox * @param {function} onImport - callback(yamlString) when import succeeds + * @param {function} getEditorYaml - returns current editor YAML string (for merge mode) */ -export function setupBashImport(elements, onImport) { - const { btnOpen, modal, textarea, btnImport, btnCancel, status } = elements; +export function setupBashImport(elements, onImport, getEditorYaml) { + const { btnOpen, modal, textarea, btnImport, btnCancel, status, mergeCheckbox } = elements; function openModal() { textarea.value = ""; @@ -116,7 +268,24 @@ export function setupBashImport(elements, onImport) { return; } - const yaml = jsyaml.dump(config, { lineWidth: -1, noRefs: true }); + let finalConfig = config; + + // Merge mode: combine with existing editor content + if (mergeCheckbox && mergeCheckbox.checked && getEditorYaml) { + const existingYaml = getEditorYaml(); + if (existingYaml && existingYaml.trim()) { + try { + const existing = jsyaml.load(existingYaml); + if (existing && typeof existing === "object" && !Array.isArray(existing)) { + finalConfig = mergeConfigs(existing, config); + } + } catch { + // Existing YAML is invalid — fall through to replace mode + } + } + } + + const yaml = jsyaml.dump(finalConfig, { lineWidth: -1, noRefs: true }); onImport(yaml); closeModal(); });