aredn/files/app/main/status/e/dhcp.ut

680 lines
30 KiB
Plaintext
Executable File

{%
/*
* Part of AREDN® -- Used for creating Amateur Radio Emergency Data Networks
* Copyright (C) 2024 Tim Wilkinson
* See Contributors file for additional contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Additional Terms:
*
* Additional use restrictions exist on the AREDN® trademark and logo.
* See AREDNLicense.txt for more info.
*
* Attributions to the AREDN® Project must be retained in the source code.
* If importing this code into a new or existing project attribution
* to the AREDN® project must be added to the source code.
*
* You must not misrepresent the origin of the material contained within.
*
* Modified versions must be modified to attribute to the original source
* and be marked in reasonable ways as differentiate it from the original
* version
*/
%}
{%
if (request.env.REQUEST_METHOD === "PUT") {
if ("options" in request.args) {
configuration.prepareChanges();
const options = json(request.args.options);
const dhcp = configuration.getDHCP();
let f = fs.open(dhcp.reservations, "w");
if (f) {
const base = iptoarr(dhcp.start)[3];
for (let i = 0; i < length(options); i++) {
const o = options[i];
if (o.reserved) {
const ip = iptoarr(o.ip)[3];
if (length(o.name) > 0 && ip >= base && match(o.mac, /^([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]$/)) {
f.write(`${o.mac} ${ip - base + 2} ${o.name}${o.noprop ? " #NOPROP" : ""}\n`);
}
}
}
f.close();
}
}
if ("advtags" in request.args) {
configuration.prepareChanges();
const advtags = json(request.args.advtags);
const dhcp = configuration.getDHCP();
let f = fs.open(dhcp.dhcptags, "w");
if (f) {
for (let i = 0; i < length(advtags); i++) {
const t = advtags[i];
f.write(`${t.name} ${t.type} ${t.match}\n`);
}
f.close();
}
}
if ("advoptions" in request.args) {
configuration.prepareChanges();
const advoptions = json(request.args.advoptions);
const dhcp = configuration.getDHCP();
let f = fs.open(dhcp.dhcpoptions, "w");
if (f) {
for (let i = 0; i < length(advoptions); i++) {
const o = advoptions[i];
f.write(`${o.name} ${o.always ? "force" : "onrequest"} ${o.type} ${o.value}\n`);
}
f.close();
}
}
print(_R("changes"));
return;
}
if (request.env.REQUEST_METHOD === "DELETE") {
configuration.revertModalChanges();
print(_R("changes"));
return;
}
const dhcp = configuration.getDHCP();
const start = iptoarr(dhcp.start);
const end = iptoarr(dhcp.end);
const leases = [];
const options = [];
const advoptions = [];
const advtags = [];
for (let i = start[3]; i <= end[3]; i++) {
push(options, { mac: "", ip: `${start[0]}.${start[1]}.${start[2]}.${i}`, name: "", noprop: false, reserved: false, leased: false });
}
let reservations = 0;
let active = 0;
let f = fs.open(dhcp.reservations);
if (f) {
for (let l = f.read("line"); length(l); l = f.read("line")) {
// mac, last-ip, name, flags
const v = match(trim(l), /^([^ ]+) ([^ ]+) ([^ ]+) ?(.*)/);
if (v) {
const o = options[int(v[2]) - 2];
if (o) {
o.mac = v[1];
o.name = v[3];
o.noprop = v[4] == "#NOPROP";
o.reserved = true;
reservations++;
}
}
}
f.close();
}
f = fs.open(dhcp.leases);
if (f) {
for (let l = f.read("line"); length(l); l = f.read("line")) {
// ?, mac, ip, name, ?
const v = match(l, /^(.+) (.+) (.+) (.+) (.+)$/);
if (v) {
const ip = iptoarr(v[3]);
const o = options[ip[3] - start[3]];
if (o) {
o.leased = true;
if (o.mac === "") {
o.mac = v[2];
}
if (o.name === "") {
o.name = v[4];
}
active++;
}
}
}
f.close();
}
f = fs.open(dhcp.dhcptags);
if (f) {
for (let l = f.read("line"); length(l); l = f.read("line")) {
const m = match(replace(l, /\n+$/, ""), /^([^\W_]+)\s(\w+)\s(.+)$/);
if (m) {
const p = replace(replace(replace(m[3], /"/g, "&quot;"), /</g, "&lt;"), />/g, "&gt;");
push(advtags, { name: m[1], type: m[2], match: p });
}
}
f.close();
}
f = fs.open(dhcp.dhcpoptions);
if (f) {
for (let l = f.read("line"); length(l); l = f.read("line")) {
const m = match(replace(l, /\n+$/, ""), /^(\S*)\s(force|onrequest)\s(\d+)\s(.+)$/);
if (m) {
push(advoptions, { name: m[1], always: m[2] === "force", type: int(m[3]), value: m[4] });
}
}
f.close();
}
const dhcpOptionTypes = {
"1": "netmask",
"2": "time-offset",
"3": "router",
"6": "dns-server",
"7": "log-server",
"9": "lpr-server",
"13": "boot-file-size",
"15": "domain-name",
"16": "swap-server",
"17": "root-path",
"18": "extension-path",
"19": "ip-forward-enable",
"20": "non-local-source-routing",
"21": "policy-filter",
"22": "max-datagram-reassembly",
"23": "default-ttl",
"26": "mtu",
"27": "all-subnets-local",
"31": "router-discovery",
"32": "router-solicitation",
"33": "static-route",
"34": "trailer-encapsulation",
"35": "arp-timeout",
"36": "ethernet-encap",
"37": "tcp-ttl",
"38": "tcp-keepalive",
"40": "nis-domain",
"41": "nis-server",
"42": "ntp-server",
"44": "netbios-ns",
"45": "netbios-dd",
"46": "netbios-nodetype",
"47": "netbios-scope",
"48": "x-windows-fs",
"49": "x-windows-dm",
"58": "T1",
"59": "T2",
"60": "vendor-class",
"64": "nis+-domain",
"65": "nis+-server",
"66": "tftp-server",
"67": "bootfile-name",
"68": "mobile-ip-home",
"69": "smtp-server",
"70": "pop3-server",
"71": "nntp-server",
"74": "irc-server",
"77": "user-class",
"80": "rapid-commit",
"93": "client-arch",
"94": "client-interface-id",
"97": "client-machine-id",
"100": "posix-timezone",
"101": "tzdb-timezone",
"108": "ipv6-only",
"119": "domain-search",
"120": "sip-server",
"121": "classless-static-route",
"125": "vendor-id-encap",
"150": "tftp-server-address",
"255": "server-ip-address"
};
%}
<div class="dialog">
{{_R("dialog-header", "LAN DHCP")}}
<div>
<div class="dhcp">
<div>
<div class="cols">
<div>
<div class="o">Address Reservations</div>
<div class="m">Hostnames with fixed addresses</div>
</div>
<button class="plus" {{length(options) === reservations ? "disabled" : ""}}>+</button>
</div>
</div>
{{_H("Creates a permenant mapping between a device MAC address and an IP address on the LAN network.
The given hostname is available to everyone on the mesh unless the entry is marked as <b>do not propagate</b>")}}
<form>
<div id="dhcp-reservations">
<div class="reservation-label adr">
<div>
<div>hostname</div>
<div>ip address</div>
<div>mac a&zwnj;ddress</div>
<div>do not propagate</div>
</div>
<div></div>
</div>
{% if (reservations > 0) {
for (let i = 0; i < length(options); i++) {
const o = options[i];
if (o.reserved) {
%}
<div class="reservation adr" data-ip="{{o.ip}}">
<div>
<input name="hostname" type="text" required placeholder="hostname" value="{{o.name}}">
<select class="dhcp-addresses">
</select>
<input name="mac" type="text" required placeholder="mac a&zwnj;ddress" pattern="([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]" value="{{o.mac}}">
<label><input type="checkbox" {{o.noprop ? "checked" : ""}}></label>
</div>
<button>-</button>
</div>
{% }
}
} %}
</div>
</form>
<hr>
<div class="o">Active Leases</div>
<div class="m">Addresses currently in use</div>
{{_H("The list of active leases currently allocated to LAN devices. Any of these leases can be promoted
to a permanent mapping to allow IP Addresses to be fixed to specific devices.")}}
{% if (active > 0) {
%}
<div class="lease-label adr">
<div>
<div>hostname</div>
<div>ip address</div>
<div>mac a&zwnj;ddress</div>
</div>
<div></div>
</div>
{%
for (let i = 0; i < length(options); i++) {
const o = options[i];
if (o.leased) {
%}
<div class="lease adr" data-ip="{{o.ip}}">
<div>
<input readonly type="text" value="{{o.name}}">
<input readonly type="text" value="{{o.ip}}">
<input readonly type="text" value="{{o.mac}}">
</div>
<button {{o.reserved ? "disabled" : ""}}>+</button>
</div>
{% }
}
} %}
</div>
{{_R("dialog-advanced")}}
<div>
{% if (includeAdvanced) { %}
<div class="dhcp-tags">
<div class="cols">
<div>
<div class="o">Tags</div>
<div class="m">Tags for advanced options</div>
</div>
<button>+</button>
</div>
<div class="dhcptag-label adr">
<div class="row">
<div>tag</div>
<div>type</div>
<div>match</div>
<div></div>
</div>
<div></div>
</div>
<div class="list noborder">{%
for (let i = 0; i < length(advtags); i++) {
const t = advtags[i];
%}<div class="tag adr">
<div class="row">
<input name="tag_name" type="text" required value="{{t.name}}">
<select name="tag_type" required>
<option value="">-</option>
<option value="vendorclass" {{t.type == "vendorclass" ? "selected": ""}}>Vendor Class</option>
<option value="userclass" {{t.type == "userclass" ? "selected": ""}}>User Class</option>
<option value="mac" {{t.type == "mac" ? "selected": ""}}>MAC Address</option>
<option value="circuitid" {{t.type == "circuitid" ? "selected": ""}}>Agent Circuit ID</option>
<option value="remoteid" {{t.type == "remoteid" ? "selected": ""}}>Agent Remote ID</option>
<option value="subscriberid" {{t.type == "subscriberid" ? "selected": ""}}>Subscriber-ID</option>
</select>
<input name="tag_match" type="text" required value="{{t.match}}">
<div></div>
</div>
<button>-</button>
</div>{%
}
%}</div>
</div>
<div class="dhcp-options">
<div class="cols">
<div>
<div class="o">Options</div>
<div class="m">Advanced options</div>
</div>
<button>+</button>
</div>
<div class="dhcpoption-label adr">
<div class="row">
<div>tag</div>
<div>option</div>
<div>value</div>
<div>always</div>
</div>
<div></div>
</div>
<div class="list noborder">{%
for (let i = 0; i < length(advoptions); i++) {
const o = advoptions[i];
%}<div class="option adr">
<div class="row">
<select name="option_name">
<option value="{{o.name}}" selected>{{o.name}}</option>
</select>
<input name="option_type" type="text" required list="dhcp-option-type-list" value="{{dhcpOptionTypes[o.type] ? `${o.type}: ${dhcpOptionTypes[o.type]}` : o.type}}" pattern="([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(: .+)?">
<input name="option_value" type="text" required value="{{o.value}}">
<label><input name="option_always" type="checkbox" {{o.always ? "checked" : ""}}></label>
</div>
<button>-</button>
</div>{%
}
%}</div>
</div>
{% } %}
</div>
</div>
<datalist id="dhcp-option-type-list">
{%
for (let k in dhcpOptionTypes) {
print(`<option value="${k}: ${dhcpOptionTypes[k]}"></option>`);
}
%}
</datalist>
{{_R("dialog-footer")}}
<script>
(function(){
{{_R("open")}}
const options = {{sprintf("%J", options)}};
function update()
{
htmx.ajax("PUT", "{{request.env.REQUEST_URI}}", {
swap: "none",
values: { options: JSON.stringify(options) }
});
}
function refreshIPSelectors()
{
const addrs = htmx.findAll(".dhcp-addresses");
for (let i = 0; i < addrs.length; i++) {
let opt = "";
for (let j = 0; j < options.length; j++) {
const o = options[j];
const self = o.ip === addrs[i].parentNode.parentNode.dataset.ip;
if (self || (!o.leased && !o.reserved)) {
opt += `<option value="${o.ip}" ${self ? "selected" : ""}>${o.ip}</option>`;
}
}
addrs[i].innerHTML = opt;
}
}
refreshIPSelectors();
htmx.on("#ctrl-modal .dialog .dhcp", "change", event => {
const target = event.target;
switch (target.nodeName) {
case "SELECT":
{
const oldip = target.parentNode.parentNode.dataset.ip;
const newip = target.value;
const oo = options.find(o => o.ip == oldip);
const no = options.find(o => o.ip == newip);
Object.assign(no, { name: oo.name, mac: oo.mac, noprop: oo.noprop, reserved: true, leased: false });
Object.assign(oo, { name: "", mac: "", noprop: false, reserved: false, leased: false });
target.parentNode.parentNode.dataset.ip = newip;
refreshIPSelectors();
update();
break;
}
case "INPUT":
switch (target.type) {
case "text":
{
if (target.validity.valid) {
const ip = target.parentNode.parentNode.dataset.ip;
const o = options.find(o => o.ip == ip);
if (target.name === "hostname") {
o.name = target.value;
}
else {
o.mac = target.value;
}
update();
}
break;
}
case "checkbox":
{
const ip = target.parentNode.parentNode.parentNode.dataset.ip;
const o = options.find(o => o.ip == ip);
o.noprop = target.checked;
update();
break;
}
default:
break;
}
break;
default:
break;
}
});
htmx.on("#ctrl-modal .dialog .dhcp", "click", event => {
const target = event.target;
switch (target.nodeName) {
case "BUTTON":
switch (target.innerText) {
case "+":
{
const ip = target.parentNode.dataset.ip;
if (ip) {
target.disabled = true;
const o = options.find(o => o.ip == ip);
o.reserved = true;
o.noprop = true;
const item = document.createElement("div");
item.innerHTML = `<div class="reservation adr" data-ip="${o.ip}"><div style="white-space:nowrap"><input name="hostname" type="text" placeholder="hostname" value="${o.name}" required> <select class="dhcp-addresses"></select> <input name="mac" type="text" placeholder="mac a&zwnj;ddress" required pattern="([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]" value="${o.mac}"><label> <input type="checkbox" ${o.noprop ? "checked" : ""}></label></div><button>-</button></div>`;
htmx.find("#dhcp-reservations").appendChild(item.firstChild);
update();
refreshIPSelectors();
}
else {
function newDHCPEntry()
{
const o = options.find(o => !o.reserved && !o.leased);
if (o) {
Object.assign(o, { name: "", mac: "", noprop: true, reserved: true, leased: false });
const item = document.createElement("div");
item.innerHTML = `<div class="reservation adr" data-ip="${o.ip}"><div style="white-space:nowrap"><input name="hostname" type="text" placeholder="hostname" value="${o.name}" required> <select class="dhcp-addresses"></select> <input name="mac" type="text" placeholder="mac a&zwnj;ddress" required pattern="([0-9a-fA-F][0-9a-fA-F]:){5}[0-9a-fA-F][0-9a-fA-F]" value="${o.mac}"><label> <input type="checkbox" ${o.noprop ? "checked" : ""}></label></div><button>-</button></div>`;
const fc = item.firstChild;
htmx.find("#dhcp-reservations").appendChild(fc);
htmx.find(fc, "input").focus();
htmx.on(fc, "keypress", event => {
if (event.keyCode === 13 && event.target.nodeName == "INPUT" && htmx.closest(event.target, ".reservation") === htmx.find("#dhcp-reservations .reservation:last-child")) {
event.preventDefault();
newDHCPEntry();
}
});
}
if (!options.find(o => !o.reserved && !o.leased)) {
htmx.find("#ctrl-modal .dialog .dhcp .plus").disabled = true;
}
refreshIPSelectors();
}
newDHCPEntry();
}
break;
}
case "-":
{
const ip = target.parentNode.dataset.ip;
const o = options.find(o => o.ip == ip);
o.reserved = false;
if (o.leased) {
const l = htmx.find(`#ctrl-modal .dialog .lease[data-ip="${ip}"] button`);
l.disabled = false;
}
const item = target.parentNode;
htmx.remove(item);
htmx.find("#ctrl-modal .dialog .dhcp button").disabled = false;
refreshIPSelectors();
update();
break;
}
default:
break;
}
break;
default:
break;
}
});
{% if (includeAdvanced) { %}
function refreshAdvOptions()
{
const tagnames = [];
const names = htmx.findAll("#ctrl-modal .dialog .dhcp-tags .list .row input[name=tag_name]");
for (let i = 0; i < names.length; i++) {
if (names[i].validity.valid && tagnames.indexOf(names[i].value) === -1) {
tagnames.push(names[i].value);
}
}
const options = htmx.findAll("#ctrl-modal .dialog .dhcp-options .list .row select[name=option_name]");
for (let i = 0; i < options.length; i++) {
const v = options[i].value;
options[i].innerHTML = "<option value=''>[all]</option>" + tagnames.map(t => `<option value="${t}" ${t === v ? "selected": ""}>${t}</option>`).join("");
}
}
function updateTags()
{
const advtags = [];
const tags = htmx.findAll("#ctrl-modal .dialog .dhcp-tags .list .row");
for (let i = 0; i < tags.length; i++) {
const name = htmx.find(tags[i], "input[name=tag_name]");
const type = htmx.find(tags[i], "select[name=tag_type]");
const match = htmx.find(tags[i], "input[name=tag_match]");
if (name.validity.valid && type.validity.valid && match.validity.valid) {
advtags.push({ name: name.value, type: type.value, match: match.value });
}
}
htmx.ajax("PUT", "{{request.env.REQUEST_URI}}", {
swap: "none",
values: { advtags: JSON.stringify(advtags) }
});
}
const dhcpOptionTypes = {%print(dhcpOptionTypes);%};
function updateOptions()
{
const advoptions = [];
const options = htmx.findAll("#ctrl-modal .dialog .dhcp-options .list .row");
for (let i = 0; i < options.length; i++) {
const name = htmx.find(options[i], "select[name=option_name]");
const type = htmx.find(options[i], "input[name=option_type]");
const value = htmx.find(options[i], "input[name=option_value]");
const always = htmx.find(options[i], "input[name=option_always]");
const tvalue = type.validity.valid ? `${parseInt(type.value)}` : "";
if (name.validity.valid && type.validity.valid && value.validity.valid) {
advoptions.push({ name: name.value, type: tvalue, value: value.value, always: !!always.checked });
}
if (dhcpOptionTypes[tvalue]) {
type.value = `${tvalue}: ${dhcpOptionTypes[tvalue]}`;
}
}
htmx.ajax("PUT", "{{request.env.REQUEST_URI}}", {
swap: "none",
values: { advoptions: JSON.stringify(advoptions) }
});
}
htmx.on("#ctrl-modal .dialog .dhcp-tags", "click", event => {
const target = event.target;
switch (target.nodeName) {
case "BUTTON":
switch (target.innerText) {
case "+":
function addNewTag()
{
const item = document.createElement("div");
item.innerHTML = `<div class="tag adr"><div class="row"><input name="tag_name" type="text" required value=""> <select name="tag_type" required><option value="">-</option><option value="vendorclass">Vendor Class</option><option value="userclass">User Class</option><option value="mac">MAC Address</option><option value="circuitid">Agent Circuit ID</option><option value="remoteid">Agent Remote ID</option><option value="subscriberid">Subscriber-ID</option></select> <input name="tag_match" type="text" required value=""><div></div></div><button>-</button></div>`;
const fc = item.firstChild;
htmx.find("#ctrl-modal .dialog .dhcp-tags .list").appendChild(fc);
htmx.find(fc, "input").focus();
htmx.on(fc, "keypress", event => {
if (event.keyCode === 13 && event.target.nodeName == "INPUT" && htmx.closest(event.target, ".tag") === htmx.find(".dhcp-tags .list .tag:last-child")) {
event.preventDefault();
addNewTag();
}
});
}
addNewTag();
break;
case "-":
const row = target.parentNode;
row.parentNode.removeChild(row);
refreshAdvOptions();
updateTags();
updateOptions();
break;
default:
break;
}
break;
default:
break;
}
});
htmx.on("#ctrl-modal .dialog .dhcp-tags", "change", _ => {
refreshAdvOptions();
updateTags();
});
htmx.on("#ctrl-modal .dialog .dhcp-options", "click", event => {
const target = event.target;
switch (target.nodeName) {
case "BUTTON":
switch (target.innerText) {
case "+":
function addNewOption()
{
const item = document.createElement("div");
item.innerHTML = `<div class="option adr"><div class="row"><select name="option_name"><option value="">[all]</option></select> <input name="option_type" type="text" required list="dhcp-option-type-list" pattern="([1-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(: .+)?"> <input name="option_value" type="text" required value=""> <label><input name="option_always" type="checkbox"></label></div><button>-</button></div>`;
const fc = item.firstChild;
htmx.find("#ctrl-modal .dialog .dhcp-options .list").appendChild(fc);
refreshAdvOptions();
htmx.find(fc, "select").focus();
htmx.on(fc, "keypress", event => {
if (event.keyCode === 13 && event.target.nodeName == "INPUT" && htmx.closest(event.target, ".option") === htmx.find(".dhcp-options .list .option:last-child")) {
event.preventDefault();
addNewOption();
}
});
}
addNewOption();
break;
case "-":
const row = target.parentNode;
row.parentNode.removeChild(row);
updateOptions();
break;
default:
break;
}
break;
default:
break;
}
});
htmx.on("#ctrl-modal .dialog .dhcp-options", "change", _ => {
updateOptions();
});
refreshAdvOptions();
{% } %}
})();
</script>
</div>