Commit 4596e856 authored by jan.koester's avatar jan.koester
Browse files

updates

parent a47b7afb
Loading
Loading
Loading
Loading
+3 −1
Changes for CMakeLists.txt: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -10,7 +10,7 @@ project(webctl VERSION 0.1.0 LANGUAGES CXX)
#                    forwarded to the backend; drops to the webctl user
#                    after binding
#   modules/         one shared object per area (users, network, shares,
#                    raid, lvm, filesystems, services, system),
#                    raid, lvm, filesystems, services, updates, system),
#                    dlopen()ed by the backend from its module directory
#   www/             the AJAX UI; www/modules/<name>.js per module

@@ -86,6 +86,8 @@ webctl_module(services modules/services/services.cpp)
target_link_libraries(webctl_mod_services PRIVATE sysbus::sysbus)
webctl_module(system modules/system/system.cpp)
target_link_libraries(webctl_mod_system PRIVATE sysbus::sysbus)
# runs dnf5 (as a job, modules/common/jobs.cpp)
webctl_module(updates modules/updates/updates.cpp modules/common/jobs.cpp)
# needs sharedd/api.h (sharedd-dev)
webctl_module(shares modules/shares/shares.cpp)
target_link_libraries(webctl_mod_shares PRIVATE sysbus::sysbus)
+482 −0
Changes for modules/updates/updates.cpp: 482 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 "updates": software updates with dnf5.
 *
 *   status                        -> {checked, upgrades: [{name, arch, installed, available, repository}],
 *                                     checkError?, last?, reboot: [packages], repos, jobs}
 *   check                         refresh the repositories and look for updates (a job)
 *   upgrade  {packages?: [names]} install the updates, all or the named packages (a job)
 *   repo     {id, enabled}        switch a repository in /etc/yum.repos.d on or off
 *
 * dnf5 runs in the job's child process (jobs.h), so the backend keeps
 * answering while it downloads. What it found and how the last update
 * went is kept in /var/lib/webctl; packages that only take effect after a
 * restart (kernel, C library, init) are noted in /run/webctl, which a
 * reboot empties. */

#include <algorithm>
#include <cerrno>
#include <cstring>
#include <ctime>
#include <fstream>
#include <map>
#include <set>
#include <sstream>
#include <string>
#include <vector>

#include <dirent.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>

#include <webctl/module.h>

#include "jobs.h"

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

