// Copyright 2022 Stan Grishin // This code wouldn't have been possible without help from [@vsviridov](https://github.com/vsviridov) "require ui"; "require rpc"; "require form"; "require baseclass"; var pkg = { get Name() { return "pbr"; }, get LuciCompat() { return 25; }, get ReadmeCompat() { return "1.2.2"; }, get URL() { return ( "https://docs.openwrt.melmac.ca/" + pkg.Name + "/" + (pkg.ReadmeCompat ? pkg.ReadmeCompat + "/" : "") ); }, get DonateURL() { return ( "https://docs.openwrt.melmac.ca/" + pkg.Name + "/" + (pkg.ReadmeCompat ? pkg.ReadmeCompat + "/" : "") + "#donate" ); }, isVersionMismatch: function (luci, pkg, rpcd) { return luci !== pkg || pkg !== rpcd || luci !== rpcd; }, formatMessage: function (info, template) { if (!template) return _("Unknown message") + "
"; return ( (Array.isArray(info) ? template.format(...info) : template.format(info || " ")) + "
" ); }, buildGatewayText: function (gw) { const gateways = Array.isArray(gw) ? gw : Object.values(gw); const lines = gateways.map((g) => { const iface = g.name; if (!iface) return ""; const dev_ipv4 = g.device_ipv4; const gw_ipv4 = g.gateway_ipv4; const dev_ipv6 = g.device_ipv6; const gw_ipv6 = g.gateway_ipv6; const default_gw = g.default; const parts = [iface]; if (dev_ipv4 && dev_ipv4 !== iface) parts.push(dev_ipv4); if (gw_ipv4) parts.push(gw_ipv4); if (gw_ipv6) { if (dev_ipv6 && dev_ipv6 !== iface) parts.push(dev_ipv6); parts.push(gw_ipv6); } let line = parts.join("/"); if (default_gw) line += " ✓"; return line; }); return lines.join("
"); }, }; var getInitList = rpc.declare({ object: "luci." + pkg.Name, method: "getInitList", params: ["name"], }); var getInitStatus = rpc.declare({ object: "luci." + pkg.Name, method: "getInitStatus", params: ["name"], }); var getInterfaces = rpc.declare({ object: "luci." + pkg.Name, method: "getInterfaces", params: ["name"], }); var getPlatformSupport = rpc.declare({ object: "luci." + pkg.Name, method: "getPlatformSupport", params: ["name"], }); var getServiceInfo = rpc.declare({ object: "service", method: "list", params: ["name", "verbose"], }); var _setInitAction = rpc.declare({ object: "luci." + pkg.Name, method: "setInitAction", params: ["name", "action"], expect: { result: false }, }); var RPC = { listeners: [], on: function (event, callback) { var pair = { event: event, callback: callback }; this.listeners.push(pair); return function unsubscribe() { this.listeners = this.listeners.filter(function (listener) { return listener !== pair; }); }.bind(this); }, emit: function (event, data) { this.listeners.forEach(function (listener) { if (listener.event === event) { listener.callback(data); } }); }, setInitAction: function (name, action) { _setInitAction(name, action) .then( function (result) { this.emit("setInitAction", { result: result, action: action }); }.bind(this), ) .catch( function (error) { // Even if RPC call fails/times out, emit event to start polling // This handles cases where the backend task starts but RPC times out this.emit("setInitAction", { timeout: true, action: action }); }.bind(this), ); }, }; // Poll service status until the expected running state is reached. // expectRunning: true for start/restart, false for stop. var pollServiceStatus = function (expectRunning, callback) { var maxAttempts = 300; // Max 5 minutes of polling var attempt = 0; var checkStatus = function () { attempt++; L.resolveDefault(getInitStatus(pkg.Name), {}) .then(function (statusData) { var isRunning = statusData && statusData[pkg.Name] && statusData[pkg.Name].running; // Check if the expected state has been reached if (expectRunning ? isRunning === true : isRunning !== true) { callback(true); } // Check if timed out else if (attempt >= maxAttempts) { callback(false, "timeout"); } // Continue polling else { setTimeout(checkStatus, 1000); } }) .catch(function (err) { // Retry on error unless timed out if (attempt < maxAttempts) { setTimeout(checkStatus, 1000); } else { callback(false, "error"); } }); }; // Start polling after a delay to give the backend time to start the task setTimeout(checkStatus, 3000); }; var status = baseclass.extend({ render: function () { return Promise.all([ L.resolveDefault(getInitStatus(pkg.Name), {}), L.resolveDefault(getServiceInfo(pkg.Name, true), {}), ]).then(function ([initStatus, ubusInfo]) { var reply = { status: initStatus?.[pkg.Name] || { enabled: null, running: null, running_iptables: null, running_nft: null, running_nft_file: null, version: null, packageCompat: 0, rpcdCompat: 0, }, ubus: ubusInfo?.[pkg.Name]?.instances?.main?.data || { packageCompat: 0, gateways: [], errors: [], warnings: [], }, }; if ( pkg.isVersionMismatch( pkg.LuciCompat, reply.status.packageCompat, reply.status.rpcdCompat, ) ) { reply.ubus.warnings.push({ code: "warningInternalVersionMismatch", info: [ reply.ubus.packageCompat, pkg.LuciCompat, reply.status.rpcdCompat, '', "", ], }); } var text; var header = E("h2", {}, _("Policy Based Routing - Status")); var statusTitle = E( "label", { class: "cbi-value-title" }, _("Service Status"), ); if (reply.status.version) { text = _("Version %s").format(reply.status.version) + " - "; if (reply.status.running) { text += _("Running"); if (reply.status.running_iptables) { text += " (" + _("iptables mode") + ")."; } else if (reply.status.running_nft_file) { text += " (" + _("fw4 nft file mode") + ")."; } else if (reply.status.running_nft) { text += " (" + _("nft mode") + ")."; } else { text += "."; } } else { if (reply.status.enabled) { text += _("Stopped."); } else { text += _("Stopped (Disabled)."); } } } else { text = _("Not installed or not found"); } var statusText = E("div", {}, text); var statusField = E("div", { class: "cbi-value-field" }, statusText); var statusDiv = E("div", { class: "cbi-value" }, [ statusTitle, statusField, ]); var gatewaysDiv = []; if (reply.ubus.gateways) { var gatewaysTitle = E( "label", { class: "cbi-value-title" }, _("Service Gateways"), ); var description = _( "The %s indicates default gateway. See the %sREADME%s for details.", ).format( "", '', "", ) + "
" + "
" + _("Please %sdonate%s to support development of this project.").format( "", "", ); var gatewaysDescr = E( "div", { class: "cbi-value-description" }, description, ); text = pkg.buildGatewayText(reply.ubus.gateways); var gatewaysText = E("div", {}, text); var gatewaysField = E("div", { class: "cbi-value-field" }, [ gatewaysText, gatewaysDescr, ]); gatewaysDiv = E("div", { class: "cbi-value" }, [ gatewaysTitle, gatewaysField, ]); } var warningsDiv = []; if (reply.ubus.warnings && reply.ubus.warnings.length) { var warningTable = { warningInternalVersionMismatch: _( "Internal version mismatch (package: %s, luci app: %s, luci rpcd: %s), you may need to update packages or reboot the device, please check the %sREADME%s.", ), warningResolverNotSupported: _( "Resolver set (%s) is not supported on this system.", ).format(L.uci.get(pkg.Name, "config", "resolver_set")), warningAGHVersionTooLow: _( "Installed AdGuardHome (%s) doesn't support 'ipset_file' option.", ), warningPolicyProcessCMD: _("%s"), warningTorUnsetParams: _( "Please unset 'src_addr', 'src_port' and 'dest_port' for policy '%s'", ), warningTorUnsetProto: _( "Please unset 'proto' or set 'proto' to 'all' for policy '%s'", ), warningTorUnsetChainIpt: _( "Please unset 'chain' or set 'chain' to 'PREROUTING' for policy '%s'", ), warningTorUnsetChainNft: _( "Please unset 'chain' or set 'chain' to 'prerouting' for policy '%s'", ), warningInvalidOVPNConfig: _( "Invalid OpenVPN config for %s interface", ), warningOutdatedLuciPackage: _( "The WebUI application (luci-app-pbr) is outdated, please update it", ), warningOutdatedPrincipalPackage: _( "The principal package (pbr) is outdated, please update it", ), warningBadNftCallsInUserFile: _( "Incompatible nft calls detected in user include file, disabling fw4 nft file support", ), warningDnsmasqInstanceNoConfdir: _( "Dnsmasq instance (%s) targeted in settings, but it doesn't have its own confdir", ), warningDhcpLanForce: _( _( "Please set 'dhcp.%%s.force=1' to speed up service start-up %s(more info)%s", ).format( "", "", ), ), warningSummary: _("Warnings encountered, please check %s"), warningIncompatibleDHCPOption6: _( "Incompatible DHCP Option 6 for interface %s", ), warningNetifdMissingInterfaceLocal: _( "Netifd setup: option netifd_interface_local is missing, assuming '%s'", ), warningUplinkDown: _( "Uplink/WAN interface is still down, going back to boot mode", ), }; var warningsTitle = E( "label", { class: "cbi-value-title" }, _("Service Warnings"), ); text = ""; reply.ubus.warnings.forEach((element) => { if (element.code && warningTable[element.code]) { text += pkg.formatMessage(element.info, warningTable[element.code]); } else { text += _("Unknown warning") + "
"; } }); text += _("Warnings encountered, please check the %sREADME%s").format( '', "!
", ); var warningsText = E("div", { class: "cbi-value-description" }, text); var warningsField = E( "div", { class: "cbi-value-field" }, warningsText, ); warningsDiv = E("div", { class: "cbi-value" }, [ warningsTitle, warningsField, ]); } var errorsDiv = []; if (reply.ubus.errors && reply.ubus.errors.length) { var errorTable = { errorConfigValidation: _("Config (%s) validation failure").format( "/etc/config/" + pkg.Name, ), errorNoIptables: _("%s binary cannot be found").format("iptables"), errorNoIpset: _( "Resolver set support (%s) requires ipset, but ipset binary cannot be found", ).format(L.uci.get(pkg.Name, "config", "resolver_set")), errorNoNft: _( "Resolver set support (%s) requires nftables, but nft binary cannot be found", ).format(L.uci.get(pkg.Name, "config", "resolver_set")), errorResolverNotSupported: _( "Resolver set (%s) is not supported on this system", ).format(L.uci.get(pkg.Name, "config", "resolver_set")), errorServiceDisabled: _( "The %s service is currently disabled", ).format(pkg.Name), errorNoUplinkGateway: _( "The %s service failed to discover uplink gateway", ).format(pkg.Name), errorNoUplinkInterface: _( "The %s interface not found, you need to set the 'pbr.config.uplink_interface' option", ), errorNoUplinkInterfaceHint: _( "Refer to %sREADME%s for details", ).format( '', "!
", ), errorIpsetNameTooLong: _( "The ipset name '%s' is longer than allowed 31 characters", ), errorNftsetNameTooLong: _( "The nft set name '%s' is longer than allowed 255 characters", ), errorUnexpectedExit: _( "Unexpected exit or service termination: '%s'", ), errorPolicyNoSrcDest: _( "Policy '%s' has no source/destination parameters", ), errorPolicyNoInterface: _("Policy '%s' has no assigned interface"), errorPolicyNoDns: _("Policy '%s' has no assigned DNS"), errorPolicyProcessNoInterfaceDns: _( "Interface '%s' has no assigned DNS", ), errorPolicyUnknownInterface: _( "Policy '%s' has an unknown interface", ), errorPolicyProcessCMD: _("%s"), errorFailedSetup: _("Failed to set up '%s'"), errorFailedReload: _("Failed to reload '%s'"), errorUserFileNotFound: _("Custom user file '%s' not found or empty"), errorUserFileSyntax: _("Syntax error in custom user file '%s'"), errorUserFileRunning: _("Error running custom user file '%s'"), errorUserFileNoCurl: _( "Use of 'curl' is detected in custom user file '%s', but 'curl' isn't installed", ), errorNoGateways: _("Failed to set up any gateway"), errorResolver: _("Resolver '%s'"), errorPolicyProcessNoIpv6: _( "Skipping IPv6 policy '%s' as IPv6 support is disabled", ), errorPolicyProcessUnknownFwmark: _( "Unknown packet mark for interface '%s'", ), errorPolicyProcessMismatchFamily: _( "Mismatched IP family between in policy '%s'", ), errorPolicyProcessUnknownProtocol: _( "Unknown protocol in policy '%s'", ), errorPolicyProcessInsertionFailed: _( "Insertion failed for both IPv4 and IPv6 for policy '%s'", ), errorPolicyProcessInsertionFailedIpv4: _( "Insertion failed for IPv4 for policy '%s'", ), errorPolicyProcessUnknownEntry: _("Unknown entry in policy '%s'"), errorInterfaceRoutingEmptyValues: _( "Received empty tid/mark or interface name when setting up routing", ), errorInterfaceMarkOverflow: _( "Interface mark for '%s' exceeds the fwmask value", ), errorFailedToResolve: _("Failed to resolve '%s'"), errorInvalidOVPNConfig: _( "Invalid OpenVPN config for '%s' interface", ), errorNftMainFileInstall: _("Failed to install fw4 nft file '%s'"), errorNoDownloadWithSecureReload: _( "Policy '%s' refers to URL which can't be downloaded in 'secure_reload' mode", ), errorDownloadUrlNoHttps: _( "Failed to download '%s', HTTPS is not supported", ), errorDownloadUrl: _("Failed to download '%s'"), errorFileSchemaRequiresCurl: _( "The '%s' schema requires curl, but it's not detected on this system", ).format("file://"), errorTryFailed: _("Command failed: '%s'"), errorIncompatibleUserFile: _( "Incompatible custom user file detected '%s'", ), errorDefaultFw4TableMissing: _("Default fw4 table '%s' is missing"), errorDefaultFw4ChainMissing: _("Default fw4 chain '%s' is missing"), errorRequiredBinaryMissing: _("Required binary '%s' is missing"), errorInterfaceRoutingUnknownDevType: _( "Unknown IPv6 Link type for device '%s'", ), errorMktempFileCreate: _( "Failed to create temporary file with mktemp mask: '%s'", ), errorSummary: _("Errors encountered, please check %s"), errorNftNetifdFileInstall: _( "Netifd setup: failed to install fw4 netifd nft file '%s'", ), errorNftNetifdFileDelete: _( "Netifd setup: failed to delete fw4 netifd nft file '%s'", ), errorNetifdMissingOption: _( "Netifd setup: required option '%s' is missing", ), errorNetifdInvalidGateway4: _( "Netifd setup: invalid value of netifd_interface_default option '%s'", ), errorNetifdInvalidGateway6: _( "Netifd setup: invalid value of netifd_interface_default6 option '%s'", ), errorUplinkDown: _( "Uplink/WAN interface is still down, increase value of 'procd_boot_trigger_delay' option", ), }; var errorsTitle = E( "label", { class: "cbi-value-title" }, _("Service Errors"), ); text = ""; reply.ubus.errors.forEach((element) => { if (element.code && errorTable[element.code]) { text += pkg.formatMessage(element.info, errorTable[element.code]); } else { text += _("Unknown error") + "
"; } }); text += _("Errors encountered, please check the %sREADME%s").format( '', "!
", ); var errorsText = E("div", { class: "cbi-value-description" }, text); var errorsField = E("div", { class: "cbi-value-field" }, errorsText); errorsDiv = E("div", { class: "cbi-value" }, [ errorsTitle, errorsField, ]); } var btn_gap = E("span", {}, "  "); var btn_gap_long = E( "span", {}, "        ", ); var btn_start = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Starting %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "start"); }, }, _("Start"), ); var btn_action = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Restarting %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "restart"); }, }, _("Restart"), ); var btn_stop = E( "button", { class: "btn cbi-button cbi-button-reset", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Stopping %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "stop"); }, }, _("Stop"), ); var btn_enable = E( "button", { class: "btn cbi-button cbi-button-apply", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Enabling %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "enable"); }, }, _("Enable"), ); var btn_disable = E( "button", { class: "btn cbi-button cbi-button-reset", disabled: true, click: function (ev) { ui.showModal(null, [ E( "p", { class: "spinning" }, _("Disabling %s service").format(pkg.Name), ), ]); return RPC.setInitAction(pkg.Name, "disable"); }, }, _("Disable"), ); if (reply.status.enabled) { btn_enable.disabled = true; btn_disable.disabled = false; if (reply.status.running) { btn_start.disabled = true; btn_action.disabled = false; btn_stop.disabled = false; } else { btn_start.disabled = false; btn_action.disabled = true; btn_stop.disabled = true; } } else { btn_start.disabled = true; btn_action.disabled = true; btn_stop.disabled = true; btn_enable.disabled = false; btn_disable.disabled = true; } var buttonsTitle = E( "label", { class: "cbi-value-title" }, _("Service Control"), ); var buttonsText = E("div", {}, [ btn_start, btn_gap, btn_action, btn_gap, btn_stop, btn_gap_long, btn_enable, btn_gap, btn_disable, ]); var buttonsField = E("div", { class: "cbi-value-field" }, buttonsText); var buttonsDiv = reply.status.version ? E("div", { class: "cbi-value" }, [buttonsTitle, buttonsField]) : ""; var donateTitle = E( "label", { class: "cbi-value-title" }, _("Donate to the Project"), ); var donateText = E( "div", { class: "cbi-value-field" }, E( "div", { class: "cbi-value-description" }, _("Please %sdonate%s to support development of this project.").format( "", "", ), ), ); var donateDiv = reply.status.version ? E("div", { class: "cbi-value" }, [donateTitle, donateText]) : ""; return E("div", {}, [ header, statusDiv, gatewaysDiv, warningsDiv, errorsDiv, buttonsDiv, // donateDiv, ]); }); }, }); RPC.on("setInitAction", function (reply) { var action = reply && reply.action; if (action === "start" || action === "restart" || action === "reload") { // Long-running: poll until service is running pollServiceStatus(true, function () { ui.hideModal(); location.reload(); }); } else if (action === "stop") { // Poll until service has stopped pollServiceStatus(false, function () { ui.hideModal(); location.reload(); }); } else { // enable/disable are fast, just reload immediately ui.hideModal(); location.reload(); } }); return L.Class.extend({ status: status, pkg: pkg, getInitStatus: getInitStatus, getInterfaces: getInterfaces, getPlatformSupport: getPlatformSupport, });