Commit 449be9e6 authored by jan.koester's avatar jan.koester
Browse files

test

parent c8429f74
Loading
Loading
Loading
Loading
+5 −1
Changes for CMakeLists.txt: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ project(webctl VERSION 0.2.3 LANGUAGES CXX)
#   webctl-frontend  HTTPS on 0.0.0.0:8443: static UI + JSON API, every call
#                    forwarded to the backend; drops to the webctl user
#                    after binding
#   modules/         one shared object per area (dashboard, users, network, shares,
#   modules/         one shared object per area (dashboard, users, network, firewall, shares,
#                    raid, lvm, filesystems, services, journal, updates, snmp,
#                    time, ups, system),
#                    dlopen()ed by the backend from its module directory
@@ -133,6 +133,10 @@ target_link_libraries(webctl_mod_time PRIVATE sysbus::sysbus)
# over sysbus; needs upsd/api.h (upsd-dev)
webctl_module(ups modules/ups/ups.cpp)
target_link_libraries(webctl_mod_ups PRIVATE sysbus::sysbus)
# firewall: packet filter, NAT and masquerading, a client of firewalld over
# sysbus; needs firewalld/api.h (firewalld-dev)
webctl_module(firewall modules/firewall/firewall.cpp)
target_link_libraries(webctl_mod_firewall PRIVATE sysbus::sysbus)

install(TARGETS webctl-backend webctl-frontend RUNTIME DESTINATION ${CMAKE_INSTALL_SBINDIR})
install(DIRECTORY www/ DESTINATION ${WEBCTL_WWWDIR})
+273 −0
Changes for modules/firewall/firewall.cpp: 273 added lines, 0 removed lines.
Original line number Diff line number Diff line
/*******************************************************************************
Copyright (c) 2026, Jan Koester jan.koester@gmx.net
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the <organization> nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*******************************************************************************/

/* webctl module "firewall": packet filter, NAT and masquerading, entirely
 * over firewalld's sysbus API (include/firewalld/api.h). firewalld's
 * fields become camelCase keys (to_address -> toAddress); counters are
 * numbers (null: not counted), yes/no are booleans.
 *
 *   info                    -> {settings, state, rules[], nat[]}
 *       settings: enabled, input, forward, ping, log, ipv4Forwarding,
 *           ipv6Forwarding
 *       state: state, error, applied, testing, testLeft, ipv4Forwarding,
 *           ipv6Forwarding, inputPolicyPackets, forwardPolicyPackets,
 *           interfaces[], masquerade[{interface, networks, packets}]
 *       rules: {id, chain, action, protocol, ports, source, destination,
 *           in, out, family, log, enabled, comment, packets, bytes}
 *       nat: {id, type, in, out, protocol, ports, source, destination,
 *           toAddress, toPort, family, forward, enabled, comment, packets,
 *           bytes}
 *   save {settings, test}
 *   ruleAdd {rule, position, test} -> {id}, ruleEdit {id, rule, test},
 *   ruleDelete {id, test}, ruleMove {id, position, test}
 *   natAdd, natEdit, natDelete, natMove   the same for NAT rules
 *   confirm, revert         keep / undo the changes under test
 *   apply                   put the rules into the kernel again
 *
 * test: true makes the change a test that firewalld undoes after
 * TestSeconds unless confirm comes -- a rule that cuts this browser off
 * goes away by itself.
 */

#include <cctype>
#include <cstdlib>
#include <set>
#include <string>
#include <vector>

#include <firewalld/api.h>
#include <sysbus/client.h>
#include <webctl/module.h>

using webctl::error;
using webctl::jstr;
using webctl::tr;

