Commit 08a05a25 authored by jan.koester's avatar jan.koester
Browse files

iscsi export

parent 7e0a5949
Loading
Loading
Loading
Loading
+1 −119
Changes for modules/shares/shares.cpp: 1 added line, 119 removed lines.
Original line number Diff line number Diff line
@@ -43,14 +43,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *   smbPassword {name, password}   set the user's password for login and
 *                                  SMB; an empty password ends SMB logins
 *
 * iSCSI targets (sharedd's LIO targets):
 *   info adds iscsi{targets[{name, iqn, comment, luns[], readOnly, open,
 *     initiators[], chapUser, chapPasswordSet, chapUserIn,
 *     chapPasswordInSet, sessions[], error}], devices[{device, kind, size,
 *     inUse}]}; settings add iscsi, iscsiBaseName, iscsiPortals[]
 *   iscsiCreate {target}, iscsiUpdate {name, target} (chapPassword /
 *     chapPasswordIn missing or null: kept), iscsiDelete {name}
 *
 * Files and folders inside a share (sharedd's Browse & co.):
 *   browse {share, path}        -> {path, self{owner, group, mode}, entries[{name, type, size, mtime,
 *                                  owner, group, mode}], accounts{users, groups}}
@@ -155,88 +147,6 @@ std::string shareFields(json_object *s, Fields &out) {
    return "";
}

json_object *strings(const std::vector<std::string> &v) {
    json_object *a = json_object_new_array();
    for (auto &x : v) json_object_array_add(a, jstr(x));
    return a;
}

json_object *iscsiTargetJson(const Fields &f) {
    json_object *o = json_object_new_object();
    std::vector<std::string> luns, initiators, sessions;
    for (auto &p : f) {
        const std::string &k = p.first, &v = p.second;
        if (k == "name" || k == "iqn" || k == "comment" || k == "error") json_object_object_add(o, k.c_str(), jstr(v));
        else if (k == "lun") luns.push_back(v);
        else if (k == "initiator") initiators.push_back(v);
        else if (k == "session") sessions.push_back(v);
        else if (k == "read_only") json_object_object_add(o, "readOnly", json_object_new_boolean(v == "yes"));
        else if (k == "open") json_object_object_add(o, "open", json_object_new_boolean(v == "yes"));
        else if (k == "chap_user") json_object_object_add(o, "chapUser", jstr(v));
        else if (k == "chap_user_in") json_object_object_add(o, "chapUserIn", jstr(v));
        else if (k == "chap_password_set") json_object_object_add(o, "chapPasswordSet", json_object_new_boolean(v == "yes"));
        else if (k == "chap_password_in_set") json_object_object_add(o, "chapPasswordInSet", json_object_new_boolean(v == "yes"));
    }
    json_object_object_add(o, "luns", strings(luns));
    json_object_object_add(o, "initiators", strings(initiators));
    json_object_object_add(o, "sessions", strings(sessions));
    return o;
}

// iSCSI targets and the devices that could be LUNs; nullptr or an error
json_object *iscsiInfo(json_object *out) {
    sysbus::Message r;
    if (json_object *e = callSharedd(sharedd::method::ListIscsiTargets, {}, r)) return e;
    json_object *iscsi = json_object_new_object();
    json_object *targets = json_object_new_array();
    for (auto &t : sharesOf(r)) json_object_array_add(targets, iscsiTargetJson(t));
    json_object_object_add(iscsi, "targets", targets);
    if (json_object *e = callSharedd(sharedd::method::ListIscsiDevices, {}, r)) return json_object_put(iscsi), e;
    json_object *devices = json_object_new_array(), *cur = nullptr;
    for (auto &f : r.fields) {
        if (f.first == "device") {
            cur = json_object_new_object();
            json_object_array_add(devices, cur);
            json_object_object_add(cur, "device", jstr(f.second));
        } else if (cur && f.first == "size") {
            json_object_object_add(cur, "size", json_object_new_int64(std::strtoll(f.second.c_str(), nullptr, 10)));
        } else if (cur && f.first == "kind") {
            json_object_object_add(cur, "kind", jstr(f.second));
        } else if (cur && f.first == "in_use") {
            json_object_object_add(cur, "inUse", jstr(f.second));
        }
    }
    json_object_object_add(iscsi, "devices", devices);
    json_object_object_add(out, "iscsi", iscsi);
    return nullptr;
}

// a target from the UI as sharedd's fields; keepSecrets: missing/null
// passwords stay as they are
std::string iscsiFields(json_object *t, Fields &out, bool keepSecrets) {
    if (!t || !json_object_is_type(t, json_type_object)) return tr("no target given", "kein Target angegeben");
    out.emplace_back("comment", webctl::str(t, "comment"));
    out.emplace_back("read_only", yesNo(webctl::boolean(t, "readOnly")));
    out.emplace_back("open", yesNo(webctl::boolean(t, "open")));
    for (auto &k : {std::make_pair("luns", "lun"), std::make_pair("initiators", "initiator")}) {
        json_object *list = nullptr;
        if (!json_object_object_get_ex(t, k.first, &list) || !list) continue;
        if (!json_object_is_type(list, json_type_array)) return tr("%1 must be a list", "%1 muss eine Liste sein", {k.first});
        for (size_t i = 0; i < json_object_array_length(list); ++i) {
            const char *v = json_object_get_string(json_object_array_get_idx(list, i));
            if (v && *v) out.emplace_back(k.second, v);
        }
    }
    out.emplace_back("chap_user", webctl::str(t, "chapUser"));
    out.emplace_back("chap_user_in", webctl::str(t, "chapUserIn"));
    for (auto &k : {std::make_pair("chapPassword", "chap_password"), std::make_pair("chapPasswordIn", "chap_password_in")}) {
        json_object *v = nullptr;
        bool given = json_object_object_get_ex(t, k.first, &v) && v;
        if (given || !keepSecrets) out.emplace_back(k.second, webctl::str(t, k.first));
    }
    return "";
}

json_object *info() {
    json_object *out = json_object_new_object();

@@ -247,7 +157,7 @@ json_object *info() {
        json_object_put(e);
    } else {
        json_object *st = json_object_new_object();
        for (const char *k : {"smb_state", "smb_error", "nfs_state", "nfs_error", "iscsi_state", "iscsi_error"})
        for (const char *k : {"smb_state", "smb_error", "nfs_state", "nfs_error"})
            json_object_object_add(st, k, jstr(r.get(k)));
        json_object_object_add(out, "servers", st);  // not "status": the frontend takes that for the HTTP status

@@ -265,16 +175,12 @@ json_object *info() {
        json_object_object_add(set, "nfs", json_object_new_boolean(r.get("nfs") == "yes"));
        json_object_object_add(set, "nfsThreads", json_object_new_int(std::atoi(r.get("nfs_threads").c_str())));
        json_object_object_add(set, "nfsRoot", jstr(r.get("nfs_root")));
        json_object_object_add(set, "iscsi", json_object_new_boolean(r.get("iscsi") == "yes"));
        json_object_object_add(set, "iscsiBaseName", jstr(r.get("iscsi_base_name")));
        json_object_object_add(set, "iscsiPortals", strings(r.getAll("iscsi_portal")));
        json_object_object_add(out, "settings", set);

        if (json_object *e = callSharedd(sharedd::method::ListShares, {}, r)) return json_object_put(out), e;
        json_object *shares = json_object_new_array();
        for (auto &s : sharesOf(r)) json_object_array_add(shares, shareJson(s));
        json_object_object_add(out, "shares", shares);
        if (json_object *e = iscsiInfo(out)) return json_object_put(out), e;
    }

    // who can be given access, and who has an SMB password
@@ -323,14 +229,6 @@ json_object *settings(json_object *a) {
    flag("nfs", "nfs");
    if (webctl::has(a, "nfsThreads")) f.emplace_back("nfs_threads", std::to_string(webctl::integer(a, "nfsThreads", 8)));
    text("nfsRoot", "nfs_root");
    flag("iscsi", "iscsi");
    text("iscsiBaseName", "iscsi_base_name");
    json_object *portals = nullptr;
    if (json_object_object_get_ex(a, "iscsiPortals", &portals) && portals && json_object_is_type(portals, json_type_array))
        for (size_t i = 0; i < json_object_array_length(portals); ++i) {
            const char *v = json_object_get_string(json_object_array_get_idx(portals, i));
            f.emplace_back("iscsi_portal", v ? v : "");
        }
    sysbus::Message r;
    if (json_object *e = callSharedd(sharedd::method::SetSettings, f, r)) return e;
    return webctl::ok();
@@ -437,22 +335,6 @@ json_object *call(const webctl_request *req) {
        return webctl::ok();
    }
    if (action == "settings") return settings(a);
    if (action == "iscsiDelete") {
        if (json_object *e = callSharedd(sharedd::method::DeleteIscsiTarget, {{"name", webctl::str(a, "name")}}, r)) return e;
        return webctl::ok();
    }
    if (action == "iscsiCreate" || action == "iscsiUpdate") {
        json_object *t = nullptr;
        json_object_object_get_ex(a, "target", &t);
        bool update = action == "iscsiUpdate";
        Fields f = {{"name", update ? webctl::str(a, "name") : webctl::str(t, "name")}};
        std::string why = iscsiFields(t, f, update);
        if (!why.empty()) return error(why);
        if (update && webctl::str(t, "name") != f[0].second) f.emplace_back("new_name", webctl::str(t, "name"));
        if (json_object *e = callSharedd(update ? sharedd::method::EditIscsiTarget : sharedd::method::CreateIscsiTarget, f, r))
            return e;
        return webctl::ok();
    }
    if (action == "delete") {
        if (json_object *e = callSharedd(sharedd::method::DeleteShare, {{"name", webctl::str(a, "name")}}, r)) return e;
        return webctl::ok();
+2 −142
Changes for www/modules/shares.js: 2 added lines, 142 removed lines.
Original line number Diff line number Diff line
@@ -4,30 +4,6 @@
  const {h, t, table, dialog, confirmAction, call, run, toast} = webctl;

  webctl.translations({de: {
    'iSCSI targets': 'iSCSI-Targets', 'New target': 'Neues Target', 'Target {0}': 'Target {0}',
    'No iSCSI targets yet.': 'Noch keine iSCSI-Targets.', 'LUNs': 'LUNs', 'Sessions': 'Sitzungen',
    'Volumes and disks': 'Volumes und Laufwerke', 'Image files (one path per line)': 'Image-Dateien (ein Pfad pro Zeile)',
    'Ticked in order: LUN 0, 1, ... Mounted volumes can\'t be exported.':
      'In Reihenfolge angehakt: LUN 0, 1, ... Eingehängte Volumes lassen sich nicht exportieren.',
    'Any initiator (no access list; for a lab)': 'Jeder Initiator (keine Zugriffsliste; für ein Labor)',
    'Initiators allowed (IQNs, one per line)': 'Erlaubte Initiatoren (IQNs, einer pro Zeile)',
    'The initiator shows its IQN, e.g. iscsictl status or /etc/iscsi/initiatorname.iscsi.':
      'Der Initiator zeigt seine IQN, z. B. iscsictl status oder /etc/iscsi/initiatorname.iscsi.',
    'CHAP (the initiator logs in with user and secret)': 'CHAP (der Initiator meldet sich mit Benutzer und Passwort an)',
    'CHAP user': 'CHAP-Benutzer', 'CHAP secret': 'CHAP-Passwort', 'unchanged': 'unverändert',
    'Mutual CHAP (the target proves itself too)': 'Gegenseitiges CHAP (auch das Target weist sich aus)',
    'Target user': 'Target-Benutzer', 'Target secret': 'Target-Passwort',
    'any initiator': 'jeder Initiator', 'none': 'keine', 'in use': 'in Benutzung',
    'Target {0} saved.': 'Target {0} gespeichert.', 'Target {0} created.': 'Target {0} angelegt.',
    'Delete target {0}': 'Target {0} löschen',
    'Initiators connected to it lose their disks; the volumes and files themselves stay.':
      'Verbundene Initiatoren verlieren ihre Laufwerke; die Volumes und Dateien selbst bleiben.',
    'Target {0} deleted.': 'Target {0} gelöscht.',
    'iSCSI target (LIO)': 'iSCSI-Target (LIO)', 'Base name (targets: base name:name)': 'Basisname (Targets: Basisname:Name)',
    'Portals (address:port, one per line)': 'Portale (Adresse:Port, eines pro Zeile)',
    'Changing the base name renames every target: initiators have to discover them again.':
      'Ein neuer Basisname benennt alle Targets um: die Initiatoren müssen sie neu suchen.',
    'Name (target: {0}:name)': 'Name (Target: {0}:Name)',
    'Shares': 'Freigaben', 'File servers': 'Dateiserver', 'running': 'läuft', 'failed': 'Fehler',
    'disabled': 'aus', 'Settings': 'Einstellungen', 'New share': 'Neue Freigabe', 'Create': 'Anlegen',
    'Name': 'Name', 'Path': 'Pfad', 'Protocols': 'Protokolle', 'Access': 'Zugriff', 'Who': 'Wer',
@@ -284,110 +260,18 @@
        h('div', {class: 'row'},
          h('label', {}, t('Threads'), h('input', {name: 'nfsThreads', type: 'number', min: 1, max: 256, value: s.nfsThreads})),
          h('label', {}, t('NFSv4 root (clients see it as /)'), h('input', {name: 'nfsRoot', value: s.nfsRoot}))),
        h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'iscsi', checked: s.iscsi}), h('strong', {}, t('iSCSI target (LIO)'))),
        h('label', {}, t('Base name (targets: base name:name)'),
          h('input', {name: 'iscsiBaseName', value: s.iscsiBaseName, class: 'mono'})),
        h('p', {class: 'hint'}, t('Changing the base name renames every target: initiators have to discover them again.')),
        h('label', {}, t('Portals (address:port, one per line)'),
          h('textarea', {name: 'iscsiPortals', rows: 2, class: 'mono'}, (s.iscsiPortals || []).join('\n'))),
      ],
      onSubmit: async d => {
        await call('shares', 'settings', {smb: d.smb, workgroup: d.workgroup, netbiosName: d.netbiosName,
          serverString: d.serverString, minProtocol: d.minProtocol, maxProtocol: d.maxProtocol, signing: d.signing,
          mapToGuest: d.mapToGuest, guestAccount: d.guestAccount, nfs: d.nfs, nfsThreads: Number(d.nfsThreads),
          nfsRoot: d.nfsRoot.trim(), iscsi: d.iscsi, iscsiBaseName: d.iscsiBaseName.trim(),
          iscsiPortals: list(d.iscsiPortals)});
          nfsRoot: d.nfsRoot.trim()});
        toast(t('Settings saved.'));
        load();
      },
    });
  }

  // ---- iSCSI targets ----

  function iscsiDialog(tg) {
    const old = tg;
    tg = tg || {name: '', comment: '', luns: [], readOnly: false, open: false, initiators: [], chapUser: '', chapUserIn: ''};
    const devices = data.iscsi.devices;
    const deviceOf = p => devices.find(d => d.device === p);
    const files = tg.luns.filter(l => !deviceOf(l));
    const password = (name, isSet) => h('input', {name, type: 'password', autocomplete: 'new-password', maxlength: 255,
      placeholder: old && isSet ? t('unchanged') : ''});
    dialog({
      title: old ? t('Target {0}', old.name) : t('New target'), submit: old ? undefined : t('Create'), wide: true,
      fields: () => {
        const open = h('input', {type: 'checkbox', name: 'open', checked: tg.open});
        const initiators = h('label', {}, t('Initiators allowed (IQNs, one per line)'),
          h('textarea', {name: 'initiators', rows: 3, class: 'mono'}, tg.initiators.join('\n')));
        const chap = h('input', {type: 'checkbox', name: 'chap', checked: !!tg.chapUser});
        const chapBox = h('div', {class: 'row'},
          h('label', {}, t('CHAP user'), h('input', {name: 'chapUser', value: tg.chapUser, maxlength: 255})),
          h('label', {}, t('CHAP secret'), password('chapPassword', tg.chapPasswordSet)));
        const mutual = h('input', {type: 'checkbox', name: 'mutual', checked: !!tg.chapUserIn});
        const mutualLabel = h('label', {class: 'check'}, mutual, t('Mutual CHAP (the target proves itself too)'));
        const mutualBox = h('div', {class: 'row'},
          h('label', {}, t('Target user'), h('input', {name: 'chapUserIn', value: tg.chapUserIn, maxlength: 255})),
          h('label', {}, t('Target secret'), password('chapPasswordIn', tg.chapPasswordInSet)));
        const update = () => {
          initiators.hidden = open.checked;
          chapBox.hidden = mutualLabel.hidden = !chap.checked;
          mutualBox.hidden = !chap.checked || !mutual.checked;
        };
        [open, chap, mutual].forEach(x => x.addEventListener('change', update));
        update();
        return [
          h('label', {}, t('Name (target: {0}:name)', data.settings.iscsiBaseName),
            h('input', {name: 'name', value: tg.name, required: true, maxlength: 63, pattern: '[a-z0-9][a-z0-9.\\-]*'})),
          h('label', {}, t('Comment'), h('input', {name: 'comment', value: tg.comment})),
          h('label', {}, t('Volumes and disks')),
          h('div', {class: 'checklist'}, devices.map(d => {
            const mine = tg.luns.includes(d.device);
            return h('label', {class: 'check'},
              h('input', {type: 'checkbox', name: 'devices', value: d.device, checked: mine, disabled: !mine && !!d.inUse}),
              h('span', {class: 'mono'}, d.device), ' ', h('span', {class: 'muted'}, d.kind + ', ' + bytes(d.size)),
              !mine && d.inUse ? [' ', h('span', {class: 'badge'}, d.inUse)] : null);
          })),
          h('label', {}, t('Image files (one path per line)'),
            h('textarea', {name: 'files', rows: 2, class: 'mono'}, files.join('\n'))),
          h('p', {class: 'hint'}, t('Ticked in order: LUN 0, 1, ... Mounted volumes can\'t be exported.')),
          h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'readOnly', checked: tg.readOnly}), t('Read-only')),
          h('label', {class: 'check'}, open, t('Any initiator (no access list; for a lab)')),
          initiators,
          h('p', {class: 'hint'}, t('The initiator shows its IQN, e.g. iscsictl status or /etc/iscsi/initiatorname.iscsi.')),
          h('label', {class: 'check'}, chap, t('CHAP (the initiator logs in with user and secret)')),
          chapBox, mutualLabel, mutualBox,
        ];
      },
      onSubmit: async d => {
        // keep the old LUN order for what stays, new ones after it
        const picked = d.devices || [];
        const luns = tg.luns.filter(l => picked.includes(l) || (!deviceOf(l) && d.files.split('\n').map(x => x.trim()).includes(l)));
        for (const l of [...picked, ...d.files.split('\n').map(x => x.trim()).filter(Boolean)])
          if (!luns.includes(l)) luns.push(l);
        const target = {name: d.name.trim(), comment: d.comment, luns, readOnly: d.readOnly, open: d.open,
                        initiators: d.open ? [] : d.initiators.split(/[\s,]+/).filter(Boolean),
                        chapUser: d.chap ? d.chapUser.trim() : '', chapUserIn: d.chap && d.mutual ? d.chapUserIn.trim() : ''};
        if (!d.chap) Object.assign(target, {chapPassword: '', chapPasswordIn: ''});
        else {
          if (!old || d.chapPassword) target.chapPassword = d.chapPassword;
          if (!d.mutual) target.chapPasswordIn = '';
          else if (!old || d.chapPasswordIn) target.chapPasswordIn = d.chapPasswordIn;
        }
        if (old) await call('shares', 'iscsiUpdate', {name: old.name, target});
        else await call('shares', 'iscsiCreate', {target});
        toast(old ? t('Target {0} saved.', target.name) : t('Target {0} created.', target.name));
        load();
      },
    });
  }

  async function deleteIscsi(tg) {
    if (!await confirmAction(t('Delete target {0}', tg.name),
                             t('Initiators connected to it lose their disks; the volumes and files themselves stay.'), t('Delete')))
      return;
    if (await run(() => call('shares', 'iscsiDelete', {name: tg.name}), t('Target {0} deleted.', tg.name)) !== undefined) load();
  }

  function who(s) {
    const parts = [];
    if (s.smb) parts.push(s.users.length ? s.users.join(', ') : t('everyone with an SMB password'));
@@ -543,8 +427,7 @@
          h('button', {onclick: settingsDialog}, t('Settings'))),
        h('div', {class: 'table-wrap'}, h('table', {class: 'facts'}, h('tbody', {},
          h('tr', {}, h('th', {}, 'SMB'), h('td', {}, badge(st.smb_state), st.smb_error ? h('span', {class: 'muted'}, ' ' + st.smb_error) : null)),
          h('tr', {}, h('th', {}, 'NFS'), h('td', {}, badge(st.nfs_state), st.nfs_error ? h('span', {class: 'muted'}, ' ' + st.nfs_error) : null)),
          h('tr', {}, h('th', {}, 'iSCSI'), h('td', {}, badge(st.iscsi_state), st.iscsi_error ? h('span', {class: 'muted'}, ' ' + st.iscsi_error) : null)))))));
          h('tr', {}, h('th', {}, 'NFS'), h('td', {}, badge(st.nfs_state), st.nfs_error ? h('span', {class: 'muted'}, ' ' + st.nfs_error) : null)))))));
      sections.push(h('section', {class: 'panel'},
        h('div', {class: 'toolbar'},
          h('h2', {}, t('Shares')),
@@ -563,29 +446,6 @@
            h('button', {class: 'small danger', onclick: () => deleteShare(s)}, t('Delete')),
          ]},
        ], data.shares, t('No shares yet.'))));
      if (data.iscsi && data.settings.iscsi) {
        const deviceOf = p => data.iscsi.devices.find(d => d.device === p);
        sections.push(h('section', {class: 'panel'},
          h('div', {class: 'toolbar'},
            h('h2', {}, t('iSCSI targets')),
            h('button', {class: 'primary', onclick: () => iscsiDialog(null)}, t('New target'))),
          table([
            {title: t('Name'), render: x => [h('strong', {}, x.name), h('div', {class: 'muted mono'}, x.iqn),
              x.comment ? h('div', {class: 'muted'}, x.comment) : null]},
            {title: t('LUNs'), render: x => x.luns.map((l, i) => h('div', {class: 'mono'}, i + ': ' + l,
              deviceOf(l) ? h('span', {class: 'muted'}, ' ' + bytes(deviceOf(l).size)) : null))},
            {title: t('Access'), render: x => [
              h('span', {class: 'badge ' + (x.readOnly ? '' : 'ok')}, x.readOnly ? t('read-only') : t('read-write')),
              x.chapUser ? [' ', h('span', {class: 'badge'}, 'CHAP' + (x.chapUserIn ? ' ⇄' : ''))] : null,
              h('div', {class: 'muted mono'}, x.open ? t('any initiator') : x.initiators.join(', '))]},
            {title: t('Sessions'), render: x => [
              x.sessions.length ? x.sessions.map(i => h('div', {class: 'mono'}, i)) : h('span', {class: 'muted'}, t('none')),
              x.error ? h('div', {}, h('span', {class: 'badge danger'}, x.error)) : null]},
            {title: '', class: 'actions', render: x => [
              h('button', {class: 'small', onclick: () => iscsiDialog(x)}, t('Edit')),
              h('button', {class: 'small danger', onclick: () => deleteIscsi(x)}, t('Delete'))]},
          ], data.iscsi.targets, t('No iSCSI targets yet.'))));
      }
    }
    sections.push(h('section', {class: 'panel'},
      h('div', {class: 'toolbar'}, h('h2', {}, t('SMB access'))),