namespace {

const char *const DNF = "/usr/bin/dnf5";
const char *const RPM = "/usr/bin/rpm";
const char *const REPOS_DIR = "/etc/yum.repos.d";
const char *const STATE_DIR = "/var/lib/webctl";
const char *const CHECK_FILE = "/var/lib/webctl/updates.json";
const char *const LAST_FILE = "/var/lib/webctl/updates-last.json";
const char *const RUN_DIR = "/run/webctl";
const char *const REBOOT_FILE = "/run/webctl/reboot-required";

// packages a running system only picks up after a restart
const std::set<std::string> REBOOT_PACKAGES = {"kernel", "systempp", "syslite", "fresh"};

// output kept from an update run
const size_t LOG_MAX = 64 * 1024;

struct Result {
    int rc = -1;
    std::string out, err;
};

// runs argv with a clean environment; stdout and stderr are kept apart
// (dnf5 writes --json to stdout, its messages to stderr)
Result runCommand(const std::vector<std::string> &argv) {
    Result r;
    int o[2], e[2];
    if (pipe2(o, O_CLOEXEC) != 0) { r.err = strerror(errno); return r; }
    if (pipe2(e, O_CLOEXEC) != 0) { r.err = strerror(errno); close(o[0]); close(o[1]); return r; }
    pid_t pid = fork();
    if (pid < 0) {
        r.err = strerror(errno);
        for (int fd : {o[0], o[1], e[0], e[1]}) close(fd);
        return r;
    }
    if (pid == 0) {
        dup2(o[1], 1);
        dup2(e[1], 2);
        int null = open("/dev/null", O_RDONLY);
        if (null >= 0) dup2(null, 0);
        std::vector<char *> a;
        for (auto &s : argv) a.push_back(const_cast<char *>(s.c_str()));
        a.push_back(nullptr);
        char *env[] = {const_cast<char *>("PATH=/usr/sbin:/usr/bin:/sbin:/bin"), const_cast<char *>("HOME=/root"),
                       const_cast<char *>("LC_ALL=C"), const_cast<char *>("TERM=dumb"), nullptr};
        execve(a[0], a.data(), env);
        dprintf(2, "%s: %s\n", a[0], strerror(errno));
        _exit(127);
    }
    close(o[1]);
    close(e[1]);
    pollfd fds[2] = {{o[0], POLLIN, 0}, {e[0], POLLIN, 0}};
    std::string *bufs[2] = {&r.out, &r.err};
    int open = 2;
    while (open > 0) {
        if (poll(fds, 2, -1) < 0) {
            if (errno == EINTR) continue;
            break;
        }
        for (int i = 0; i < 2; ++i) {
            if (fds[i].fd < 0 || !(fds[i].revents & (POLLIN | POLLHUP | POLLERR))) continue;
            char buf[8192];
            ssize_t n = read(fds[i].fd, buf, sizeof(buf));
            if (n < 0 && errno == EINTR) continue;
            if (n <= 0) {
                close(fds[i].fd);
                fds[i].fd = -1;
                --open;
            } else {
                bufs[i]->append(buf, (size_t)n);
            }
        }
    }
    int status = 0;
    while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
    r.rc = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0);
    return r;
}

std::string trimmed(std::string s) {
    while (!s.empty() && (s.back() == '\n' || s.back() == ' ' || s.back() == '\r')) s.pop_back();
    size_t b = s.find_first_not_of(" \n\r");
    return b == std::string::npos ? "" : s.substr(b);
}

// the last lines of a command's messages, for an error
std::string tail(const std::string &s, size_t max = 600) {
    std::string t = trimmed(s);
    return t.size() > max ? "…" + t.substr(t.size() - max) : t;
}

std::string readFile(const std::string &path) {
    std::ifstream in(path);
    std::stringstream ss;
    ss << in.rdbuf();
    return ss.str();
}

// written next to the target, then renamed over it
bool writeFile(const std::string &path, const std::string &data, mode_t mode = 0600) {
    std::string tmp = path + ".tmp";
    int fd = open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, mode);
    if (fd < 0) return false;
    size_t done = 0;
    while (done < data.size()) {
        ssize_t n = write(fd, data.data() + done, data.size() - done);
        if (n < 0 && errno == EINTR) continue;
        if (n <= 0) { close(fd); unlink(tmp.c_str()); return false; }
        done += (size_t)n;
    }
    fsync(fd);
    close(fd);
    return rename(tmp.c_str(), path.c_str()) == 0;
}

bool writeJson(const char *path, json_object *o) {
    mkdir(STATE_DIR, 0700);
    return writeFile(path, json_object_to_json_string_ext(o, JSON_C_TO_STRING_PLAIN));
}

json_object *readJson(const char *path) {
    std::string s = readFile(path);
    return s.empty() ? nullptr : json_tokener_parse(s.c_str());
}

// installed packages: name -> version-release (epoch: if not 0)
std::map<std::string, std::string> installed() {
    std::map<std::string, std::string> m;
    Result r = runCommand({RPM, "-qa", "--qf", "%{NAME}\\t%{EVR}\\n"});
    std::istringstream in(r.out);
    std::string line;
    while (std::getline(in, line)) {
        size_t tab = line.find('\t');
        if (tab != std::string::npos) m[line.substr(0, tab)] = line.substr(tab + 1);
    }
    return m;
}

// ---- repositories (/etc/yum.repos.d/*.repo) ----

struct Repo {
    std::string id, name, baseurl, file;
    bool enabled = true;
};