namespace {

using Fields = std::vector<std::pair<std::string, std::string>>;

constexpr int TestSeconds = 120;

const char *yesNo(bool b) { return b ? "yes" : "no"; }

// to_address -> toAddress
std::string camel(const std::string &k) {
    std::string out;
    bool up = false;
    for (char c : k) {
        if (c == '_') up = true;
        else out += up ? (char)toupper((unsigned char)c) : c, up = false;
    }
    return out;
}

// toAddress -> to_address
std::string snake(const std::string &k) {
    std::string out;
    for (char c : k) {
        if (isupper((unsigned char)c)) out += '_', out += (char)tolower((unsigned char)c);
        else out += c;
    }
    return out;
}

// a call to firewalld; on failure the error object to return
json_object *callFirewalld(const char *method, const Fields &args, sysbus::Message &reply) {
    sysbus::Client bus;
    if (!bus.connect())
        return error(tr("sysbus isn't reachable: %1", "sysbus ist nicht erreichbar: %1", {bus.lastError()}), 503);
    Fields all = args;
    all.emplace_back("lang", webctl::language());
    reply = bus.callService(firewalld::Service, method, all, 30000);
    if (reply.type != sysbus::MessageType::Error) return nullptr;
    const std::string &name = reply.member;
    if (name == sysbus::error::ServiceUnknown)
        return error(tr("firewalld isn't running (Services: firewalld)", "firewalld läuft nicht (Dienste: firewalld)"), 503);
    int status = name == sysbus::error::AccessDenied ? 403 : name == sysbus::error::InvalidArgs ? 400 : 500;
    return error(reply.get("message", name), status);
}

json_object *value(const std::string &k, const std::string &v, const std::set<std::string> &numbers,
                   const std::set<std::string> &flags) {
    if (numbers.count(k)) return v.empty() ? nullptr : json_object_new_double(std::strtod(v.c_str(), nullptr));
    if (flags.count(k)) return v.empty() ? nullptr : json_object_new_boolean(v == "yes");
    return jstr(v);
}

bool skip(const std::string &k) { return k == "lang" || k.compare(0, 7, "sysbus.") == 0; }

// a flat reply as an object
json_object *fieldsJson(const sysbus::Message &r, const std::set<std::string> &numbers,
                        const std::set<std::string> &flags) {
    json_object *o = json_object_new_object();
    for (auto &f : r.fields)
        if (!skip(f.first)) json_object_object_add(o, camel(f.first).c_str(), value(f.first, f.second, numbers, flags));
    return o;
}

// a list reply (each entry starting with "id") as an array of objects
json_object *listJson(const sysbus::Message &r, const std::set<std::string> &flags) {
    json_object *list = json_object_new_array();
    json_object *cur = nullptr;
    for (auto &f : r.fields) {
        if (skip(f.first)) continue;
        if (f.first == "id") {
            cur = json_object_new_object();
            json_object_array_add(list, cur);
        }
        if (cur) json_object_object_add(cur, camel(f.first).c_str(), value(f.first, f.second, {"packets", "bytes"}, flags));
    }
    return list;
}

const std::set<std::string> SettingsFlags = {"enabled", "ping", "log", "ipv4_forwarding", "ipv6_forwarding"};
const std::set<std::string> RuleFlags = {"log", "enabled", "forward"};

// the object's keys as firewalld's fields: booleans yes/no, numbers
// whole; only the keys in allowed
std::string toFields(json_object *o, const std::set<std::string> &allowed, Fields &out) {
    if (!o || !json_object_is_type(o, json_type_object)) return tr("nothing given", "nichts angegeben");
    json_object_object_foreach(o, key, val) {
        std::string k = snake(key);
        if (!allowed.count(k)) continue;  // id, packets, ... come along from the list
        if (val && json_object_is_type(val, json_type_boolean))
            out.emplace_back(k, yesNo(json_object_get_boolean(val)));
        else if (val && (json_object_is_type(val, json_type_double) || json_object_is_type(val, json_type_int)))
            out.emplace_back(k, std::to_string((long long)json_object_get_double(val)));
        else
            out.emplace_back(k, val ? json_object_get_string(val) : "");
    }
    return "";
}

const std::set<std::string> SettingsKeys = {"enabled", "input", "forward", "ping", "log", "ipv4_forwarding",
                                            "ipv6_forwarding"};
const std::set<std::string> RuleKeys = {"chain", "action", "protocol", "ports", "source", "destination", "in",
                                        "out", "family", "log", "enabled", "comment"};
const std::set<std::string> NatKeys = {"type", "in", "out", "protocol", "ports", "source", "destination",
                                       "to_address", "to_port", "family", "forward", "enabled", "comment"};

json_object *change(const char *method, Fields f, const webctl_request *req) {
    if (webctl::boolean(req->args, "test")) f.emplace_back("confirm", std::to_string(TestSeconds));
    sysbus::Message r;
    if (json_object *e = callFirewalld(method, f, r)) return e;
    json_object *out = webctl::ok();
    if (r.has("id")) json_object_object_add(out, "id", jstr(r.get("id")));
    return out;
}

json_object *info() {
    sysbus::Message settings, status, rules, nat;
    if (json_object *e = callFirewalld(firewalld::method::GetSettings, {}, settings)) return e;
    if (json_object *e = callFirewalld(firewalld::method::GetStatus, {}, status)) return e;
    if (json_object *e = callFirewalld(firewalld::method::ListRules, {}, rules)) return e;
    if (json_object *e = callFirewalld(firewalld::method::ListNat, {}, nat)) return e;

    json_object *state = json_object_new_object();
    json_object *ifs = json_object_new_array(), *masq = json_object_new_array();
    json_object *m = nullptr;
    for (auto &f : status.fields) {
        const std::string &k = f.first, &v = f.second;
        if (skip(k)) continue;
        if (k == "interface") {
            json_object_array_add(ifs, jstr(v));
        } else if (k == "masquerade_interface") {
            m = json_object_new_object();
            json_object_array_add(masq, m);
            json_object_object_add(m, "interface", jstr(v));
        } else if (k == "masquerade_networks" || k == "masquerade_packets") {
            if (m)
                json_object_object_add(m, k == "masquerade_networks" ? "networks" : "packets",
                                       k == "masquerade_networks" ? jstr(v) : json_object_new_double(std::strtod(v.c_str(), nullptr)));
        } else {
            json_object_object_add(state, camel(k).c_str(),
                                   value(k, v, {"applied", "test_left", "input_policy_packets", "forward_policy_packets", "rules", "nat"},
                                         {"testing", "ipv4_forwarding", "ipv6_forwarding"}));
        }
    }
    json_object_object_add(state, "interfaces", ifs);
    json_object_object_add(state, "masquerade", masq);

    json_object *out = json_object_new_object();
    json_object_object_add(out, "settings", fieldsJson(settings, {}, SettingsFlags));
    json_object_object_add(out, "state", state);
    json_object_object_add(out, "rules", listJson(rules, RuleFlags));
    json_object_object_add(out, "nat", listJson(nat, RuleFlags));
    return out;
}

json_object *call(const webctl_request *req) {
    namespace m = firewalld::method;
    webctl::LanguageScope lang(req->lang);
    std::string action = req->action;

    if (action == "info") return info();
    if (action == "confirm") return change(m::Confirm, {}, req);
    if (action == "revert") return change(m::Revert, {}, req);
    if (action == "apply") return change(m::Apply, {}, req);
    if (action == "save") {
        json_object *s = nullptr;
        json_object_object_get_ex(req->args, "settings", &s);
        Fields f;
        std::string bad = toFields(s, SettingsKeys, f);
        if (!bad.empty()) return error(bad);
        return change(m::SetSettings, f, req);
    }

    bool nat = action.compare(0, 3, "nat") == 0;
    std::string verb = action.substr(nat ? 3 : 4);
    if ((nat || action.compare(0, 4, "rule") == 0) && (verb == "Add" || verb == "Edit" || verb == "Delete" || verb == "Move")) {
        Fields f;
        if (verb != "Add") {
            std::string id = webctl::str(req->args, "id");
            if (id.empty()) return error(tr("no rule given", "keine Regel angegeben"));
            f.emplace_back("id", id);
        }
        if (verb == "Add" || verb == "Edit") {
            json_object *r = nullptr;
            json_object_object_get_ex(req->args, "rule", &r);
            std::string bad = toFields(r, nat ? NatKeys : RuleKeys, f);
            if (!bad.empty()) return error(bad);
        }
        if (webctl::has(req->args, "position")) {
            long p = webctl::integer(req->args, "position");
            if (p < 0) return error(tr("invalid position", "ungültige Position"));
            f.emplace_back("position", std::to_string(p));
        }
        const char *method = verb == "Add"    ? (nat ? m::AddNat : m::AddRule)
                           : verb == "Edit"   ? (nat ? m::EditNat : m::EditRule)
                           : verb == "Delete" ? (nat ? m::DeleteNat : m::DeleteRule)
                                              : (nat ? m::MoveNat : m::MoveRule);
        return change(method, f, req);
    }
    return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404);
}

} // namespace

