Commit 547b5c10 authored by jan.koester's avatar jan.koester
Browse files

test

parent 673c174f
Loading
Loading
Loading
Loading
+1 −1
Changes for CMakeLists.txt: 1 added line, 1 removed line.
Original line number Diff line number Diff line
cmake_minimum_required(VERSION 3.16)
project(webctl VERSION 0.1.0 LANGUAGES CXX)
project(webctl VERSION 0.2.0 LANGUAGES CXX)

# webctl: a web interface for FenrirOS.
#
+79 −1
Changes for modules/network/network.cpp: 79 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -29,12 +29,18 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * Configurations are syslite's .network files; create/update/delete only
 * change those, start/restart/reload apply them.
 *
 *   list                                          -> {interfaces}
 *   list                                          -> {interfaces, netdevs}
 *   create  {interface, config?, match?, dhcp?, address?, gateway?, dns?[]}
 *   update  {interface | config, match?, dhcp?, address?, gateway?, dns?[]}
 *   delete  {interface, config?}
 *   start | stop | restart  {interface}
 *   reload | forceReload    {interface?}          (none: all)
 *
 * Bonds and bridges (.netdev files; reload without an interface creates
 * them and adds their members):
 *   createNetdev  {netdev, kind: bond|bridge, mode?, miimon?, lacpRate?, hashPolicy?, stp?, members?[]}
 *   updateNetdev  {netdev, mode?, miimon?, lacpRate?, hashPolicy?, stp?, members?[]}
 *   deleteNetdev  {netdev}                        (also removes the device)
 */

#include <string>
@@ -78,9 +84,58 @@ json_object *toJson(const sysbus::InterfaceInfo &i) {
    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);
    json_object_object_add(o, "kind", jstr(i.kind));
    json_object_object_add(o, "master", jstr(i.master));
    json_object_object_add(o, "memberOf", jstr(i.memberOf));
    return o;
}

json_object *netdevJson(const sysbus::NetDevInfo &n) {
    json_object *o = json_object_new_object();
    json_object_object_add(o, "name", jstr(n.name));
    json_object_object_add(o, "kind", jstr(n.kind));
    json_object_object_add(o, "mode", jstr(n.mode));
    json_object_object_add(o, "miimon", json_object_new_int(n.miimonMs));
    json_object_object_add(o, "lacpRate", jstr(n.lacpRate));
    json_object_object_add(o, "hashPolicy", jstr(n.hashPolicy));
    json_object_object_add(o, "stp", json_object_new_boolean(n.stp));
    json_object_object_add(o, "config", jstr(n.config));
    json_object_object_add(o, "configPath", jstr(n.configPath));
    json_object_object_add(o, "exists", json_object_new_boolean(n.exists));
    json_object *m = json_object_new_array();
    for (auto &x : n.members) json_object_array_add(m, jstr(x));
    json_object_object_add(o, "members", m);
    return o;
}

bool netdevSettings(json_object *a, sysbus::NetDevSettings &s, std::string &err) {
    s.netdev = webctl::str(a, "netdev");
    s.kind = webctl::str(a, "kind");
    if (webctl::has(a, "mode")) s.mode = webctl::str(a, "mode");
    if (webctl::has(a, "miimon")) s.miimonMs = (int)webctl::integer(a, "miimon", 100);
    if (webctl::has(a, "lacpRate")) s.lacpRate = webctl::str(a, "lacpRate");
    if (webctl::has(a, "hashPolicy")) s.hashPolicy = webctl::str(a, "hashPolicy");
    if (webctl::has(a, "stp")) s.stp = webctl::boolean(a, "stp");
    json_object *members = nullptr;
    if (json_object_object_get_ex(a, "members", &members) && members) {
        if (!json_object_is_type(members, json_type_array)) {
            err = tr("members must be a list of interfaces", "members muss eine Liste von Interfaces sein");
            return false;
        }
        std::vector<std::string> list;
        for (size_t i = 0; i < json_object_array_length(members); ++i) {
            const char *m = json_object_get_string(json_object_array_get_idx(members, i));
            if (m && *m) list.push_back(m);
        }
        s.members = list;
    }
    if (s.netdev.empty()) {
        err = tr("no bond or bridge name", "kein Name für Bond oder Bridge angegeben");
        return false;
    }
    return true;
}