std::vector<std::string> repoFiles() {
    std::vector<std::string> files;
    if (DIR *d = opendir(REPOS_DIR)) {
        while (dirent *e = readdir(d)) {
            std::string n = e->d_name;
            if (n.size() > 5 && n.compare(n.size() - 5, 5, ".repo") == 0) files.push_back(std::string(REPOS_DIR) + "/" + n);
        }
        closedir(d);
    }
    std::sort(files.begin(), files.end());
    return files;
}

std::string iniValue(const std::string &line, std::string &key) {
    size_t eq = line.find('=');
    if (eq == std::string::npos) { key.clear(); return ""; }
    key = trimmed(line.substr(0, eq));
    return trimmed(line.substr(eq + 1));
}

bool truthy(const std::string &v) {
    std::string l = v;
    std::transform(l.begin(), l.end(), l.begin(), ::tolower);
    return l == "1" || l == "yes" || l == "true" || l == "on";
}

std::vector<Repo> repos() {
    std::vector<Repo> list;
    for (auto &f : repoFiles()) {
        std::ifstream in(f);
        std::string line;
        Repo *cur = nullptr;
        while (std::getline(in, line)) {
            std::string l = trimmed(line);
            if (l.empty() || l[0] == '#' || l[0] == ';') continue;
            if (l.front() == '[' && l.back() == ']') {
                list.push_back(Repo{});
                cur = &list.back();
                cur->id = l.substr(1, l.size() - 2);
                cur->file = f;
                continue;
            }
            if (!cur) continue;
            std::string key, value = iniValue(l, key);
            if (key == "name") cur->name = value;
            else if (key == "baseurl") cur->baseurl = value;
            else if (key == "enabled") cur->enabled = truthy(value);
        }
    }
    return list;
}

json_object *reposJson() {
    json_object *a = json_object_new_array();
    for (auto &r : repos()) {
        json_object *o = json_object_new_object();
        json_object_object_add(o, "id", jstr(r.id));
        json_object_object_add(o, "name", jstr(r.name));
        json_object_object_add(o, "baseurl", jstr(r.baseurl));
        json_object_object_add(o, "enabled", json_object_new_boolean(r.enabled));
        json_object_object_add(o, "file", jstr(r.file));
        json_object_array_add(a, o);
    }
    return a;
}

// sets enabled= in the repository's section, adding it if it's missing
json_object *setRepo(json_object *args) {
    std::string id = webctl::str(args, "id");
    bool enable = webctl::boolean(args, "enabled", true);
    for (auto &r : repos()) {
        if (r.id != id) continue;
        std::ifstream in(r.file);
        std::string line, out;
        bool inSection = false, done = false;
        auto addLine = [&] { out += std::string("enabled=") + (enable ? "1" : "0") + "\n"; done = true; };
        while (std::getline(in, line)) {
            std::string l = trimmed(line);
            if (!l.empty() && l.front() == '[' && l.back() == ']') {
                if (inSection && !done) addLine();
                inSection = l.substr(1, l.size() - 2) == id;
            } else if (inSection && !done) {
                std::string key;
                iniValue(l, key);
                if (key == "enabled") { addLine(); continue; }
            }
            out += line + "\n";
        }
        if (inSection && !done) addLine();
        struct stat st {};
        stat(r.file.c_str(), &st);
        if (!writeFile(r.file, out, st.st_mode ? st.st_mode & 07777 : 0644))
            return error(tr("%1 can't be written: %2", "%1 kann nicht geschrieben werden: %2", {r.file, strerror(errno)}), 500);
        return webctl::ok();
    }
    return error(tr("no repository %1", "kein Repository %1", {id}), 404);
}

// ---- looking for updates ----

