Add per-server Interface/KeyPair edit hardening and OPNsense config import

Redact the private key from the /servers/:id/keypair response body -
the UI never rendered it, but the raw key was still returned over the
wire (json:"private_key,omitempty" plus explicit clearing before the
JSON response).

Add a new import flow: an admin can upload an OPNsense config.xml,
preview the WireGuard servers/clients it defines (editable before
committing), and confirm to create the corresponding
Server/ServerSetting/Client records. Nothing is auto-applied - no
wg-quick/systemctl call happens, matching the existing manual "Apply"
step for regular server management.

Schema verified against OPNsense core (WireGuard has been in core
since 22.1, not a plugin) - see opnsense/parse.go for the confirmed
tag reference. Public keys are always re-derived from private keys
rather than trusted from the export; client public-key collisions
against existing store data are skipped and reported per-batch rather
than aborting the whole import.

Since OPNsense stores DNS/MTU per-server and keepalive per-client, but
this fork only had those app-wide (GlobalSetting), extended
ServerSetting with DNSServers/MTU and Client with PersistentKeepalive
as optional overrides that fall back to the global default when unset
- existing single-server behavior is unchanged when the override is
empty/zero. Manual UI editing of the per-client keepalive override
outside the import flow is left for a later pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VjwLYRA87o8m9a9zztgs3
This commit is contained in:
sysops
2026-07-24 00:05:08 +02:00
co-authored by Claude Sonnet 5
parent 0dbb916866
commit 388a8377cd
10 changed files with 943 additions and 31 deletions
+241 -3
View File
@@ -107,6 +107,17 @@ All Servers
<input type="text" class="form-control" id="_settings_lan_interface" placeholder="e.g. eth0, br-lan">
<small class="form-text text-muted">Optional. Used only for the Firewall Preview - lets peers forward to this interface.</small>
</div>
<div class="form-group">
<label for="_settings_dns_servers" class="control-label">DNS Servers (override)</label>
<input type="text" class="form-control" id="_settings_dns_servers"
placeholder="e.g. 1.1.1.1, 8.8.8.8">
<small class="form-text text-muted">Comma-separated. Leave empty to fall back to the app-wide default DNS servers.</small>
</div>
<div class="form-group">
<label for="_settings_mtu" class="control-label">MTU (override)</label>
<input type="text" class="form-control" id="_settings_mtu" placeholder="e.g. 1420">
<small class="form-text text-muted">Leave empty to fall back to the app-wide default MTU.</small>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
@@ -180,6 +191,36 @@ All Servers
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_import_opnsense">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Import from OPNsense</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="text-muted">Upload an OPNsense <code>config.xml</code>. This only stages the data below for
you to review and edit - nothing is written until you click "Confirm Import", and the resulting
servers are never started automatically (use the normal per-server "Apply" flow for that).</p>
<div class="form-inline mb-2">
<input type="file" id="_opnsense_file" accept=".xml">
<button type="button" class="btn btn-primary btn-sm ml-2" id="btn_opnsense_preview">Preview</button>
</div>
<div id="_opnsense_preview_area"></div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="btn_opnsense_confirm" style="display:none;">Confirm Import</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_firewall">
<div class="modal-dialog modal-lg">
<div class="modal-content">
@@ -309,10 +350,13 @@ All Servers
// load server list
$(document).ready(function () {
populateServersList();
let newServerHtml = '<div class="col-sm-2 offset-md-4" style=" text-align: right;">' +
let newServerHtml = '<div class="col-sm-3 offset-md-3" style=" text-align: right;">' +
'<button style="" id="btn_new_server" type="button" class="btn btn-outline-primary btn-sm" ' +
'data-toggle="modal" data-target="#modal_new_server">' +
'<i class="nav-icon fas fa-plus"></i> New Server</button></div>';
'<i class="nav-icon fas fa-plus"></i> New Server</button> ' +
'<button id="btn_import_opnsense" type="button" class="btn btn-outline-secondary btn-sm" ' +
'data-toggle="modal" data-target="#modal_import_opnsense">' +
'<i class="nav-icon fas fa-file-import"></i> Import from OPNsense</button></div>';
$('h1').parents(".row").append(newServerHtml);
})
@@ -377,6 +421,8 @@ All Servers
modal.find("#_settings_firewall_mark").val("");
modal.find("#_settings_table").val("");
modal.find("#_settings_lan_interface").val("");
modal.find("#_settings_dns_servers").val("");
modal.find("#_settings_mtu").val("");
$.ajax({
cache: false,
@@ -390,6 +436,8 @@ All Servers
modal.find("#_settings_firewall_mark").val(settings.firewall_mark);
modal.find("#_settings_table").val(settings.table);
modal.find("#_settings_lan_interface").val(settings.lan_interface);
modal.find("#_settings_dns_servers").val((settings.dns_servers || []).join(", "));
modal.find("#_settings_mtu").val(settings.mtu || "");
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
@@ -400,12 +448,19 @@ All Servers
function submitServerSettings() {
const serverId = $("#_settings_server_id").val();
const dnsServers = $("#_settings_dns_servers").val().split(",").map(function (a) {
return a.trim();
}).filter(function (a) {
return a !== "";
});
const data = {
"endpoint_address": $("#_settings_endpoint_address").val(),
"config_file_path": $("#_settings_config_file_path").val(),
"firewall_mark": $("#_settings_firewall_mark").val(),
"table": $("#_settings_table").val(),
"lan_interface": $("#_settings_lan_interface").val()
"lan_interface": $("#_settings_lan_interface").val(),
"dns_servers": dnsServers,
"mtu": parseInt($("#_settings_mtu").val(), 10) || 0
};
$.ajax({
@@ -712,6 +767,189 @@ All Servers
});
});
// OPNsense import: preview step (multipart upload, no store writes)
var opnsensePreviewData = null;
function csvList(arr) {
return (arr || []).join(", ");
}
function renderOPNsensePreview(preview) {
opnsensePreviewData = preview;
const area = $("#_opnsense_preview_area");
area.empty();
if (!preview.servers || preview.servers.length === 0) {
area.append('<p class="text-muted">No WireGuard servers found in this config.xml.</p>');
$("#btn_opnsense_confirm").hide();
return;
}
$.each(preview.servers, function (si, server) {
let warnings = "";
if (server.warnings && server.warnings.length) {
warnings = '<div class="alert alert-warning py-1 px-2 mb-2">' + server.warnings.join("<br>") + '</div>';
}
let html = '<div class="card mb-3" data-serverindex="' + si + '">' +
'<div class="card-header py-1"><strong>Server</strong></div>' +
'<div class="card-body py-2">' + warnings +
'<div class="form-row">' +
'<div class="col-md-2"><label>ID</label><input type="text" class="form-control form-control-sm _f_id" value="' + (server.id || "") + '"></div>' +
'<div class="col-md-2"><label>Name</label><input type="text" class="form-control form-control-sm _f_name" value="' + (server.name || "") + '"></div>' +
'<div class="col-md-2"><label>Interface</label><input type="text" class="form-control form-control-sm _f_interface" value="' + (server.interface || "") + '"></div>' +
'<div class="col-md-3"><label>Addresses</label><input type="text" class="form-control form-control-sm _f_addresses" value="' + csvList(server.addresses) + '"></div>' +
'<div class="col-md-1"><label>Port</label><input type="text" class="form-control form-control-sm _f_port" value="' + (server.listen_port || "") + '"></div>' +
'<div class="col-md-2"><label>MTU</label><input type="text" class="form-control form-control-sm _f_mtu" value="' + (server.mtu || "") + '"></div>' +
'</div>' +
'<div class="form-row mt-1">' +
'<div class="col-md-4"><label>DNS servers</label><input type="text" class="form-control form-control-sm _f_dns" value="' + csvList(server.dns_servers) + '"></div>' +
'<div class="col-md-4"><label>Endpoint address</label><input type="text" class="form-control form-control-sm _f_endpoint" value="' + (server.endpoint_address || "") + '"></div>' +
'</div>' +
'<table class="table table-sm mt-2 mb-0"><thead><tr><th>Client name</th><th>Allocated IP</th><th>Allowed IPs</th><th>Keepalive</th><th>Public key</th></tr></thead><tbody>';
$.each(server.clients || [], function (ci, client) {
let cwarn = "";
if (client.warnings && client.warnings.length) {
cwarn = '<br><small class="text-warning">' + client.warnings.join("; ") + '</small>';
}
html += '<tr data-clientindex="' + ci + '">' +
'<td><input type="text" class="form-control form-control-sm _c_name" value="' + (client.name || "") + '">' + cwarn + '</td>' +
'<td><input type="text" class="form-control form-control-sm _c_allocated" value="' + csvList(client.allocated_ips) + '"></td>' +
'<td><input type="text" class="form-control form-control-sm _c_allowed" value="' + csvList(client.allowed_ips) + '"></td>' +
'<td><input type="text" class="form-control form-control-sm _c_keepalive" value="' + (client.persistent_keepalive || "") + '"></td>' +
'<td><small class="text-muted">' + (client.public_key || "(missing)") + '</small></td>' +
'</tr>';
});
html += '</tbody></table></div></div>';
area.append(html);
});
$("#btn_opnsense_confirm").show();
}
$("#btn_opnsense_preview").click(function () {
const fileInput = document.getElementById('_opnsense_file');
if (!fileInput.files || fileInput.files.length === 0) {
toastr.error("Please choose a config.xml file first");
return;
}
const formData = new FormData();
formData.append('config', fileInput.files[0]);
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/import/opnsense/preview',
data: formData,
processData: false,
contentType: false,
dataType: 'json',
success: function (data) {
renderOPNsensePreview(data);
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message'] || "Could not parse config.xml");
}
});
});
// Read the (possibly edited) preview form back into the same shape the
// preview endpoint returned, so the commit endpoint gets full PreviewServer/
// PreviewClient structures - not the original XML.
function collectOPNsensePreviewEdits() {
const servers = [];
$("#_opnsense_preview_area > .card").each(function () {
const card = $(this);
const clients = [];
card.find("tbody tr").each(function () {
const row = $(this);
clients.push({
name: row.find("._c_name").val(),
allocated_ips: row.find("._c_allocated").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
allowed_ips: row.find("._c_allowed").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
persistent_keepalive: parseInt(row.find("._c_keepalive").val(), 10) || 0,
enabled: true
});
});
servers.push({
id: card.find("._f_id").val(),
name: card.find("._f_name").val(),
interface: card.find("._f_interface").val(),
addresses: card.find("._f_addresses").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
listen_port: parseInt(card.find("._f_port").val(), 10) || 0,
mtu: parseInt(card.find("._f_mtu").val(), 10) || 0,
dns_servers: card.find("._f_dns").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
endpoint_address: card.find("._f_endpoint").val(),
enabled: true,
clients: clients
});
});
return servers;
}
// The public key is display-only (not editable) and matched back to
// the original preview payload by position, since the input form
// doesn't carry it. Merge it in from the last preview response before
// sending the commit request.
function mergeOriginalPreviewFields(edited) {
if (!opnsensePreviewData || !opnsensePreviewData.servers) return edited;
$.each(edited, function (si, server) {
const orig = opnsensePreviewData.servers[si] || {};
server.private_key = orig.private_key;
server.public_key = orig.public_key;
server.source_uuid = orig.source_uuid;
$.each(server.clients, function (ci, client) {
const origClient = (orig.clients || [])[ci] || {};
client.public_key = origClient.public_key;
client.preshared_key = origClient.preshared_key;
client.additional_notes = origClient.additional_notes;
client.source_uuid = origClient.source_uuid;
});
});
return edited;
}
$("#btn_opnsense_confirm").click(function () {
if (!opnsensePreviewData) return;
const edited = mergeOriginalPreviewFields(collectOPNsensePreviewEdits());
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/import/opnsense/commit',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify({ servers: edited }),
success: function (results) {
let created = 0, failed = 0;
$.each(results, function (i, r) {
if (r.imported) { created++; } else { failed++; }
if (r.error) { toastr.error(r.id + ": " + r.error); }
if (r.clients_skipped && r.clients_skipped.length) {
toastr.warning(r.id + " skipped clients: " + r.clients_skipped.join(", "));
}
});
toastr.success("Imported " + created + " server(s)" + (failed ? (", " + failed + " failed") : ""));
$("#modal_import_opnsense").modal('hide');
$('#servers-list').empty();
populateServersList();
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message'] || "Import failed");
}
});
});
$("#modal_import_opnsense").on('show.bs.modal', function () {
$("#_opnsense_file").val("");
$("#_opnsense_preview_area").empty();
$("#btn_opnsense_confirm").hide();
opnsensePreviewData = null;
});
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function (form) {