bool settings(json_object *a, sysbus::InterfaceSettings &s, std::string &err) {
    s.interface = webctl::str(a, "interface");
    s.config = webctl::str(a, "config");
@@ -118,11 +173,34 @@ json_object *call(const webctl_request *req) {
            json_object_put(list);
            return busError(bus);
        }
        json_object *netdevs = json_object_new_array();
        for (auto &n : bus.listNetDevs()) json_object_array_add(netdevs, netdevJson(n));
        if (!bus.lastErrorName().empty()) {
            json_object_put(list);
            json_object_put(netdevs);
            return busError(bus);
        }
        json_object *out = json_object_new_object();
        json_object_object_add(out, "interfaces", list);
        json_object_object_add(out, "netdevs", netdevs);
        return out;
    }

    if (action == "createNetdev" || action == "updateNetdev") {
        sysbus::NetDevSettings s;
        std::string err;
        if (!netdevSettings(a, s, err)) return error(err);
        if (action == "createNetdev" && s.kind != "bond" && s.kind != "bridge")
            return error(tr("kind must be bond or bridge", "Art muss bond oder bridge sein"));
        bool done = action == "createNetdev" ? bus.createNetDev(s) : bus.editNetDev(s);
        return done ? webctl::ok() : busError(bus);
    }
    if (action == "deleteNetdev") {
        std::string n = webctl::str(a, "netdev");
        if (n.empty()) return error(tr("no bond or bridge name", "kein Name für Bond oder Bridge angegeben"));
        return bus.deleteNetDev(n) ? webctl::ok() : busError(bus);
    }

    std::string iface = webctl::str(a, "interface");
    bool ok;
    if (action == "create" || action == "update") {
+184 −4
Changes for www/modules/network.js: 184 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -28,18 +28,196 @@
    'Reload': 'Neu laden', 'Configure': 'Konfigurieren', 'Delete config': 'Konfig. löschen',
    '{0} started.': '{0} gestartet.', '{0} stopped.': '{0} gestoppt.', '{0} restarted.': '{0} neu gestartet.',
    '{0} reloaded.': '{0} neu geladen.', 'No interfaces found.': 'Keine Interfaces gefunden.',
    'Bonds and bridges': 'Bonds und Bridges', 'New bond': 'Neues Bond', 'New bridge': 'Neue Bridge',
    'No bonds or bridges.': 'Keine Bonds oder Bridges.', 'Name': 'Name', 'Kind': 'Art', 'Members': 'Mitglieder',
    'bond': 'Bond', 'bridge': 'Bridge', 'Edit': 'Bearbeiten', 'Delete': 'Löschen',
    'member of {0}': 'Mitglied von {0}', 'not created yet': 'noch nicht angelegt', 'STP on': 'STP an', 'STP off': 'STP aus',
    'Edit {0}': '{0} bearbeiten', 'Mode': 'Modus',
    'Round robin (balance-rr)': 'Reihum (balance-rr)', 'Active backup (active-backup)': 'Aktiv/Reserve (active-backup)',
    'XOR (balance-xor)': 'XOR (balance-xor)', 'Broadcast (broadcast)': 'Broadcast (broadcast)',
    'LACP (802.3ad)': 'LACP (802.3ad)', 'Adaptive transmit (balance-tlb)': 'Adaptives Senden (balance-tlb)',
    'Adaptive load balancing (balance-alb)': 'Adaptiver Lastausgleich (balance-alb)',
    'LACP rate': 'LACP-Rate', 'slow (30 s)': 'langsam (30 s)', 'fast (1 s)': 'schnell (1 s)',
    'Transmit hash policy': 'Hash-Verfahren beim Senden', 'Link monitoring (ms)': 'Link-Überwachung (ms)',
    'LACP needs a switch with link aggregation (802.3ad) on these ports.':
      'LACP braucht einen Switch mit Link-Aggregation (802.3ad) auf diesen Ports.',
    'Spanning tree (STP)': 'Spanning Tree (STP)',
    'Only needed when the bridge connects networks that are also connected elsewhere (loops).':
      'Nur nötig, wenn die Bridge Netze verbindet, die auch anderweitig verbunden sind (Schleifen).',
    'Member interfaces': 'Mitglieds-Interfaces', 'No free interfaces.': 'Keine freien Interfaces.',
    'Members lose their own addresses: the {0} has them. Changing the interface this page runs over can cut the connection.':
      'Mitglieder verlieren ihre eigenen Adressen, die hat dann das {0}. Änderungen am Interface, über das diese Seite läuft, können die Verbindung trennen.',
    'Addresses of {0}': 'Adressen von {0}',
    'Name (e.g. bond0)': 'Name (z. B. bond0)', 'Name (e.g. br0)': 'Name (z. B. br0)',
    'A name is needed.': 'Ein Name ist nötig.',
    'Names: letters, digits, "-", "_" and ".", at most 15 characters.':
      'Namen: Buchstaben, Ziffern, „-“, „_“ und „.“, höchstens 15 Zeichen.',
    'Select at least one member.': 'Mindestens ein Mitglied auswählen.',
    '{0} saved and applied.': '{0} gespeichert und angewendet.', '{0} saved.': '{0} gespeichert.',
    'Delete {0}?': '{0} löschen?',
    '{0} and its configuration are removed; its members get their own configuration again (usually DHCP).':
      '{0} und seine Konfiguration werden entfernt; seine Mitglieder bekommen wieder eine eigene Konfiguration (meist DHCP).',
    '{0} deleted.': '{0} gelöscht.',
  }});

  let root;
  let interfaces = [];
  let netdevs = [];

  async function load() {
    const r = await run(() => call('network', 'list'));
    if (!r) return;
    interfaces = r.interfaces || [];
    netdevs = r.netdevs || [];
    draw();
  }

  const bondModes = [
    ['balance-rr', 'Round robin (balance-rr)'], ['active-backup', 'Active backup (active-backup)'],
    ['balance-xor', 'XOR (balance-xor)'], ['broadcast', 'Broadcast (broadcast)'], ['802.3ad', 'LACP (802.3ad)'],
    ['balance-tlb', 'Adaptive transmit (balance-tlb)'], ['balance-alb', 'Adaptive load balancing (balance-alb)'],
  ];
  const hashPolicies = ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4', 'vlan+srcmac'];
  const ifnameOk = n => /^[A-Za-z0-9_.-]{1,15}$/.test(n) && n !== '.' && n !== '..';

  // interfaces that can join nd: real ones (no bond/bridge, not lo) that
  // aren't members of another bond or bridge
  function candidates(nd) {
    const name = nd ? nd.name : null;
    return interfaces.filter(i => i.name !== 'lo' && !i.kind && !netdevs.some(n => n.name === i.name) &&
      !netdevs.some(n => n.name !== name && n.members.includes(i.name)));
  }

  function netdevDialog(kind, nd) {
    const iface = nd && interfaces.find(i => i.name === nd.name);
    const own = iface && ownConfig(iface);
    const members = nd ? nd.members : [];
    dialog({
      title: nd ? t('Edit {0}', nd.name) : t(kind === 'bond' ? 'New bond' : 'New bridge'),
      fields: form => {
        const pool = candidates(nd);
        for (const m of members) if (!pool.some(i => i.name === m)) pool.push({name: m, operState: ''});
        const bondOpts = h('div', {},
          h('label', {}, t('Mode'), h('select', {name: 'mode', onchange: () => toggle()},
            bondModes.map(([v, text]) => h('option', {value: v, selected: (nd ? nd.mode : 'active-backup') === v}, t(text))))),
          h('div', {class: 'lacp'},
            h('label', {}, t('LACP rate'), h('select', {name: 'lacpRate'},
              h('option', {value: 'slow', selected: !nd || nd.lacpRate !== 'fast'}, t('slow (30 s)')),
              h('option', {value: 'fast', selected: nd && nd.lacpRate === 'fast'}, t('fast (1 s)')))),
            h('p', {class: 'hint'}, t('LACP needs a switch with link aggregation (802.3ad) on these ports.'))),
          h('div', {class: 'hash'},
            h('label', {}, t('Transmit hash policy'), h('select', {name: 'hashPolicy'},
              hashPolicies.map(p => h('option', {value: p, selected: (nd && nd.hashPolicy ? nd.hashPolicy : 'layer2') === p}, p))))),
          h('label', {}, t('Link monitoring (ms)'), h('input', {name: 'miimon', type: 'number', min: 0, max: 60000,
            value: nd ? nd.miimon : 100})));
        const toggle = () => {
          const mode = form.elements.mode ? form.elements.mode.value : '';
          bondOpts.querySelector('.lacp').hidden = mode !== '802.3ad';
          bondOpts.querySelector('.hash').hidden = mode !== '802.3ad' && mode !== 'balance-xor';
        };
        const staticFields = h('div', {},
          h('label', {}, t('Address (CIDR)'), h('input', {name: 'address', value: own ? iface.address : '', placeholder: '192.168.1.10/24'})),
          h('label', {}, t('Gateway'), h('input', {name: 'gateway', value: own ? iface.gateway : '', placeholder: '192.168.1.1'})));
        const toggleIp = () => { staticFields.hidden = form.elements.ipmode.value === 'dhcp'; };
        const dhcp = !own || iface.dhcp;
        const ipmode = (value, text) => h('label', {class: 'check'},
          h('input', {type: 'radio', name: 'ipmode', value, checked: (value === 'dhcp') === dhcp, onchange: toggleIp}), text);
        setTimeout(() => { if (kind === 'bond') toggle(); toggleIp(); });
        return [
          nd ? null : h('label', {}, t(kind === 'bond' ? 'Name (e.g. bond0)' : 'Name (e.g. br0)'),
            h('input', {name: 'name', value: kind === 'bond' ? 'bond0' : 'br0', required: true})),
          kind === 'bond' ? bondOpts
            : h('div', {},
                h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'stp', checked: nd ? nd.stp : false}), t('Spanning tree (STP)')),
                h('p', {class: 'hint'}, t('Only needed when the bridge connects networks that are also connected elsewhere (loops).'))),
          h('h3', {}, t('Member interfaces')),
          pool.length
            ? h('div', {class: 'checklist'}, pool.map(i => h('label', {class: 'check'},
                h('input', {type: 'checkbox', name: 'members', value: i.name, checked: members.includes(i.name)}),
                i.name, i.addresses && i.addresses.length ? h('span', {class: 'muted'}, ' ' + i.addresses.join(', ')) : null)))
            : h('p', {class: 'empty'}, t('No free interfaces.')),
          h('h3', {}, t('Addresses of {0}', nd ? nd.name : t(kind))),
          h('div', {class: 'row'}, ipmode('dhcp', t('DHCP')), ipmode('static', t('Static'))),
          staticFields,
          h('label', {}, t('DNS servers (comma separated)'), h('input', {name: 'dns', value: own ? (iface.dns || []).join(', ') : ''})),
          h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'apply', checked: true}), t('Apply now')),
          h('p', {class: 'hint'}, t('Members lose their own addresses: the {0} has them. Changing the interface this page runs over can cut the connection.', t(kind))),
        ];
      },
      onSubmit: async d => {
        const name = nd ? nd.name : (d.name || '').trim();
        if (!name) throw new Error(t('A name is needed.'));
        if (!ifnameOk(name)) throw new Error(t('Names: letters, digits, "-", "_" and ".", at most 15 characters.'));
        const memberList = d.members || [];
        if (!memberList.length) throw new Error(t('Select at least one member.'));
        const dhcp = d.ipmode === 'dhcp';
        if (!dhcp && !d.address.trim()) throw new Error(t('A static configuration needs an address.'));
        const args = {netdev: name, members: memberList};
        if (kind === 'bond') {
          args.mode = d.mode;
          args.miimon = Number(d.miimon) || 0;
          if (d.mode === '802.3ad') args.lacpRate = d.lacpRate;
          if (d.mode === '802.3ad' || d.mode === 'balance-xor') args.hashPolicy = d.hashPolicy;
        } else {
          args.stp = !!d.stp;
        }
        if (nd) await call('network', 'updateNetdev', args);
        else await call('network', 'createNetdev', Object.assign({kind}, args));
        // the device's own addresses: its configuration 10-<name>
        const ipArgs = {interface: name, dhcp, address: dhcp ? '' : d.address.trim(), gateway: dhcp ? '' : d.gateway.trim(),
                        dns: d.dns.split(/[\s,]+/).filter(Boolean)};
        if (own) await call('network', 'update', Object.assign({config: iface.config}, ipArgs));
        else await call('network', 'create', ipArgs).catch(() => call('network', 'update', Object.assign({config: '10-' + name}, ipArgs)));
        if (d.apply) await call('network', 'reload', {});
        toast(d.apply ? t('{0} saved and applied.', name) : t('{0} saved.', name));
        load();
      },
    });
  }

  async function deleteNetdev(nd) {
    const ok = await confirmAction(t('Delete {0}?', nd.name),
      t('{0} and its configuration are removed; its members get their own configuration again (usually DHCP).', nd.name),
      t('Delete'));
    if (!ok) return;
    if (await run(() => call('network', 'deleteNetdev', {netdev: nd.name}), t('{0} deleted.', nd.name)) !== undefined) {
      await run(() => call('network', 'reload', {}));
      load();
    }
  }

  function netdevPanel() {
    const ifState = nd => {
      const i = interfaces.find(x => x.name === nd.name);
      return i ? state(i) : h('span', {class: 'badge warn'}, t('not created yet'));
    };
    const settingsText = nd => nd.kind === 'bond'
      ? (bondModes.find(m => m[0] === nd.mode) || [nd.mode, nd.mode])[0]
      : t(nd.stp ? 'STP on' : 'STP off');
    return h('section', {class: 'panel'},
      h('div', {class: 'toolbar'},
        h('h2', {}, t('Bonds and bridges')),
        h('button', {onclick: () => netdevDialog('bond')}, t('New bond')),
        h('button', {onclick: () => netdevDialog('bridge')}, t('New bridge'))),
      table([
        {title: t('Name'), render: nd => h('strong', {}, nd.name)},
        {title: t('Kind'), render: nd => [t(nd.kind), ' ', h('span', {class: 'muted mono'}, settingsText(nd))]},
        {title: t('Members'), render: nd => nd.members.length ? nd.members.map(m => {
            const i = interfaces.find(x => x.name === m);
            const ok = i && i.master === nd.name && i.operState === 'up';
            return h('span', {class: 'badge ' + (ok ? 'ok' : 'warn'), title: i ? i.operState : ''}, m);
          }) : h('span', {class: 'muted'}, '–')},
        {title: t('Status'), render: ifState},
        {title: t('Addresses'), render: nd => {
            const i = interfaces.find(x => x.name === nd.name);
            return i && i.addresses.length ? i.addresses.map(a => h('div', {class: 'mono'}, a)) : h('span', {class: 'muted'}, '–');
          }},
        {title: '', class: 'actions', render: nd => [
          h('button', {class: 'small', onclick: () => netdevDialog(nd.kind, nd)}, t('Edit')),
          h('button', {class: 'small danger', onclick: () => deleteNetdev(nd)}, t('Delete'))]},
      ], netdevs, t('No bonds or bridges.')));
  }

  async function action(name, act, done) {
    if (await run(() => call('network', act, name ? {interface: name} : {}), done) !== undefined) load();
  }