// dnf5 check-upgrade --json: {"upgrades": [{name, arch, evr, repository}], ...};
// rc 100: there are updates, 0: none
json_object *check(bool refresh) {
    json_object *state = json_object_new_object();
    json_object_object_add(state, "checked", json_object_new_int64((int64_t)time(nullptr)));
    std::vector<std::string> argv = {DNF, "-q", "check-upgrade", "--json"};
    if (refresh) argv.insert(argv.begin() + 1, "--refresh");
    else argv.insert(argv.begin() + 1, "--cacheonly");
    Result r = runCommand(argv);
    json_object *upgrades = json_object_new_array();
    json_object *found = nullptr;
    if ((r.rc == 0 || r.rc == 100) && !trimmed(r.out).empty()) found = json_tokener_parse(r.out.c_str());
    if (r.rc != 0 && r.rc != 100) {
        std::string msg = tail(r.err.empty() ? r.out : r.err);
        json_object_object_add(state, "checkError",
            jstr(tr("dnf5 failed (exit %1): %2", "dnf5 ist fehlgeschlagen (Exit %1): %2", {std::to_string(r.rc), msg})));
    }
    if (found) {
        auto have = installed();
        std::set<std::string> seen;
        json_object_object_foreach(found, section, list) {
            (void)section;
            if (!json_object_is_type(list, json_type_array)) continue;
            for (size_t i = 0; i < json_object_array_length(list); ++i) {
                json_object *p = json_object_array_get_idx(list, i);
                std::string name = webctl::str(p, "name"), arch = webctl::str(p, "arch");
                if (!seen.insert(name + "." + arch).second) continue;
                json_object *u = json_object_new_object();
                json_object_object_add(u, "name", jstr(name));
                json_object_object_add(u, "arch", jstr(arch));
                json_object_object_add(u, "installed", jstr(have.count(name) ? have[name] : ""));
                json_object_object_add(u, "available", jstr(webctl::str(p, "evr")));
                json_object_object_add(u, "repository", jstr(webctl::str(p, "repository")));
                json_object_array_add(upgrades, u);
            }
        }
        json_object_put(found);
    }
    json_object_object_add(state, "upgrades", upgrades);
    writeJson(CHECK_FILE, state);
    return state;
}

// ---- installing them ----

void noteReboot(const std::vector<std::string> &names) {
    std::set<std::string> pending;
    std::istringstream in(readFile(REBOOT_FILE));
    for (std::string n; std::getline(in, n);) if (!n.empty()) pending.insert(n);
    for (auto &n : names) pending.insert(n);
    std::string out;
    for (auto &n : pending) out += n + "\n";
    mkdir(RUN_DIR, 0700);
    writeFile(REBOOT_FILE, out);
}

bool validName(const std::string &n) {
    if (n.empty() || n[0] == '-') return false;
    for (char c : n)
        if (!(isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '+')) return false;
    return true;
}

json_object *upgrade(const std::vector<std::string> &packages) {
    auto before = installed();
    std::vector<std::string> argv = {DNF, "-y", "upgrade"};
    argv.insert(argv.end(), packages.begin(), packages.end());
    time_t started = time(nullptr);
    Result r = runCommand(argv);
    auto after = installed();

    // what changed: [{name, from, to}]
    json_object *changed = json_object_new_array();
    std::vector<std::string> reboot;
    for (auto &[name, evr] : after) {
        auto it = before.find(name);
        if (it == before.end() || it->second == evr) continue;
        json_object *c = json_object_new_object();
        json_object_object_add(c, "name", jstr(name));
        json_object_object_add(c, "from", jstr(it->second));
        json_object_object_add(c, "to", jstr(evr));
        json_object_array_add(changed, c);
        if (REBOOT_PACKAGES.count(name)) reboot.push_back(name);
    }
    if (!reboot.empty()) noteReboot(reboot);

    std::string log = r.out;
    if (!r.err.empty()) log += (log.empty() ? "" : "\n") + r.err;
    if (log.size() > LOG_MAX) log = "…\n" + log.substr(log.size() - LOG_MAX);

    int changedCount = (int)json_object_array_length(changed);
    json_object *last = json_object_new_object();
    json_object_object_add(last, "started", json_object_new_int64((int64_t)started));
    json_object_object_add(last, "finished", json_object_new_int64((int64_t)time(nullptr)));
    json_object_object_add(last, "ok", json_object_new_boolean(r.rc == 0));
    json_object_object_add(last, "exitCode", json_object_new_int(r.rc));
    json_object_object_add(last, "changed", changed);
    json_object_object_add(last, "log", jstr(log));
    writeJson(LAST_FILE, last);
    json_object_put(last);

    // the list of updates as it is now, from the metadata just downloaded
    json_object_put(check(false));

    if (r.rc != 0)
        return error(tr("the update failed (dnf5 exit %1): %2", "die Aktualisierung ist fehlgeschlagen (dnf5 Exit %1): %2",
                        {std::to_string(r.rc), tail(r.err.empty() ? r.out : r.err)}), 500);
    json_object *o = webctl::ok();
    json_object_object_add(o, "changed", json_object_new_int(changedCount));
    return o;
}

