Commit 7369b9c3 authored by jan.koester's avatar jan.koester
Browse files

iscsi

parent c5c651e4
Loading
Loading
Loading
Loading
+5 −2
Changes for CMakeLists.txt: 5 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -2,7 +2,8 @@ cmake_minimum_required(VERSION 3.16)
project(sharedd VERSION 0.1.0 LANGUAGES CXX)

# sharedd: SMB and NFS shares served by the kernel's own file servers --
# ksmbd for SMB2/3 and knfsd for NFSv4. sharedd is what the kernel asks
# ksmbd for SMB2/3 and knfsd for NFSv4 -- and iSCSI targets (LVM volumes,
# disks, image files) served by the kernel's LIO, set up over configfs. sharedd is what the kernel asks
# who may log in and which share means which directory (in place of
# ksmbd.mountd and rpc.mountd), keeps the shares in /etc/sharedd/sharedd.yaml
# (libconfplus) and offers them on sysbus as the service "sharedd".
@@ -34,6 +35,7 @@ add_executable(sharedd
    src/nfs.cpp
    src/rpc.cpp
    src/files.cpp
    src/iscsi.cpp
)
target_include_directories(sharedd PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_definitions(sharedd PRIVATE SHAREDD_CONFIG="${SHAREDD_CONFIG}")
@@ -45,6 +47,7 @@ target_link_libraries(sharectl PRIVATE sysbus::sysbus)

install(TARGETS sharedd sharectl RUNTIME DESTINATION ${CMAKE_INSTALL_SBINDIR})
install(DIRECTORY include/sharedd DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(FILES dist/sharedd.yaml DESTINATION /etc/sharedd)
# iSCSI CHAP secrets: root only
install(FILES dist/sharedd.yaml DESTINATION /etc/sharedd PERMISSIONS OWNER_READ OWNER_WRITE)
set(SHAREDD_UNITDIR "/etc/syslite/services" CACHE PATH "syslite unit directory")
install(FILES dist/sharedd.service DESTINATION ${SHAREDD_UNITDIR})
+11 −0
Changes for dist/sharedd.yaml: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
# sharedd: SMB and NFS shares (served by the kernel's ksmbd and knfsd).
# Edit with sharectl or webctl; by hand, then: sharectl reload
# Keeps iSCSI CHAP secrets: mode 0600.
sharedd:
  smb:
    enabled: true
@@ -23,3 +24,13 @@ sharedd:
  #     nfs: true
  #     nfs_clients: ["192.168.1.0/24"]
  #     squash: root            # root|all|none
  iscsi:
    enabled: true             # LIO, the kernel's iSCSI target (port 3260)
    portals: ["0.0.0.0:3260"]
    # base_name: iqn.2026-01.de.tuxist.fenriros:<host>  # made up on the first start
    # targets:
    #   - name: vm1             # the target is <base_name>:vm1
    #     luns: [/dev/vg0/vm1]  # LVM volumes, disks, md arrays or image files
    #     initiators: [iqn.2005-03.org.open-iscsi:1234abcd]
    #     chap_user: vm1        # optional; mutual: chap_user_in, chap_password_in
    #     chap_password: secret12345678
+34 −3
Changes for include/sharedd/api.h: 34 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -52,10 +52,32 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * min_protocol, max_protocol (SMB2_10 SMB3_00 SMB3_02 SMB3_11), signing
 * (disabled|enabled|auto|mandatory), map_to_guest (never|bad_user),
 * guest_account, nfs (yes|no), nfs_threads, nfs_root (the directory NFSv4
 * clients see as "/"; NFS shares have to be below it).
 * clients see as "/"; NFS shares have to be below it), iscsi (yes|no),
 * iscsi_base_name (targets are <base>:<name>), iscsi_portal (repeated:
 * address:port or [IPv6]:port to listen on).
 *
 * Status: smb_state / nfs_state (disabled|running|failed), smb_error /
 * nfs_error, shares. */
 * Status: smb_state / nfs_state / iscsi_state (disabled|running|failed),
 * smb_error / nfs_error / iscsi_error, shares, iscsi_targets.
 *
 * An iSCSI target (served by the kernel's LIO), in replies and as
 * arguments:
 *   name           [a-z0-9.-] (max. 63); the target is <base name>:<name>
 *   iqn            replies only
 *   comment        free text
 *   lun            repeated, LUN 0, 1, ...: a block device (LVM volume,
 *                  md array, disk, partition) or an image file (>= 1 MiB)
 *   read_only      yes|no
 *   open           yes|no: any initiator may connect (no ACL list)
 *   initiator      repeated: the initiators' IQNs allowed in
 *   chap_user, chap_password        CHAP (empty: none); replies have
 *                                   chap_password_set instead
 *   chap_user_in, chap_password_in  mutual CHAP: what the target answers
 *                                   with; replies: chap_password_in_set
 * ListIscsiTargets repeats these per target, each starting with its
 * "name", plus "session" (repeated: initiators logged in) and "error" (why
 * it couldn't be set up, e.g. the volume is mounted here). EditIscsiTarget
 * takes name + the whole new target (rename: new_name); a missing
 * chap_password/chap_password_in keeps the stored one. */

#pragma once

@@ -74,6 +96,15 @@ constexpr const char *GetSettings = "GetSettings";
constexpr const char *SetSettings = "SetSettings";      // the settings to change
constexpr const char *Reload = "Reload";                // re-read the configuration file

// iSCSI targets (changes: root only)
constexpr const char *ListIscsiTargets = "ListIscsiTargets";
constexpr const char *CreateIscsiTarget = "CreateIscsiTarget";  // a target
constexpr const char *EditIscsiTarget = "EditIscsiTarget";      // name + the whole new target
constexpr const char *DeleteIscsiTarget = "DeleteIscsiTarget";  // name
// block devices that could be LUNs -> per device: device (path), kind
// (lvm|md|disk|partition), size (bytes), in_use (why not, "" if free)
constexpr const char *ListIscsiDevices = "ListIscsiDevices";

// Files and folders inside a share (root only). path: inside the share,
// "" for its own folder; ".." and symlinks can't lead out of it.
//   Browse {share, path} -> path, self_owner/self_group/self_mode (the
+184 −1
Changes for src/config.cpp: 184 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -191,6 +191,140 @@ Share *Config::find(const std::string &name) {
    return nullptr;
}

IscsiTarget *Config::findIscsi(const std::string &name) {
    for (IscsiTarget &t : iscsiTargets)
        if (t.name == lower(name)) return &t;
    return nullptr;
}

bool IscsiTarget::operator==(const IscsiTarget &o) const {
    return name == o.name && comment == o.comment && luns == o.luns && readOnly == o.readOnly &&
           initiators == o.initiators && open == o.open && chapUser == o.chapUser && chapPassword == o.chapPassword &&
           chapUserIn == o.chapUserIn && chapPasswordIn == o.chapPasswordIn;
}

std::string defaultIscsiBaseName() {
    char host[256] = "";
    gethostname(host, sizeof(host) - 1);
    std::string h;
    for (char c : std::string(host).substr(0, std::string(host).find('.')))
        if (isalnum((unsigned char)c) || c == '-') h += (char)tolower((unsigned char)c);
    return "iqn.2026-01.de.tuxist.fenriros:" + (h.empty() ? std::string("fenriros") : h);
}

bool validIscsiName(std::string &name) {
    name = lower(name);
    if (name.size() < 5 || name.size() > 223) return false;
    std::string type = name.substr(0, 4);
    if (type == "iqn.") {
        // iqn.YYYY-MM.reversed.domain[:anything]
        if (name.size() < 12 || name[8] != '-' || name[11] != '.') return false;
        for (int i : {4, 5, 6, 7, 9, 10})
            if (!isdigit((unsigned char)name[i])) return false;
        for (char c : name)
            if (!(isalnum((unsigned char)c) || c == '-' || c == '.' || c == ':')) return false;
        return true;
    }
    if (type == "eui." || type == "naa.") {
        std::string hex = name.substr(4);
        if (type == "eui." ? hex.size() != 16 : hex.size() != 16 && hex.size() != 32) return false;
        return hex.find_first_not_of("0123456789abcdef") == std::string::npos;
    }
    return false;
}

namespace {

// "address:port" or "[v6]:port"
bool validPortal(const std::string &p) {
    std::string addr, port;
    if (!p.empty() && p[0] == '[') {
        size_t close = p.find("]:");
        if (close == std::string::npos) return false;
        addr = p.substr(1, close - 1);
        port = p.substr(close + 2);
    } else {
        size_t colon = p.find(':');
        if (colon == std::string::npos || p.find(':', colon + 1) != std::string::npos) return false;
        addr = p.substr(0, colon);
        port = p.substr(colon + 1);
    }
    if (port.empty() || port.size() > 5 || port.find_first_not_of("0123456789") != std::string::npos) return false;
    int n = std::stoi(port);
    unsigned char buf[16];
    bool v6 = p[0] == '[';
    return n >= 1 && n <= 65535 && inet_pton(v6 ? AF_INET6 : AF_INET, addr.c_str(), buf) == 1;
}

bool chapText(const std::string &v) {
    if (v.empty() || v.size() > 255) return false;
    for (unsigned char c : v)
        if (c < 0x20 || c == 0x7f) return false;
    return true;
}

} // namespace

std::string checkIscsiTarget(IscsiTarget &t, const Config &cfg, const std::string &oldName, const std::string &lang) {
    t.name = lower(t.name);
    const std::string badName = tr(lang, "invalid target name: lower-case letters, digits, . - (max. 63)",
                                   "ungültiger Target-Name: Kleinbuchstaben, Ziffern, . - (max. 63)");
    if (t.name.empty() || t.name.size() > 63 || !isalnum((unsigned char)t.name[0])) return badName;
    for (char c : t.name)
        if (!(isalnum((unsigned char)c) || c == '-' || c == '.')) return badName;
    for (const IscsiTarget &o : cfg.iscsiTargets)
        if (o.name == t.name && o.name != lower(oldName))
            return tr(lang, "an iSCSI target %1 exists", "ein iSCSI-Target %1 gibt es schon", {t.name});
    for (unsigned char c : t.comment)
        if (c < 0x20) return tr(lang, "invalid comment", "ungültiger Kommentar");

    if (t.luns.empty()) return tr(lang, "the target needs at least one LUN", "das Target braucht mindestens eine LUN");
    if (t.luns.size() > 64) return tr(lang, "at most 64 LUNs", "höchstens 64 LUNs");
    for (size_t i = 0; i < t.luns.size(); ++i) {
        std::string &l = t.luns[i];
        while (l.size() > 1 && l.back() == '/') l.pop_back();
        if (l.empty() || l[0] != '/' || l.find("/../") != std::string::npos || l.find(',') != std::string::npos ||
            l.find('\n') != std::string::npos)
            return tr(lang, "invalid LUN path: %1", "ungültiger LUN-Pfad: %1", {l});
        struct stat st;
        if (stat(l.c_str(), &st) != 0)
            return tr(lang, "%1 doesn't exist", "%1 gibt es nicht", {l});
        if (!S_ISBLK(st.st_mode) && !S_ISREG(st.st_mode))
            return tr(lang, "%1 is neither a block device nor a file", "%1 ist weder ein Blockgerät noch eine Datei", {l});
        if (S_ISREG(st.st_mode) && st.st_size < 1024 * 1024)
            return tr(lang, "the image file %1 is smaller than 1 MiB", "die Image-Datei %1 ist kleiner als 1 MiB", {l});
        for (size_t j = 0; j < i; ++j)
            if (t.luns[j] == l) return tr(lang, "%1 is a LUN twice", "%1 ist zweimal LUN", {l});
        for (const IscsiTarget &o : cfg.iscsiTargets)
            if (o.name != lower(oldName))
                for (const std::string &ol : o.luns)
                    if (ol == l) return tr(lang, "%1 is exported by %2 already", "%1 exportiert schon %2", {l, o.name});
    }

    for (std::string &i : t.initiators)
        if (!validIscsiName(i)) return tr(lang, "invalid initiator name: %1", "ungültiger Initiator-Name: %1", {i});
    if (t.open) t.initiators.clear();
    else if (t.initiators.empty())
        return tr(lang, "name the initiators that may connect (or allow any)",
                  "die Initiatoren nennen, die sich verbinden dürfen (oder alle erlauben)");

    if (t.chapUser.empty() && t.chapPassword.empty()) {
        t.chapUserIn.clear();
        t.chapPasswordIn.clear();
        return "";
    }
    if (!chapText(t.chapUser) || !chapText(t.chapPassword))
        return tr(lang, "CHAP needs a user name and a secret (up to 255 characters)",
                  "CHAP braucht Benutzername und Passwort (bis 255 Zeichen)");
    if (t.chapUserIn.empty() && t.chapPasswordIn.empty()) return "";
    if (!chapText(t.chapUserIn) || !chapText(t.chapPasswordIn))
        return tr(lang, "mutual CHAP needs the target's user name and secret",
                  "gegenseitiges CHAP braucht Benutzername und Passwort des Targets");
    if (t.chapPasswordIn == t.chapPassword)
        return tr(lang, "mutual CHAP: the two secrets must differ", "gegenseitiges CHAP: die beiden Passwörter müssen sich unterscheiden");
    return "";
}

std::string createShareDir(const Share &s, const std::string &lang) {
    struct stat st;
    if (stat(s.path.c_str(), &st) == 0) return "";
@@ -332,6 +466,16 @@ std::string checkSettings(const Settings &s, const std::string &lang) {
    if (s.nfsRoot.empty() || s.nfsRoot[0] != '/' || s.nfsRoot.find("/../") != std::string::npos ||
        (s.nfsRoot.size() > 1 && s.nfsRoot.back() == '/'))
        return tr(lang, "the NFSv4 root must be an absolute directory", "die NFSv4-Wurzel muss ein absolutes Verzeichnis sein");
    std::string base = s.iscsiBaseName;
    if (!validIscsiName(base) || base != s.iscsiBaseName)
        return tr(lang, "invalid iSCSI base name (iqn.YYYY-MM.domain:..., lower case)",
                  "ungültiger iSCSI-Basisname (iqn.JJJJ-MM.domain:..., klein geschrieben)");
    if (s.iscsiPortals.empty() || s.iscsiPortals.size() > 16)
        return tr(lang, "iSCSI needs 1 to 16 portals", "iSCSI braucht 1 bis 16 Portale");
    for (const std::string &p : s.iscsiPortals)
        if (!validPortal(p))
            return tr(lang, "invalid iSCSI portal %1 (address:port, [IPv6]:port)",
                      "ungültiges iSCSI-Portal %1 (Adresse:Port, [IPv6]:Port)", {p});
    return "";
}

@@ -378,6 +522,27 @@ Config loadConfig(const std::string &path) {
            sh.fsid = (uint32_t)fsid;
            cfg.shares.push_back(sh);
        }

        readBool(conf, r + "/iscsi/enabled", s.iscsi);
        readString(conf, r + "/iscsi/base_name", s.iscsiBaseName);
        std::vector<std::string> portals = readList(conf, r + "/iscsi/portals");
        if (!portals.empty()) s.iscsiPortals = portals;
        for (int i = 0;; ++i) {
            std::string p = r + "/iscsi/targets/" + std::to_string(i);
            if (!conf.getKey(p + "/name")) break;
            IscsiTarget t;
            readString(conf, p + "/name", t.name);
            readString(conf, p + "/comment", t.comment);
            t.luns = readList(conf, p + "/luns");
            readBool(conf, p + "/read_only", t.readOnly);
            t.initiators = readList(conf, p + "/initiators");
            readBool(conf, p + "/open", t.open);
            readString(conf, p + "/chap_user", t.chapUser);
            readString(conf, p + "/chap_password", t.chapPassword);
            readString(conf, p + "/chap_user_in", t.chapUserIn);
            readString(conf, p + "/chap_password_in", t.chapPasswordIn);
            cfg.iscsiTargets.push_back(t);
        }
    } catch (const confplus::ConfException &e) {
        throw std::runtime_error(path + ": " + e.what());
    }
@@ -388,7 +553,7 @@ void saveConfig(const std::string &path, const Config &cfg) {
    // libconfplus loads before it can save: start from a minimal document
    std::string tmp = path + ".tmp";
    {
        int fd = open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0644);
        int fd = open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0600);
        if (fd < 0) throw std::runtime_error(tmp + ": " + strerror(errno));
        const char skel[] = "sharedd:\n  version: 1\n";
        bool ok = write(fd, skel, sizeof(skel) - 1) == (ssize_t)(sizeof(skel) - 1);
@@ -427,11 +592,29 @@ void saveConfig(const std::string &path, const Config &cfg) {
            set(conf, p + "/async", yesNo(sh.async));
            if (sh.fsid) set(conf, p + "/fsid", std::to_string(sh.fsid));
        }
        set(conf, r + "/iscsi/enabled", yesNo(s.iscsi));
        if (!s.iscsiBaseName.empty()) set(conf, r + "/iscsi/base_name", s.iscsiBaseName);
        setList(conf, r + "/iscsi/portals", s.iscsiPortals);
        for (size_t i = 0; i < cfg.iscsiTargets.size(); ++i) {
            const IscsiTarget &t = cfg.iscsiTargets[i];
            std::string p = r + "/iscsi/targets/" + std::to_string(i);
            set(conf, p + "/name", t.name);
            if (!t.comment.empty()) set(conf, p + "/comment", t.comment);
            setList(conf, p + "/luns", t.luns);
            set(conf, p + "/read_only", yesNo(t.readOnly));
            setList(conf, p + "/initiators", t.initiators);
            set(conf, p + "/open", yesNo(t.open));
            if (!t.chapUser.empty()) set(conf, p + "/chap_user", t.chapUser);
            if (!t.chapPassword.empty()) set(conf, p + "/chap_password", t.chapPassword);
            if (!t.chapUserIn.empty()) set(conf, p + "/chap_user_in", t.chapUserIn);
            if (!t.chapPasswordIn.empty()) set(conf, p + "/chap_password_in", t.chapPasswordIn);
        }
        conf.saveConfig(tmp.c_str());
    } catch (const confplus::ConfException &e) {
        unlink(tmp.c_str());
        throw std::runtime_error(path + ": " + e.what());
    }
    chmod(tmp.c_str(), 0600);  // the CHAP secrets
    if (rename(tmp.c_str(), path.c_str()) != 0) {
        int err = errno;
        unlink(tmp.c_str());
+42 −0
Changes for src/config.h: 42 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -42,6 +42,18 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *         users: [jan, "@staff"]
 *         nfs_clients: ["192.168.1.0/24"]
 *         ...
 *     iscsi:
 *       enabled: true
 *       base_name: iqn.2026-01.de.tuxist.fenriros:nas
 *       portals: ["0.0.0.0:3260"]
 *       targets:
 *         - name: vm1
 *           luns: [/dev/vg0/vm1]
 *           initiators: [iqn.2005-03.org.open-iscsi:abc]
 *           chap_user: vm1
 *           chap_password: ...
 *
 * The file is mode 0600: it keeps the iSCSI CHAP secrets.
 *
 * See include/sharedd/api.h for what each field means. */

@@ -69,6 +81,22 @@ struct Share {
    uint32_t fsid = 0;                 // 0: derived from the path
};

// An iSCSI target exported by the kernel's LIO (iscsi.h). Its name is
// Settings::iscsiBaseName + ":" + name.
struct IscsiTarget {
    std::string name;                     // [a-z0-9.-], max. 63
    std::string comment;
    std::vector<std::string> luns;        // block devices (LVM volumes, disks, md) or image files; LUN 0, 1, ...
    bool readOnly = false;
    std::vector<std::string> initiators;  // the initiators' IQNs allowed in
    bool open = false;                    // any initiator (no list) -- for a lab
    std::string chapUser, chapPassword;   // empty: no CHAP
    std::string chapUserIn, chapPasswordIn;  // mutual CHAP: the target's credentials

    bool operator==(const IscsiTarget &o) const;
    bool operator!=(const IscsiTarget &o) const { return !(*this == o); }
};

struct Settings {
    bool smb = true;
    std::string workgroup = "WORKGROUP";
@@ -86,14 +114,20 @@ struct Settings {
    // be on an exportable filesystem -- not overlayfs, so on the live
    // system a directory on a disk or tmpfs.
    std::string nfsRoot = "/";
    bool iscsi = true;
    std::string iscsiBaseName;         // empty: made up once (defaultIscsiBaseName()) and kept
    std::vector<std::string> iscsiPortals = {"0.0.0.0:3260"};  // address:port, [v6]:port
};

struct Config {
    Settings settings;
    std::vector<Share> shares;
    std::vector<IscsiTarget> iscsiTargets;

    const Share *find(const std::string &name) const;  // SMB names are case-insensitive
    Share *find(const std::string &name);
    IscsiTarget *findIscsi(const std::string &name);
    std::string iqn(const IscsiTarget &t) const { return settings.iscsiBaseName + ":" + t.name; }
};

// Missing file: the defaults. Throws std::runtime_error if it can't be read.
@@ -125,6 +159,14 @@ uint32_t pathFsid(const std::string &path);

bool sameName(const std::string &a, const std::string &b);

// iqn.2026-01.de.tuxist.fenriros:<host name>
std::string defaultIscsiBaseName();
// an iSCSI name (iqn./eui./naa.); lower-cases it
bool validIscsiName(std::string &name);
// "" if t can be stored in cfg (replacing the target named oldName, if
// given), else why not (in lang). Normalizes it.
std::string checkIscsiTarget(IscsiTarget &t, const Config &cfg, const std::string &oldName = "", const std::string &lang = "en");

// The NetBIOS name: the setting, else the host name; upper case, max. 15.
std::string netbiosName(const Settings &s);

Loading