@@ -106,6 +284,7 @@

  function config(i) {
    if (i.name === 'lo') return h('span', {class: 'muted'}, t('loopback (set up by syslite)'));
    if (i.memberOf || i.master) return h('span', {class: 'badge'}, t('member of {0}', i.memberOf || i.master));
    if (!i.config) return h('span', {class: 'muted'}, t('none'));
    const what = i.dhcp ? 'DHCP' : t('static {0}', i.address || '');
    return [h('div', {}, what, i.gateway ? ' · GW ' + i.gateway : ''),
@@ -121,7 +300,7 @@
          h('button', {onclick: () => action('', 'reload', t('All configurations reloaded.'))}, t('Reload all')),
          h('button', {onclick: () => action('', 'forceReload', t('All interfaces reconfigured.'))}, t('Reapply all'))),
        table([
          {title: t('Interface'), render: i => h('strong', {}, i.name)},
          {title: t('Interface'), render: i => [h('strong', {}, i.name), i.kind ? [' ', h('span', {class: 'badge'}, t(i.kind))] : null]},
          {title: t('MAC'), render: i => i.mac, class: 'mono'},
          {title: t('Status'), render: state},
          {title: t('Addresses'), render: i => i.addresses.length ? i.addresses.map(a => h('div', {class: 'mono'}, a))
@@ -134,10 +313,11 @@
              : h('button', {class: 'small', onclick: () => action(i.name, 'stop', t('{0} stopped.', i.name))}, t('Stop')),
            h('button', {class: 'small', onclick: () => action(i.name, 'restart', t('{0} restarted.', i.name))}, t('Restart')),
            h('button', {class: 'small', onclick: () => action(i.name, 'reload', t('{0} reloaded.', i.name))}, t('Reload')),
            h('button', {class: 'small', onclick: () => configure(i)}, t('Configure')),
            ownConfig(i) ? h('button', {class: 'small danger', onclick: () => deleteConfig(i)}, t('Delete config')) : null,
            i.memberOf || i.master || i.kind ? null : h('button', {class: 'small', onclick: () => configure(i)}, t('Configure')),
            ownConfig(i) && !i.memberOf && !i.kind ? h('button', {class: 'small danger', onclick: () => deleteConfig(i)}, t('Delete config')) : null,
          ]},
        ], interfaces, t('No interfaces found.'))));
        ], interfaces, t('No interfaces found.'))),
      netdevPanel());
  }

  webctl.register('network', {