diff --git a/CHANGELOG.md b/CHANGELOG.md
index b63b104..5e41a7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.1.0] - 2026-02-19
+
+### Added
+
+- Bash-to-YAML reverse import: paste `firewall-cmd` commands and convert them to YAML configuration
+- Reverse parser supporting all zone commands, direct rules, rich rules, forward ports, and ICMP blocks
+- "Paste Commands" button in editor toolbar with modal overlay for pasting shell scripts
+- Handles both `--add-*` and `--remove-*` command variants
+- Skips comments, shebang lines, blank lines, and reload commands
+- Strips `sudo` prefix automatically
+
## [1.0.0] - 2026-02-16
### Added
@@ -27,5 +38,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Responsive layout for desktop and mobile
- Docker deployment with nginx
-[Unreleased]: https://github.com/studiowebux/firegen/compare/v1.0.0...HEAD
+[Unreleased]: https://github.com/studiowebux/firegen/compare/v1.1.0...HEAD
+[1.1.0]: https://github.com/studiowebux/firegen/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/studiowebux/firegen/releases/tag/v1.0.0
diff --git a/css/styles.css b/css/styles.css
index 90e5561..fceacfa 100644
--- a/css/styles.css
+++ b/css/styles.css
@@ -207,6 +207,46 @@ body {
background: var(--surface);
}
+/* Error gutter and line markers */
+
+.error-gutter {
+ width: 0.5rem;
+}
+
+.gutter-error,
+.gutter-warning {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ margin: 0.55rem auto 0;
+}
+
+.gutter-error {
+ background: var(--error);
+}
+
+.gutter-warning {
+ background: #d97706;
+}
+
+[data-theme="dark"] .gutter-warning {
+ background: #f59e0b;
+}
+
+.cm-error-line {
+ background: var(--error-bg) !important;
+}
+
+.cm-warning-line {
+ background: #fffbeb !important;
+}
+
+[data-theme="dark"] .cm-warning-line {
+ background: #1a1500 !important;
+}
+
+/* Error bar */
+
.error-bar {
background: var(--error-bg);
color: var(--error);
@@ -364,6 +404,91 @@ li.CodeMirror-hint-active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
+/* Modal */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(2px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+}
+
+.modal-overlay[hidden] {
+ display: none;
+}
+
+.modal-dialog {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ width: min(40rem, 90vw);
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
+}
+
+[data-theme="dark"] .modal-dialog {
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-block-end: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.modal-title {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--text);
+}
+
+.modal-close {
+ border-color: transparent;
+}
+
+.modal-textarea {
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ background: var(--bg-editor);
+ color: var(--text);
+ border: none;
+ border-block-end: 1px solid var(--border);
+ padding: 0.75rem 1rem;
+ resize: vertical;
+ outline: none;
+ flex: 1;
+ min-height: 12rem;
+}
+
+.modal-textarea::placeholder {
+ color: var(--text-tertiary);
+}
+
+.modal-status {
+ padding: 0.375rem 1rem;
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ color: var(--text-secondary);
+ border-block-end: 1px solid var(--border);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding: 0.625rem 1rem;
+ gap: 0.375rem;
+}
+
/* Responsive */
@media (max-width: 768px) {
diff --git a/index.html b/index.html
index ecd65ea..5231824 100644
--- a/index.html
+++ b/index.html
@@ -1,10 +1,11 @@
-
+
Firegen
+
@@ -34,6 +35,7 @@ Firegen
+
@@ -70,6 +72,22 @@ Firegen
+
+
diff --git a/js/app.mjs b/js/app.mjs
index 304d624..30cec32 100644
--- a/js/app.mjs
+++ b/js/app.mjs
@@ -4,7 +4,7 @@ import { initEditor } from "./editor.mjs";
import { parseConfig } from "./parser.mjs";
import { generateApply, generateRemove } from "./generator.mjs";
import { initTabs, setTabContent, getActiveTabContent, setOutputTheme } from "./tabs.mjs";
-import { exportYaml, setupImport } from "./import-export.mjs";
+import { exportYaml, setupImport, setupBashImport } from "./import-export.mjs";
import { SAMPLE_YAML } from "./sample.mjs";
function init() {
@@ -17,6 +17,7 @@ function init() {
const btnExample = document.getElementById("btn-example");
const btnImport = document.getElementById("btn-import");
const btnExport = document.getElementById("btn-export");
+ const btnPasteCommands = document.getElementById("btn-paste-commands");
const btnCopy = document.getElementById("btn-copy");
const fileImport = document.getElementById("file-import");
@@ -43,12 +44,13 @@ function init() {
// Process the initial sample
handleYamlChange(SAMPLE_YAML);
- // Theme toggle
+ // Theme toggle with persistence
themeToggle.addEventListener("click", () => {
const html = document.documentElement;
const isDark = html.getAttribute("data-theme") === "dark";
const newTheme = isDark ? "light" : "dark";
html.setAttribute("data-theme", newTheme);
+ localStorage.setItem("firegen-theme", newTheme);
editor.setTheme(newTheme === "dark");
setOutputTheme(newTheme === "dark");
});
@@ -67,6 +69,21 @@ function init() {
editor.setValue(content);
});
+ // Bash import
+ setupBashImport(
+ {
+ btnOpen: btnPasteCommands,
+ modal: document.getElementById("bash-import-modal"),
+ textarea: document.getElementById("bash-import-textarea"),
+ btnImport: document.getElementById("bash-import-submit"),
+ btnCancel: document.getElementById("bash-import-cancel"),
+ status: document.getElementById("bash-import-status"),
+ },
+ (yaml) => {
+ editor.setValue(yaml);
+ }
+ );
+
// Export
btnExport.addEventListener("click", () => {
const yaml = editor.getValue();
@@ -87,11 +104,12 @@ function init() {
/**
* Show errors/warnings in the collapsible error bar.
+ * Accepts structured objects with .message property.
*/
function showErrorBar(messages, isError) {
const count = messages.length;
const label = isError ? "error" : "warning";
- const first = messages[0];
+ const first = messages[0].message;
if (count === 1) {
errorText.textContent = first;
@@ -101,7 +119,7 @@ function init() {
errorToggle.hidden = false;
}
- errorDetails.textContent = messages.join("\n");
+ errorDetails.textContent = messages.map((m) => m.message).join("\n");
errorDetails.hidden = true;
errorToggle.setAttribute("aria-expanded", "false");
errorBar.hidden = false;
@@ -109,11 +127,19 @@ function init() {
/**
* Handle YAML editor content changes.
- * Parse, expand templates, generate commands, update output tabs.
+ * Parse, expand templates, generate commands, update output tabs and markers.
*/
function handleYamlChange(yamlString) {
+ editor.clearMarkers();
+
const { config, errors, warnings } = parseConfig(yamlString);
+ // Collect markers from errors and warnings with known line numbers
+ const markers = [...errors, ...warnings].filter((m) => m.line !== null);
+ if (markers.length > 0) {
+ editor.setMarkers(markers);
+ }
+
// Display errors
if (errors.length > 0) {
showErrorBar(errors, true);
@@ -125,7 +151,7 @@ function init() {
// Display warnings
let warningHeader = "";
if (warnings.length > 0) {
- warningHeader = warnings.map((w) => `# WARNING: ${w}`).join("\n") + "\n\n";
+ warningHeader = warnings.map((w) => `# WARNING: ${w.message}`).join("\n") + "\n\n";
showErrorBar(warnings, false);
} else {
errorBar.hidden = true;
diff --git a/js/editor.mjs b/js/editor.mjs
index 42635f0..9ed980d 100644
--- a/js/editor.mjs
+++ b/js/editor.mjs
@@ -28,7 +28,8 @@ export function initEditor(container, { value = "", onChange = null, debounceMs
tabSize: 2,
indentWithTabs: false,
indentUnit: 2,
- placeholder: "# Enter your firewalld YAML configuration here...",
+ gutters: ["CodeMirror-linenumbers", "error-gutter"],
+ placeholder: "# Enter your firegen YAML configuration here...",
extraKeys: {
"Ctrl-Space": "autocomplete",
// Tab inserts spaces instead of tab character
@@ -79,6 +80,9 @@ export function initEditor(container, { value = "", onChange = null, debounceMs
}
});
+ // Track lines with classes so clearMarkers can remove them
+ let markedLines = [];
+
return {
getValue() {
return editorInstance.getValue();
@@ -92,5 +96,26 @@ export function initEditor(container, { value = "", onChange = null, debounceMs
refresh() {
editorInstance.refresh();
},
+ /** Set gutter markers and line highlights for errors/warnings. */
+ setMarkers(markers) {
+ for (const { line, severity } of markers) {
+ const dot = document.createElement("div");
+ dot.className = severity === "error" ? "gutter-error" : "gutter-warning";
+ dot.title = markers.filter((m) => m.line === line).map((m) => m.message).join("\n");
+ editorInstance.setGutterMarker(line, "error-gutter", dot);
+
+ const lineClass = severity === "error" ? "cm-error-line" : "cm-warning-line";
+ editorInstance.addLineClass(line, "wrap", lineClass);
+ markedLines.push({ line, lineClass });
+ }
+ },
+ /** Remove all gutter markers and line highlights. */
+ clearMarkers() {
+ editorInstance.clearGutter("error-gutter");
+ for (const { line, lineClass } of markedLines) {
+ editorInstance.removeLineClass(line, "wrap", lineClass);
+ }
+ markedLines = [];
+ },
};
}
diff --git a/js/import-export.mjs b/js/import-export.mjs
index 97e62aa..52af539 100644
--- a/js/import-export.mjs
+++ b/js/import-export.mjs
@@ -1,5 +1,7 @@
// Pattern: Command — import/export actions encapsulated as functions
+import { parseCommands } from "./reverse-parser.mjs";
+
/**
* Download a string as a .yaml file.
* @param {string} content - YAML string to export
@@ -41,3 +43,81 @@ export function setupImport(fileInput, onLoad) {
fileInput.value = "";
});
}
+
+/**
+ * Set up the bash import modal flow.
+ * @param {object} elements - DOM elements for the modal
+ * @param {HTMLButtonElement} elements.btnOpen - button that opens the modal
+ * @param {HTMLElement} elements.modal - the modal overlay
+ * @param {HTMLTextAreaElement} elements.textarea - the paste textarea
+ * @param {HTMLButtonElement} elements.btnImport - the import/submit button
+ * @param {HTMLButtonElement} elements.btnCancel - the cancel/close button
+ * @param {HTMLElement} elements.status - status message area
+ * @param {function} onImport - callback(yamlString) when import succeeds
+ */
+export function setupBashImport(elements, onImport) {
+ const { btnOpen, modal, textarea, btnImport, btnCancel, status } = elements;
+
+ function openModal() {
+ textarea.value = "";
+ status.hidden = true;
+ status.textContent = "";
+ modal.hidden = false;
+ textarea.focus();
+ }
+
+ function closeModal() {
+ modal.hidden = true;
+ }
+
+ btnOpen.addEventListener("click", openModal);
+ btnCancel.addEventListener("click", closeModal);
+
+ // Close on backdrop click
+ modal.addEventListener("click", (e) => {
+ if (e.target === modal) {
+ closeModal();
+ }
+ });
+
+ // Close on Escape
+ modal.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ closeModal();
+ }
+ });
+
+ btnImport.addEventListener("click", () => {
+ const text = textarea.value.trim();
+ if (!text) {
+ return;
+ }
+
+ const { config, errors, skipped } = parseCommands(text);
+
+ // Show status with skipped/error counts
+ const parts = [];
+ if (skipped.length > 0) {
+ parts.push(`${skipped.length} skipped`);
+ }
+ if (errors.length > 0) {
+ parts.push(`${errors.length} error${errors.length > 1 ? "s" : ""}`);
+ }
+
+ if (parts.length > 0) {
+ status.textContent = parts.join(", ") + (errors.length > 0 ? ": " + errors.join("; ") : "");
+ status.hidden = false;
+ }
+
+ // Only import if we got something
+ if (Object.keys(config).length === 0) {
+ status.textContent = "No valid firewall-cmd commands found.";
+ status.hidden = false;
+ return;
+ }
+
+ const yaml = jsyaml.dump(config, { lineWidth: -1, noRefs: true });
+ onImport(yaml);
+ closeModal();
+ });
+}
diff --git a/js/parser.mjs b/js/parser.mjs
index 7f1cd35..3603170 100644
--- a/js/parser.mjs
+++ b/js/parser.mjs
@@ -8,6 +8,129 @@ import {
VALID_RULE_GROUP_RULE_KEYS, VALID_TARGETS,
} from "./schema.mjs";
+/**
+ * Find the 0-based line number of a key in raw YAML text.
+ * Searches for `key:` (mapping) or `- key` (sequence item) patterns.
+ * Uses optional parentKey to narrow scope to the correct section.
+ * Returns 0-based line number or null if not found.
+ */
+function findKeyLine(yamlString, key, parentKey) {
+ const lines = yamlString.split("\n");
+
+ let searchStart = 0;
+ let parentIndent = -1;
+
+ // If parentKey provided, find it first and search below it
+ if (parentKey) {
+ for (let i = 0; i < lines.length; i++) {
+ const trimmed = lines[i].trimStart();
+ if (trimmed.startsWith(`${parentKey}:`) || trimmed.startsWith(`${parentKey} :`)) {
+ searchStart = i + 1;
+ parentIndent = lines[i].length - trimmed.length;
+ break;
+ }
+ }
+ }
+
+ const keyPattern = new RegExp(`^(\\s*(-\\s+)?)(${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})\\s*:`);
+
+ for (let i = searchStart; i < lines.length; i++) {
+ const match = lines[i].match(keyPattern);
+ if (match) {
+ // If scoped by parent, ensure this line is indented deeper
+ if (parentKey && parentIndent >= 0) {
+ const lineIndent = match[1].replace(/-\s+$/, "").length;
+ if (lineIndent <= parentIndent) break; // Left parent scope
+ if (lineIndent > parentIndent) return i;
+ } else {
+ return i;
+ }
+ }
+ // If scoped by parent, stop if we reach another key at same/lesser indent
+ if (parentKey && parentIndent >= 0 && i > searchStart) {
+ const trimmed = lines[i].trimStart();
+ if (trimmed.length > 0 && !trimmed.startsWith("#")) {
+ const lineIndent = lines[i].length - trimmed.length;
+ if (lineIndent <= parentIndent && trimmed.includes(":")) break;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Adjust js-yaml syntax error line to point at the actual cause.
+ *
+ * js-yaml reports the detection point, not where the mistake is:
+ * - Missing colon: mark points to the NEXT line (unexpected indentation)
+ * - Missing quote: mark points past EOF (stream ended inside literal)
+ *
+ * This heuristic corrects the line for common cases.
+ */
+function adjustSyntaxErrorLine(line, reason, yamlString) {
+ if (line === null || !reason) return line;
+
+ const lines = yamlString.split("\n");
+ const lastLine = lines.length - 1;
+
+ // Missing quote: error detected at EOF. Find the line with the unclosed quote.
+ // Patterns: "double quoted scalar", "single quoted scalar"
+ if (reason.includes("quoted scalar")) {
+ const quoteChar = reason.includes("double") ? '"' : "'";
+ for (let i = lastLine; i >= 0; i--) {
+ const count = lines[i].split(quoteChar).length - 1;
+ if (count % 2 !== 0) return i;
+ }
+ }
+
+ // Colon / mapping errors — covers missing colon AND unclosed quotes that
+ // masquerade as colon errors (js-yaml consumes lines into the string,
+ // then reports "colon is missed" or "implicit mapping" at a later line).
+ if (
+ reason.includes("document separator") ||
+ reason.includes("bad indentation") ||
+ reason.includes("implicit key") ||
+ reason.includes("implicit mapping") ||
+ reason.includes("colon is missed")
+ ) {
+ // First check: unclosed quote before the error line (masked quote error)
+ const searchTo = Math.min(line, lastLine);
+ for (let i = searchTo; i >= 0; i--) {
+ for (const q of ['"', "'"]) {
+ const count = lines[i].split(q).length - 1;
+ if (count % 2 !== 0) return i;
+ }
+ }
+
+ // No unclosed quote — walk backwards to find a bare key missing its colon
+ for (let i = searchTo - 1; i >= 0; i--) {
+ const trimmed = lines[i].trim();
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
+ if (!trimmed.includes(":")) return i;
+ break;
+ }
+ // Fallback: one line back if in range
+ if (line > 0) return line - 1;
+ }
+
+ return line;
+}
+
+/**
+ * Create a structured error object.
+ */
+function err(message, line) {
+ return { message, line: line ?? null, severity: "error" };
+}
+
+/**
+ * Create a structured warning object.
+ */
+function warn(message, line) {
+ return { message, line: line ?? null, severity: "warning" };
+}
+
/**
* Levenshtein distance between two strings.
* Used to suggest corrections for misspelled keys.
@@ -68,17 +191,23 @@ export function parseConfig(yamlString) {
const warnings = [];
if (!yamlString || !yamlString.trim()) {
- return { config: null, variables: {}, errors: ["Empty configuration"], warnings };
+ return { config: null, variables: {}, errors: [err("Empty configuration")], warnings };
}
let raw;
try {
raw = jsyaml.load(yamlString);
} catch (e) {
+ let line = e.mark ? e.mark.line : null;
+ const lastLine = yamlString.split("\n").length - 1;
+ // Clamp to valid line range — js-yaml sometimes reports past EOF
+ if (line !== null && line > lastLine) line = lastLine;
+ // Adjust line to point at the actual cause, not the detection point
+ line = adjustSyntaxErrorLine(line, e.reason, yamlString);
return {
config: null,
variables: {},
- errors: [`YAML syntax error: ${e.message}`],
+ errors: [err(`YAML syntax error: ${e.message}`, line)],
warnings,
};
}
@@ -87,7 +216,7 @@ export function parseConfig(yamlString) {
return {
config: null,
variables: {},
- errors: ["Configuration must be a YAML mapping (object)"],
+ errors: [err("Configuration must be a YAML mapping (object)")],
warnings,
};
}
@@ -95,41 +224,43 @@ export function parseConfig(yamlString) {
// Warn on unknown top-level keys with typo suggestions
for (const key of Object.keys(raw)) {
if (!VALID_TOP_KEYS.has(key)) {
+ const line = findKeyLine(yamlString, key);
const suggestion = suggestKey(key, VALID_TOP_KEYS);
if (suggestion) {
- warnings.push(`Unknown top-level key '${key}' — did you mean '${suggestion}'?`);
+ warnings.push(warn(`Unknown top-level key '${key}' — did you mean '${suggestion}'?`, line));
} else {
- warnings.push(`Unknown top-level key: '${key}'`);
+ warnings.push(warn(`Unknown top-level key: '${key}'`, line));
}
}
}
// Validate variables block
if (raw.variables !== undefined && (typeof raw.variables !== "object" || Array.isArray(raw.variables))) {
- errors.push("'variables' must be a mapping");
+ errors.push(err("'variables' must be a mapping", findKeyLine(yamlString, "variables")));
}
// Validate zones block
if (raw.zones !== undefined) {
if (typeof raw.zones !== "object" || Array.isArray(raw.zones)) {
- errors.push("'zones' must be a mapping of zone names to zone configs");
+ errors.push(err("'zones' must be a mapping of zone names to zone configs", findKeyLine(yamlString, "zones")));
} else {
for (const [zoneName, zone] of Object.entries(raw.zones)) {
if (typeof zone !== "object" || Array.isArray(zone) || zone === null) {
- errors.push(`Zone '${zoneName}' must be a mapping`);
+ errors.push(err(`Zone '${zoneName}' must be a mapping`, findKeyLine(yamlString, zoneName, "zones")));
continue;
}
for (const key of Object.keys(zone)) {
if (!VALID_ZONE_KEYS.has(key)) {
+ const line = findKeyLine(yamlString, key, zoneName);
const suggestion = suggestKey(key, VALID_ZONE_KEYS);
if (suggestion) {
- warnings.push(`Zone '${zoneName}': unknown key '${key}' — did you mean '${suggestion}'?`);
+ warnings.push(warn(`Zone '${zoneName}': unknown key '${key}' — did you mean '${suggestion}'?`, line));
} else {
- warnings.push(`Zone '${zoneName}': unknown key '${key}'`);
+ warnings.push(warn(`Zone '${zoneName}': unknown key '${key}'`, line));
}
}
}
- validateZone(zoneName, zone, errors, warnings);
+ validateZone(zoneName, zone, yamlString, errors, warnings);
}
}
}
@@ -137,19 +268,20 @@ export function parseConfig(yamlString) {
// Validate direct block
if (raw.direct !== undefined) {
if (typeof raw.direct !== "object" || Array.isArray(raw.direct) || raw.direct === null) {
- errors.push("'direct' must be a mapping");
+ errors.push(err("'direct' must be a mapping", findKeyLine(yamlString, "direct")));
} else {
for (const key of Object.keys(raw.direct)) {
if (!VALID_DIRECT_KEYS.has(key)) {
+ const line = findKeyLine(yamlString, key, "direct");
const suggestion = suggestKey(key, VALID_DIRECT_KEYS);
if (suggestion) {
- warnings.push(`Direct: unknown key '${key}' — did you mean '${suggestion}'?`);
+ warnings.push(warn(`Direct: unknown key '${key}' — did you mean '${suggestion}'?`, line));
} else {
- warnings.push(`Direct: unknown key '${key}'`);
+ warnings.push(warn(`Direct: unknown key '${key}'`, line));
}
}
}
- validateDirect(raw.direct, errors, warnings);
+ validateDirect(raw.direct, yamlString, errors, warnings);
}
}
@@ -159,7 +291,9 @@ export function parseConfig(yamlString) {
// Process templates (variable substitution + loop expansion)
const { config, variables, warnings: templateWarnings } = processTemplate(raw);
- warnings.push(...templateWarnings);
+ for (const tw of templateWarnings) {
+ warnings.push(typeof tw === "string" ? warn(tw) : tw);
+ }
return { config, variables, errors: [], warnings };
}
@@ -169,29 +303,33 @@ export function parseConfig(yamlString) {
* @param {string} context - label for warnings (e.g., "Zone 'public' port")
* @param {object} obj - object to check
* @param {Set} validKeys - allowed keys
- * @param {string[]} warnings - collector
+ * @param {string} yamlString - raw YAML for line lookup
+ * @param {string} parentKey - parent key for scoped line search
+ * @param {object[]} warnings - collector
*/
-function warnUnknownKeys(context, obj, validKeys, warnings) {
+function warnUnknownKeys(context, obj, validKeys, yamlString, parentKey, warnings) {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
for (const key of Object.keys(obj)) {
if (!validKeys.has(key)) {
+ const line = findKeyLine(yamlString, key, parentKey);
const suggestion = suggestKey(key, validKeys);
if (suggestion) {
- warnings.push(`${context}: unknown key '${key}' — did you mean '${suggestion}'?`);
+ warnings.push(warn(`${context}: unknown key '${key}' — did you mean '${suggestion}'?`, line));
} else {
- warnings.push(`${context}: unknown key '${key}'`);
+ warnings.push(warn(`${context}: unknown key '${key}'`, line));
}
}
}
}
-function validateZone(name, zone, errors, warnings) {
+function validateZone(name, zone, yamlString, errors, warnings) {
const ctx = `Zone '${name}'`;
if (zone.target !== undefined) {
const targetStr = String(zone.target);
if (!VALID_TARGETS.includes(targetStr)) {
- warnings.push(`${ctx}: target '${targetStr}' is not one of ${VALID_TARGETS.join(", ")}`);
+ const line = findKeyLine(yamlString, "target", name);
+ warnings.push(warn(`${ctx}: target '${targetStr}' is not one of ${VALID_TARGETS.join(", ")}`, line));
}
}
@@ -202,7 +340,7 @@ function validateZone(name, zone, errors, warnings) {
for (const field of arrayFields) {
if (zone[field] !== undefined && !Array.isArray(zone[field])) {
- errors.push(`${ctx}: '${field}' must be an array`);
+ errors.push(err(`${ctx}: '${field}' must be an array`, findKeyLine(yamlString, field, name)));
}
}
@@ -210,9 +348,9 @@ function validateZone(name, zone, errors, warnings) {
if (Array.isArray(zone.ports)) {
for (const p of zone.ports) {
if (p !== null && typeof p === "object" && !Array.isArray(p)) {
- warnUnknownKeys(`${ctx} port`, p, VALID_PORT_KEYS, warnings);
+ warnUnknownKeys(`${ctx} port`, p, VALID_PORT_KEYS, yamlString, "ports", warnings);
if (p.port === undefined) {
- warnings.push(`${ctx}: port entry missing 'port' field`);
+ warnings.push(warn(`${ctx}: port entry missing 'port' field`, findKeyLine(yamlString, "ports", name)));
}
}
}
@@ -222,9 +360,9 @@ function validateZone(name, zone, errors, warnings) {
if (Array.isArray(zone.source_ports)) {
for (const sp of zone.source_ports) {
if (sp !== null && typeof sp === "object" && !Array.isArray(sp)) {
- warnUnknownKeys(`${ctx} source_port`, sp, VALID_PORT_KEYS, warnings);
+ warnUnknownKeys(`${ctx} source_port`, sp, VALID_PORT_KEYS, yamlString, "source_ports", warnings);
if (sp.port === undefined) {
- warnings.push(`${ctx}: source_port entry missing 'port' field`);
+ warnings.push(warn(`${ctx}: source_port entry missing 'port' field`, findKeyLine(yamlString, "source_ports", name)));
}
}
}
@@ -234,9 +372,9 @@ function validateZone(name, zone, errors, warnings) {
if (Array.isArray(zone.forward_ports)) {
for (const fp of zone.forward_ports) {
if (fp !== null && typeof fp === "object" && !Array.isArray(fp)) {
- warnUnknownKeys(`${ctx} forward_port`, fp, VALID_FORWARD_PORT_KEYS, warnings);
+ warnUnknownKeys(`${ctx} forward_port`, fp, VALID_FORWARD_PORT_KEYS, yamlString, "forward_ports", warnings);
if (fp.port === undefined) {
- warnings.push(`${ctx}: forward_port entry missing 'port' field`);
+ warnings.push(warn(`${ctx}: forward_port entry missing 'port' field`, findKeyLine(yamlString, "forward_ports", name)));
}
}
}
@@ -246,26 +384,26 @@ function validateZone(name, zone, errors, warnings) {
if (Array.isArray(zone.rich_rules)) {
for (const rule of zone.rich_rules) {
if (rule !== null && typeof rule === "object" && !Array.isArray(rule)) {
- warnUnknownKeys(`${ctx} rich_rule`, rule, VALID_RICH_RULE_KEYS, warnings);
+ warnUnknownKeys(`${ctx} rich_rule`, rule, VALID_RICH_RULE_KEYS, yamlString, "rich_rules", warnings);
if (!rule.action && !rule.log && !rule.audit && !rule.masquerade) {
- warnings.push(`${ctx}: rich_rule has no action, log, audit, or masquerade`);
+ warnings.push(warn(`${ctx}: rich_rule has no action, log, audit, or masquerade`, findKeyLine(yamlString, "rich_rules", name)));
}
}
}
}
}
-function validateDirect(direct, errors, warnings) {
+function validateDirect(direct, yamlString, errors, warnings) {
// Chains
if (direct.chains !== undefined) {
if (!Array.isArray(direct.chains)) {
- errors.push("'direct.chains' must be an array");
+ errors.push(err("'direct.chains' must be an array", findKeyLine(yamlString, "chains", "direct")));
} else {
for (const chain of direct.chains) {
if (chain !== null && typeof chain === "object") {
- warnUnknownKeys("Direct chain", chain, VALID_DIRECT_CHAIN_KEYS, warnings);
+ warnUnknownKeys("Direct chain", chain, VALID_DIRECT_CHAIN_KEYS, yamlString, "chains", warnings);
if (!chain.ipv || !chain.table || !chain.chain) {
- warnings.push("Direct chain entry missing required fields (ipv, table, chain)");
+ warnings.push(warn("Direct chain entry missing required fields (ipv, table, chain)", findKeyLine(yamlString, "chains", "direct")));
}
}
}
@@ -275,13 +413,13 @@ function validateDirect(direct, errors, warnings) {
// Rules
if (direct.rules !== undefined) {
if (!Array.isArray(direct.rules)) {
- errors.push("'direct.rules' must be an array");
+ errors.push(err("'direct.rules' must be an array", findKeyLine(yamlString, "rules", "direct")));
} else {
for (const rule of direct.rules) {
if (rule !== null && typeof rule === "object") {
- warnUnknownKeys("Direct rule", rule, VALID_DIRECT_RULE_KEYS, warnings);
+ warnUnknownKeys("Direct rule", rule, VALID_DIRECT_RULE_KEYS, yamlString, "rules", warnings);
if (!rule.ipv || !rule.table || !rule.chain || rule.priority === undefined || !rule.args) {
- warnings.push("Direct rule entry missing required fields (ipv, table, chain, priority, args)");
+ warnings.push(warn("Direct rule entry missing required fields (ipv, table, chain, priority, args)", findKeyLine(yamlString, "rules", "direct")));
}
}
}
@@ -291,13 +429,13 @@ function validateDirect(direct, errors, warnings) {
// Passthroughs
if (direct.passthroughs !== undefined) {
if (!Array.isArray(direct.passthroughs)) {
- errors.push("'direct.passthroughs' must be an array");
+ errors.push(err("'direct.passthroughs' must be an array", findKeyLine(yamlString, "passthroughs", "direct")));
} else {
for (const pt of direct.passthroughs) {
if (pt !== null && typeof pt === "object") {
- warnUnknownKeys("Direct passthrough", pt, VALID_PASSTHROUGH_KEYS, warnings);
+ warnUnknownKeys("Direct passthrough", pt, VALID_PASSTHROUGH_KEYS, yamlString, "passthroughs", warnings);
if (!pt.ipv || !pt.args) {
- warnings.push("Direct passthrough entry missing required fields (ipv, args)");
+ warnings.push(warn("Direct passthrough entry missing required fields (ipv, args)", findKeyLine(yamlString, "passthroughs", "direct")));
}
}
}
@@ -307,30 +445,30 @@ function validateDirect(direct, errors, warnings) {
// Rule groups
if (direct.rule_groups !== undefined) {
if (!Array.isArray(direct.rule_groups)) {
- errors.push("'direct.rule_groups' must be an array");
+ errors.push(err("'direct.rule_groups' must be an array", findKeyLine(yamlString, "rule_groups", "direct")));
} else {
for (const group of direct.rule_groups) {
if (group !== null && typeof group === "object") {
- warnUnknownKeys("Direct rule_group", group, VALID_RULE_GROUP_KEYS, warnings);
+ warnUnknownKeys("Direct rule_group", group, VALID_RULE_GROUP_KEYS, yamlString, "rule_groups", warnings);
if (!group.ipv) {
- warnings.push("Direct rule_group missing 'ipv' field");
+ warnings.push(warn("Direct rule_group missing 'ipv' field", findKeyLine(yamlString, "rule_groups", "direct")));
}
if (!group.table) {
- warnings.push("Direct rule_group missing 'table' field");
+ warnings.push(warn("Direct rule_group missing 'table' field", findKeyLine(yamlString, "rule_groups", "direct")));
}
if (!group.chain) {
- warnings.push("Direct rule_group missing 'chain' field");
+ warnings.push(warn("Direct rule_group missing 'chain' field", findKeyLine(yamlString, "rule_groups", "direct")));
}
// Validate nested rules within group
if (group.rules !== undefined && Array.isArray(group.rules)) {
for (const rule of group.rules) {
if (rule !== null && typeof rule === "object") {
- warnUnknownKeys("Direct rule_group rule", rule, VALID_RULE_GROUP_RULE_KEYS, warnings);
+ warnUnknownKeys("Direct rule_group rule", rule, VALID_RULE_GROUP_RULE_KEYS, yamlString, "rules", warnings);
if (rule.priority === undefined) {
- warnings.push("Direct rule_group rule missing 'priority' field");
+ warnings.push(warn("Direct rule_group rule missing 'priority' field", findKeyLine(yamlString, "rules", "rule_groups")));
}
if (!rule.args) {
- warnings.push("Direct rule_group rule missing 'args' field");
+ warnings.push(warn("Direct rule_group rule missing 'args' field", findKeyLine(yamlString, "rules", "rule_groups")));
}
}
}
diff --git a/js/reverse-parser.mjs b/js/reverse-parser.mjs
new file mode 100644
index 0000000..dd3c039
--- /dev/null
+++ b/js/reverse-parser.mjs
@@ -0,0 +1,706 @@
+// Pattern: Parser — transforms firewall-cmd command text into a config object
+
+/**
+ * Tokenize a firewall-cmd command line, preserving single-quoted strings.
+ * Returns an array of tokens (flags, values, positional args).
+ */
+function tokenizeLine(line) {
+ const tokens = [];
+ let i = 0;
+ const len = line.length;
+
+ while (i < len) {
+ // Skip whitespace
+ while (i < len && line[i] === " ") {
+ i++;
+ }
+ if (i >= len) {
+ break;
+ }
+
+ // Single-quoted string
+ if (line[i] === "'") {
+ i++;
+ let str = "";
+ while (i < len && line[i] !== "'") {
+ str += line[i];
+ i++;
+ }
+ i++; // skip closing quote
+ tokens.push(str);
+ continue;
+ }
+
+ // Regular token (until whitespace), with inline single-quote support
+ let token = "";
+ while (i < len && line[i] !== " ") {
+ if (line[i] === "'") {
+ // Inline quoted string (e.g. --add-rich-rule='rule ...')
+ i++; // skip opening quote
+ let quoted = "";
+ while (i < len && line[i] !== "'") {
+ quoted += line[i];
+ i++;
+ }
+ if (i < len) {
+ i++; // skip closing quote
+ }
+ token += quoted;
+ } else {
+ token += line[i];
+ i++;
+ }
+ }
+ tokens.push(token);
+ }
+
+ return tokens;
+}
+
+/**
+ * Parse tokens into a flags map and positional args.
+ * Flags: --key=value -> { key: value }, --flag -> { flag: true }
+ */
+function parseFlags(tokens) {
+ const flags = {};
+ const args = [];
+
+ for (const token of tokens) {
+ if (token.startsWith("--")) {
+ const eqIdx = token.indexOf("=");
+ if (eqIdx !== -1) {
+ const key = token.slice(2, eqIdx);
+ const value = token.slice(eqIdx + 1);
+ flags[key] = value;
+ } else {
+ flags[token.slice(2)] = true;
+ }
+ } else if (token !== "firewall-cmd" && token !== "sudo") {
+ args.push(token);
+ }
+ }
+
+ return { flags, args };
+}
+
+/**
+ * Parse a port/protocol string "80/tcp" into { port, protocol }.
+ */
+function parsePort(value) {
+ const slashIdx = value.indexOf("/");
+ if (slashIdx === -1) {
+ return { port: value, protocol: "tcp" };
+ }
+ return { port: value.slice(0, slashIdx), protocol: value.slice(slashIdx + 1) };
+}
+
+/**
+ * Parse a forward-port value "port=P:proto=PR[:toport=T][:toaddr=A]".
+ */
+function parseForwardPort(value) {
+ const result = {};
+ const parts = value.split(":");
+
+ for (const part of parts) {
+ const eqIdx = part.indexOf("=");
+ if (eqIdx === -1) {
+ continue;
+ }
+ const key = part.slice(0, eqIdx);
+ const val = part.slice(eqIdx + 1);
+
+ if (key === "port") {
+ result.port = val;
+ } else if (key === "proto") {
+ result.protocol = val;
+ } else if (key === "toport") {
+ result.to_port = val;
+ } else if (key === "toaddr") {
+ result.to_addr = val;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Parse a rich rule string into an object matching the YAML schema.
+ *
+ * Tokenizes the string respecting key="value" pairs and walks tokens
+ * left-to-right matching known keywords.
+ */
+function parseRichRule(ruleStr) {
+ // Tokenize: split on spaces but keep key="value" together
+ const rawTokens = [];
+ let i = 0;
+ const len = ruleStr.length;
+
+ while (i < len) {
+ while (i < len && ruleStr[i] === " ") {
+ i++;
+ }
+ if (i >= len) {
+ break;
+ }
+
+ let token = "";
+ while (i < len && ruleStr[i] !== " ") {
+ if (ruleStr[i] === '"') {
+ // Include the quoted value as part of this token
+ token += ruleStr[i];
+ i++;
+ while (i < len && ruleStr[i] !== '"') {
+ token += ruleStr[i];
+ i++;
+ }
+ if (i < len) {
+ token += ruleStr[i]; // closing quote
+ i++;
+ }
+ } else {
+ token += ruleStr[i];
+ i++;
+ }
+ }
+ rawTokens.push(token);
+ }
+
+ const rule = {};
+
+ /**
+ * Extract a quoted value from a token like key="value".
+ */
+ function extractValue(token) {
+ const eqIdx = token.indexOf("=");
+ if (eqIdx === -1) {
+ return token;
+ }
+ let val = token.slice(eqIdx + 1);
+ if (val.startsWith('"') && val.endsWith('"')) {
+ val = val.slice(1, -1);
+ }
+ return val;
+ }
+
+ /**
+ * Get the attribute name from key="value" token.
+ */
+ function attrName(token) {
+ const eqIdx = token.indexOf("=");
+ return eqIdx === -1 ? token : token.slice(0, eqIdx);
+ }
+
+ let pos = 0;
+
+ // Skip the "rule" keyword
+ if (pos < rawTokens.length && rawTokens[pos] === "rule") {
+ pos++;
+ }
+
+ // family="..."
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "family") {
+ rule.family = extractValue(rawTokens[pos]);
+ pos++;
+ }
+
+ // source address="..." [invert="true"]
+ if (pos < rawTokens.length && rawTokens[pos] === "source") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "address") {
+ rule.source = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "invert") {
+ if (extractValue(rawTokens[pos]) === "true") {
+ rule.source_invert = true;
+ }
+ pos++;
+ }
+ }
+
+ // destination address="..." [invert="true"]
+ if (pos < rawTokens.length && rawTokens[pos] === "destination") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "address") {
+ rule.destination = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "invert") {
+ if (extractValue(rawTokens[pos]) === "true") {
+ rule.destination_invert = true;
+ }
+ pos++;
+ }
+ }
+
+ // Element: service, port, protocol, icmp-block, icmp-type, masquerade, forward-port, source-port
+ if (pos < rawTokens.length) {
+ const keyword = rawTokens[pos];
+
+ if (keyword === "service") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "name") {
+ rule.service = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ } else if (keyword === "port") {
+ pos++;
+ while (pos < rawTokens.length) {
+ const attr = attrName(rawTokens[pos]);
+ if (attr === "port") {
+ rule.port = extractValue(rawTokens[pos]);
+ } else if (attr === "protocol") {
+ rule.protocol = extractValue(rawTokens[pos]);
+ } else {
+ break;
+ }
+ pos++;
+ }
+ } else if (keyword === "protocol") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "value") {
+ rule.protocol = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ } else if (keyword === "icmp-block") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "name") {
+ rule.icmp_block = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ } else if (keyword === "icmp-type") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "name") {
+ rule.icmp_type = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ } else if (keyword === "masquerade") {
+ rule.masquerade = true;
+ pos++;
+ } else if (keyword === "forward-port") {
+ pos++;
+ const fp = {};
+ while (pos < rawTokens.length) {
+ const attr = attrName(rawTokens[pos]);
+ if (attr === "port") {
+ fp.port = extractValue(rawTokens[pos]);
+ } else if (attr === "protocol") {
+ fp.protocol = extractValue(rawTokens[pos]);
+ } else if (attr === "to-port") {
+ fp.to_port = extractValue(rawTokens[pos]);
+ } else if (attr === "to-addr") {
+ fp.to_addr = extractValue(rawTokens[pos]);
+ } else {
+ break;
+ }
+ pos++;
+ }
+ rule.forward_port = fp;
+ } else if (keyword === "source-port") {
+ pos++;
+ const sp = {};
+ while (pos < rawTokens.length) {
+ const attr = attrName(rawTokens[pos]);
+ if (attr === "port") {
+ sp.port = extractValue(rawTokens[pos]);
+ } else if (attr === "protocol") {
+ sp.protocol = extractValue(rawTokens[pos]);
+ } else {
+ break;
+ }
+ pos++;
+ }
+ rule.source_port = sp;
+ }
+ }
+
+ // log [prefix="..." level="..." limit value="..."]
+ if (pos < rawTokens.length && rawTokens[pos] === "log") {
+ pos++;
+ const logObj = {};
+ while (pos < rawTokens.length) {
+ const attr = attrName(rawTokens[pos]);
+ if (attr === "prefix") {
+ logObj.prefix = extractValue(rawTokens[pos]);
+ pos++;
+ } else if (attr === "level") {
+ logObj.level = extractValue(rawTokens[pos]);
+ pos++;
+ } else if (attr === "limit") {
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "value") {
+ logObj.limit = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ } else {
+ break;
+ }
+ }
+ rule.log = logObj;
+ }
+
+ // audit
+ if (pos < rawTokens.length && rawTokens[pos] === "audit") {
+ rule.audit = true;
+ pos++;
+ }
+
+ // Action: accept, reject [type="..."], drop, mark [set="..."]
+ if (pos < rawTokens.length) {
+ const action = rawTokens[pos];
+ if (action === "accept" || action === "drop") {
+ rule.action = action;
+ pos++;
+ } else if (action === "reject") {
+ rule.action = "reject";
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "type") {
+ rule.reject_type = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ } else if (action === "mark") {
+ rule.action = "mark";
+ pos++;
+ if (pos < rawTokens.length && attrName(rawTokens[pos]) === "set") {
+ rule.mark_set = extractValue(rawTokens[pos]);
+ pos++;
+ }
+ }
+ }
+
+ return rule;
+}
+
+/**
+ * Ensure a zone exists in the config and return it.
+ */
+function ensureZone(config, zoneName) {
+ if (!config.zones[zoneName]) {
+ config.zones[zoneName] = {};
+ }
+ return config.zones[zoneName];
+}
+
+/**
+ * Normalize an --add-* or --remove-* flag name to the base name.
+ * e.g. "add-service" -> "service", "remove-port" -> "port"
+ */
+function normalizeFlag(flag) {
+ if (flag.startsWith("add-")) {
+ return flag.slice(4);
+ }
+ if (flag.startsWith("remove-")) {
+ return flag.slice(7);
+ }
+ return flag;
+}
+
+/**
+ * Process a zone command given the parsed flags.
+ */
+function processZoneCommand(zone, flags) {
+ // Find the action flag (add-* or remove-* or set-target)
+ for (const [key, value] of Object.entries(flags)) {
+ if (key === "zone" || key === "permanent" || key === "direct") {
+ continue;
+ }
+
+ const base = normalizeFlag(key);
+
+ if (key === "set-target") {
+ zone.target = value;
+ return true;
+ }
+
+ if (base === "interface") {
+ if (!zone.interfaces) {
+ zone.interfaces = [];
+ }
+ zone.interfaces.push(value);
+ return true;
+ }
+
+ if (base === "source") {
+ if (!zone.sources) {
+ zone.sources = [];
+ }
+ zone.sources.push(value);
+ return true;
+ }
+
+ if (base === "service") {
+ if (!zone.services) {
+ zone.services = [];
+ }
+ zone.services.push(value);
+ return true;
+ }
+
+ if (base === "port") {
+ if (!zone.ports) {
+ zone.ports = [];
+ }
+ zone.ports.push(parsePort(value));
+ return true;
+ }
+
+ if (base === "protocol") {
+ if (!zone.protocols) {
+ zone.protocols = [];
+ }
+ zone.protocols.push(value);
+ return true;
+ }
+
+ if (base === "source-port") {
+ if (!zone.source_ports) {
+ zone.source_ports = [];
+ }
+ zone.source_ports.push(parsePort(value));
+ return true;
+ }
+
+ if (base === "rich-rule") {
+ if (!zone.rich_rules) {
+ zone.rich_rules = [];
+ }
+ zone.rich_rules.push(parseRichRule(value));
+ return true;
+ }
+
+ if (base === "forward" && value === true) {
+ zone.forward = true;
+ return true;
+ }
+
+ if (base === "masquerade" && value === true) {
+ zone.masquerade = true;
+ return true;
+ }
+
+ if (base === "forward-port") {
+ if (!zone.forward_ports) {
+ zone.forward_ports = [];
+ }
+ zone.forward_ports.push(parseForwardPort(value));
+ return true;
+ }
+
+ if (base === "icmp-block" && typeof value === "string") {
+ if (!zone.icmp_blocks) {
+ zone.icmp_blocks = [];
+ }
+ zone.icmp_blocks.push(value);
+ return true;
+ }
+
+ if (base === "icmp-block-inversion") {
+ zone.icmp_block_inversion = true;
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Process a direct command.
+ * Direct commands use positional args after the --add-chain/rule/passthrough flag.
+ */
+function processDirectCommand(config, flags, tokens) {
+ // Find the direct action flag
+ const directAction = Object.keys(flags).find(
+ (k) =>
+ k === "add-chain" ||
+ k === "remove-chain" ||
+ k === "add-rule" ||
+ k === "remove-rule" ||
+ k === "add-passthrough" ||
+ k === "remove-passthrough"
+ );
+
+ if (!directAction) {
+ return false;
+ }
+
+ // Find the position of the direct action flag in the original tokens
+ // to extract positional args after it
+ const flagToken = `--${directAction}`;
+ const flagIdx = tokens.indexOf(flagToken);
+ if (flagIdx === -1) {
+ return false;
+ }
+
+ const positional = tokens.slice(flagIdx + 1);
+ const base = normalizeFlag(directAction);
+
+ if (base === "chain") {
+ // ipv table chain
+ if (positional.length >= 3) {
+ config.direct.chains.push({
+ ipv: positional[0],
+ table: positional[1],
+ chain: positional[2],
+ });
+ return true;
+ }
+ }
+
+ if (base === "rule") {
+ // ipv table chain priority args...
+ if (positional.length >= 5) {
+ config.direct.rules.push({
+ ipv: positional[0],
+ table: positional[1],
+ chain: positional[2],
+ priority: parseInt(positional[3], 10),
+ args: positional.slice(4).join(" "),
+ });
+ return true;
+ }
+ }
+
+ if (base === "passthrough") {
+ // ipv args...
+ if (positional.length >= 2) {
+ config.direct.passthroughs.push({
+ ipv: positional[0],
+ args: positional.slice(1).join(" "),
+ });
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Deduplicate direct chains (same ipv+table+chain).
+ */
+function deduplicateChains(chains) {
+ const seen = new Set();
+ return chains.filter((c) => {
+ const key = `${c.ipv}|${c.table}|${c.chain}`;
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+/**
+ * Remove empty sections from the config to produce clean YAML output.
+ */
+function cleanConfig(config) {
+ const result = {};
+
+ if (Object.keys(config.zones).length > 0) {
+ result.zones = config.zones;
+ }
+
+ const hasChains = config.direct.chains.length > 0;
+ const hasRules = config.direct.rules.length > 0;
+ const hasPassthroughs = config.direct.passthroughs.length > 0;
+
+ if (hasChains || hasRules || hasPassthroughs) {
+ result.direct = {};
+ if (hasChains) {
+ result.direct.chains = deduplicateChains(config.direct.chains);
+ }
+ if (hasRules) {
+ result.direct.rules = config.direct.rules;
+ }
+ if (hasPassthroughs) {
+ result.direct.passthroughs = config.direct.passthroughs;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Parse a block of firewall-cmd commands into a config object.
+ *
+ * @param {string} text - multiline text containing firewall-cmd commands
+ * @returns {{ config: object, errors: string[], skipped: string[] }}
+ */
+export function parseCommands(text) {
+ const config = {
+ zones: {},
+ direct: { chains: [], rules: [], passthroughs: [] },
+ };
+
+ const errors = [];
+ const skipped = [];
+
+ const lines = text.split("\n");
+
+ for (let i = 0; i < lines.length; i++) {
+ const raw = lines[i];
+ const trimmed = raw.trim();
+
+ // Skip blank lines
+ if (trimmed === "") {
+ continue;
+ }
+
+ // Skip comments and shebang
+ if (trimmed.startsWith("#") || trimmed.startsWith("#!/")) {
+ skipped.push(`Line ${i + 1}: ${trimmed}`);
+ 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 ")) {
+ line = line.slice(5);
+ }
+
+ // Must start with firewall-cmd
+ if (!line.startsWith("firewall-cmd")) {
+ skipped.push(`Line ${i + 1}: ${trimmed}`);
+ continue;
+ }
+
+ const tokens = tokenizeLine(line);
+ const { flags } = parseFlags(tokens);
+
+ // Remove --permanent (irrelevant for parsing)
+ delete flags.permanent;
+
+ // Direct commands
+ if (flags.direct) {
+ if (!processDirectCommand(config, flags, tokens)) {
+ errors.push(`Line ${i + 1}: could not parse direct command: ${trimmed}`);
+ }
+ continue;
+ }
+
+ // Zone commands
+ const zoneName = flags.zone;
+ if (zoneName) {
+ delete flags.zone;
+ const zone = ensureZone(config, zoneName);
+ if (!processZoneCommand(zone, flags)) {
+ errors.push(`Line ${i + 1}: could not parse zone command: ${trimmed}`);
+ }
+ continue;
+ }
+
+ // Unknown command structure
+ errors.push(`Line ${i + 1}: unrecognized command: ${trimmed}`);
+ }
+
+ return {
+ config: cleanConfig(config),
+ errors,
+ skipped,
+ };
+}