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:
co-authored by
Claude Sonnet 5
parent
0dbb916866
commit
388a8377cd
+241
-3
@@ -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">×</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) {
|
||||
|
||||
Reference in New Issue
Block a user