WEBCTL_MODULE({WEBCTL_MODULE_ABI, "firewall", "Firewall", 21, nullptr, nullptr, call});
+9 −2
Changes for modules/network/network.cpp: 9 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -30,11 +30,14 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * change those, start/restart/reload apply them.
 *
 *   list                                          -> {interfaces, netdevs}
 *   create  {interface, config?, match?, dhcp?, dhcp6?, address?, gateway?, address6?, gateway6?, ipv6AcceptRA?, dns?[]}
 *   update  {interface | config, match?, dhcp?, dhcp6?, address?, gateway?, address6?, gateway6?, ipv6AcceptRA?, dns?[]}
 *   create  {interface, config?, match?, dhcp?, dhcp6?, address?, gateway?, address6?, gateway6?, ipv6AcceptRA?, ipForward?, ipMasquerade?, dns?[]}
 *   update  {interface | config, match?, dhcp?, dhcp6?, address?, gateway?, address6?, gateway6?, ipv6AcceptRA?, ipForward?, ipMasquerade?, dns?[]}
 *           dhcp: DHCPv4; dhcp6: DHCPv6 when router advertisements ask for it;
 *           address6/gateway6: IPv6, next to DHCP or an IPv4 address;
 *           ipv6AcceptRA: yes|no|"" (router advertisements, "": the kernel's default)
 *           ipForward, ipMasquerade: yes|ipv4|ipv6|no|"" (IPForward=,
 *           IPMasquerade=: routing on; what comes from the interface's
 *           networks masqueraded by firewalld)
 *   an interface in the list also has liveDns[]: the name servers in use
 *   (configured, DHCP, router advertisements, DHCPv6)
 *   delete  {interface, config?}
@@ -100,6 +103,8 @@ json_object *toJson(const sysbus::InterfaceInfo &i) {
    json_object_object_add(o, "address6", jstr(i.configAddress6));
    json_object_object_add(o, "gateway6", jstr(i.gateway6));
    json_object_object_add(o, "ipv6AcceptRA", jstr(i.ipv6AcceptRA));
    json_object_object_add(o, "ipForward", jstr(i.ipForward));
    json_object_object_add(o, "ipMasquerade", jstr(i.ipMasquerade));
    json_object *dns = json_object_new_array();
    for (auto &d : i.dns) json_object_array_add(dns, jstr(d));
    json_object_object_add(o, "dns", dns);
@@ -248,6 +253,8 @@ bool settings(json_object *a, sysbus::InterfaceSettings &s, std::string &err) {
    if (webctl::has(a, "address6")) s.address6 = webctl::str(a, "address6");
    if (webctl::has(a, "gateway6")) s.gateway6 = webctl::str(a, "gateway6");
    if (webctl::has(a, "ipv6AcceptRA")) s.ipv6AcceptRA = webctl::str(a, "ipv6AcceptRA");
    if (webctl::has(a, "ipForward")) s.ipForward = webctl::str(a, "ipForward");
    if (webctl::has(a, "ipMasquerade")) s.ipMasquerade = webctl::str(a, "ipMasquerade");
    json_object *dns = nullptr;
    if (json_object_object_get_ex(a, "dns", &dns) && dns) {
        if (!json_object_is_type(dns, json_type_array)) {
+442 −0

File added.

Preview size limit exceeded, changes collapsed.

+19 −1
Changes for www/modules/network.js: 19 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -14,6 +14,11 @@
    'A static configuration needs an address.': 'Für statische Konfiguration ist eine Adresse nötig.',
    'IPv6': 'IPv6', 'IPv6 address (CIDR)': 'IPv6-Adresse (CIDR)', 'IPv6 gateway': 'IPv6-Gateway',
    'Router advertisements': 'Router-Advertisements', 'kernel default': 'Kernel-Standard',
    'Routing': 'Routing', 'Route connections coming in here': 'Hier eingehende Verbindungen weiterleiten',
    'Masquerade this network': 'Dieses Netz maskieren (Masquerading)', 'not set': 'nicht gesetzt',
    'IPv4 and IPv6': 'IPv4 und IPv6', 'IPv4 only': 'nur IPv4', 'IPv6 only': 'nur IPv6',
    'Masquerading: what comes from this interface\'s networks leaves the other interfaces with their address (a router for a LAN; firewalld does it). Either turns IP forwarding on.':
      'Masquerading: Was aus den Netzen dieses Interfaces kommt, verlässt die anderen Interfaces mit deren Adresse (ein Router für ein LAN; firewalld erledigt das). Beides schaltet die IP-Weiterleitung ein.',
    'yes: default route and address (SLAAC) from the router': 'ja: Default-Route und Adresse (SLAAC) vom Router',
    'no': 'nein',
    'IPv6 works next to DHCP or a static IPv4 address. A link-local gateway (fe80::…) is fine.':
@@ -201,6 +206,8 @@
                                  gateway6: mode === 'none' ? '' : (d.gateway6 || '').trim(),
                                  ipv6AcceptRA: mode === 'none' ? '' : (d.ipv6AcceptRA || ''),
                                  dhcp6: mode !== 'none' && !!d.dhcp6,
                                  ipForward: mode === 'none' ? '' : (d.ipForward || ''),
                                  ipMasquerade: mode === 'none' ? '' : (d.ipMasquerade || ''),
                                  dns: mode === 'none' ? [] : d.dns.split(/[\s,]+/).filter(Boolean)});
  // a static configuration: an IPv4 or an IPv6 address (or one by SLAAC)
  const staticOk = (d, mode) => mode !== 'static' || d.address.trim() || (d.address6 || '').trim() || d.ipv6AcceptRA === 'yes' || d.dhcp6;
@@ -215,7 +222,18 @@
        .map(([v, text]) => h('option', {value: v, selected: (i ? i.ipv6AcceptRA || '' : '') === v}, text)))),
    h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'dhcp6', checked: !!(i && i.dhcp6)}), t('DHCPv6 when the router asks for it')),
    h('p', {class: 'hint'}, t('IPv6 works next to DHCP or a static IPv4 address. A link-local gateway (fe80::…) is fine.') + ' ' +
      t('Router advertisements also bring name servers (RDNSS). DHCPv6 starts when they ask for it: for an address (M flag) or for name servers only (O flag); not with router advertisements off.')));
      t('Router advertisements also bring name servers (RDNSS). DHCPv6 starts when they ask for it: for an address (M flag) or for name servers only (O flag); not with router advertisements off.')),
    routingFields(i));
  // IPForward=/IPMasquerade= of i's configuration (firewalld masquerades)
  const routingFields = i => {
    const choice = (name, label) => h('label', {}, label, h('select', {name},
      [['', t('not set')], ['yes', t('IPv4 and IPv6')], ['ipv4', t('IPv4 only')], ['ipv6', t('IPv6 only')], ['no', t('no')]]
        .map(([v, text]) => h('option', {value: v, selected: (i ? i[name] || '' : '') === v}, text))));
    return h('div', {},
      h('h3', {}, t('Routing')),
      h('div', {class: 'row'}, choice('ipMasquerade', t('Masquerade this network')), choice('ipForward', t('Route connections coming in here'))),
      h('p', {class: 'hint'}, t('Masquerading: what comes from this interface\'s networks leaves the other interfaces with their address (a router for a LAN; firewalld does it). Either turns IP forwarding on.')));
  };

  function netdevDialog(kind, nd) {
    const iface = nd && interfaces.find(i => i.name === nd.name);