Skip to content
This repository was archived by the owner on Aug 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ <h1 class="app-title">Firegen</h1>
<textarea id="bash-import-textarea" class="modal-textarea" rows="15" placeholder="Paste firewall-cmd commands here...&#10;&#10;firewall-cmd --permanent --zone=public --add-service=http&#10;firewall-cmd --permanent --zone=public --add-port=8080/tcp"></textarea>
<div id="bash-import-status" class="modal-status" hidden></div>
<div class="modal-actions">
<label class="toggle-label">
<input type="checkbox" id="bash-import-merge"> Merge
</label>
<button id="bash-import-submit" class="btn btn-sm">Import</button>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion js/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
175 changes: 172 additions & 3 deletions js/import-export.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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();
});
Expand Down