json_object *status() {
    json_object *o = json_object_new_object();
    json_object *state = readJson(CHECK_FILE);
    if (state) {
        json_object_object_foreach(state, k, v) json_object_object_add(o, k, json_object_get(v));
        json_object_put(state);
    }
    if (!json_object_object_get_ex(o, "upgrades", nullptr)) json_object_object_add(o, "upgrades", json_object_new_array());
    if (json_object *last = readJson(LAST_FILE)) json_object_object_add(o, "last", last);
    json_object *reboot = json_object_new_array();
    std::istringstream in(readFile(REBOOT_FILE));
    for (std::string n; std::getline(in, n);) if (!n.empty()) json_object_array_add(reboot, jstr(n));
    json_object_object_add(o, "reboot", reboot);
    json_object_object_add(o, "repos", reposJson());
    json_object_object_add(o, "available", json_object_new_boolean(access(DNF, X_OK) == 0));
    json_object_object_add(o, "jobs", webctl::jobs::list());
    return o;
}

json_object *call(const webctl_request *req) {
    webctl::LanguageScope lang(req->lang);
    std::string action = req->action;
    if (action == "status") return status();
    if (action == "repo") return setRepo(req->args);
    if (access(DNF, X_OK) != 0) return error(tr("dnf5 is not installed", "dnf5 ist nicht installiert"), 503);
    if (action == "check")
        return webctl::jobs::run("Looking for updates", "Suche nach Aktualisierungen", {}, [] {
            json_object *s = check(true);
            json_object *e = nullptr;
            if (json_object_object_get_ex(s, "checkError", &e)) {
                json_object *err = error(json_object_get_string(e), 502);
                json_object_put(s);
                return err;
            }
            return s;
        });
    if (action == "upgrade") {
        std::vector<std::string> packages;
        json_object *list = nullptr;
        if (json_object_object_get_ex(req->args, "packages", &list) && json_object_is_type(list, json_type_array)) {
            for (size_t i = 0; i < json_object_array_length(list); ++i) {
                const char *s = json_object_get_string(json_object_array_get_idx(list, i));
                std::string n = s ? s : "";
                if (!validName(n)) return error(tr("invalid package name %1", "ungültiger Paketname %1", {n}));
                packages.push_back(n);
            }
        }
        std::string what = packages.empty() ? tr("all packages", "alle Pakete")
                         : packages.size() == 1 ? packages[0]
                         : tr("%1 packages", "%1 Pakete", {std::to_string(packages.size())});
        return webctl::jobs::run("Installing updates (%1)", "Aktualisierungen werden installiert (%1)", {what},
                                 [packages] { return upgrade(packages); });
    }
    return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404);
}

} // namespace

WEBCTL_MODULE({WEBCTL_MODULE_ABI, "updates", "Updates", 35, nullptr, nullptr, call});

www/modules/updates.js

0 → 100644
+183 −0

File added.

Preview size limit exceeded, changes collapsed.