Ctrl K
ComunidadGuías…

service/artlight-input-service.cpp

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
 * artlight-input-service — the privileged half of "the importer never attaches by itself".
 *
 * WHY THIS EXISTS
 *
 * On Linux, taking a device from an exporter is not something an ordinary program can do.
 * `usbip attach` ends up writing to /sys/devices/platform/vhci_hcd.0/attach, which is mode 0600
 * owned by root, so an unprivileged call dies with `usbip: error: import device` and exit 1. That
 * was measured on the z13, not assumed. On Windows the same call succeeds from a UAC-filtered
 * limited token, and that asymmetry is real: Windows drives the client directly and needs no
 * service. Do not "fix" Windows by adding a daemon it provably does not need.
 *
 * HOW ROOT RUNS IT — AND WHY IT IS NO LONGER pkexec
 *
 * This used to be reached as `pkexec /usr/libexec/vibeshine/artlight-input-service attach ...`,
 * authorised by org.artlight.input-service.policy. That cannot work, and it was measured rather
 * than reasoned about. The caller is the ArtLight host, which is a privileged machine host: it
 * starts by calling sanitize_startup_capabilities(), which ends in
 *
 *     prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
 *
 * NoNewPrivileges is inherited across fork and exec and cannot be unset. The kernel therefore
 * refuses to honour pkexec's setuid bit for the host or anything it spawns, pkexec finds
 * geteuid() != 0, and it dies with `pkexec must be setuid root`. Measured on the z13 on
 * 2026-10-06: /proc/<host>/status read NoNewPrivs: 1 while pkexec on disk was 4755 and fine.
 *
 * The polkit action and rule that used to authorise that call were correct about *authorisation*
 * and blind to *capability*. Their own comment describes the ArtMoon shape - a helper called from
 * a GUI in the user's active session - and ArtLight is not that shape and never was: its caller is
 * a hardened systemd system unit. No amount of polkit fixing makes a setuid transition happen
 * inside a process that has permanently refused new privileges.
 *
 * So the privilege comes from outside the caller, which is the only place it can come from:
 * systemd owns a socket at /run/artlight/usbip-helper.sock, mode 0660 root:artlight, and starts
 * one copy of this program as root per accepted connection. The caller is the only account that
 * can reach the socket, and it cannot use it to run anything but this program:
 *
 *     systemd socket -> artlight-input-service --stdin
 *     request  (one packet): <verb> [arg [arg]]
 *     response (one packet): <code> <outlen> <errlen>\n<out bytes><err bytes>
 *
 * Both directions of that encoding live in src/usbip_helper_protocol.h, which the caller includes
 * too. A wire format written at both ends is a wire format that drifts, and this drift would land
 * on the half that runs as root.
 *
 * The verbs are unchanged, and so is every rule about what this program refuses to trust.
 *
 * WHAT IT REFUSES TO TRUST
 *
 * The caller is unprivileged and hands us two things: an exporter address and a busid. The address
 * is the more dangerous of the two, because it decides which machine this one connects to, so it
 * is validated by the SAME rule the app uses — is_usable_exporter() from the policy, linked in
 * rather than copied. A second copy living here would be a rule that drifts, and the drift would
 * be on the privileged side. This is the one place that must not happen.
 *
 * It keeps no state. It does not read the caller's list and act on it; it is told one action and
 * performs that action, and the kernel's own view is the only thing it consults.
 */

#include "src/usbip_helper_protocol.h"
#include "src/usbip_input_policy.h"

#include <sys/wait.h>
#include <unistd.h>

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>

using input::usbip::is_usable_exporter;
using input::usbip::is_valid_busid;
// A namespace alias, not just two using-declarations: the verbs below also name
// helper_protocol::Request and helper_protocol::kMaxRequest, and bringing in only the functions
// leaves those two unqualified names undeclared. The first build of this file said so.
namespace helper_protocol = input::usbip::helper_protocol;
using helper_protocol::decode_request;
using helper_protocol::encode_reply;

namespace {

constexpr auto kSelf = "artlight-input-service";

struct RunResult {
  int code = 0;
  std::string out;
  std::string err;
};

/// The path the client actually lives on, resolved absolutely. Never a bare name: this program runs
/// as root, so what it executes must not be decided by anything it was handed.
constexpr const char *kUsbipCandidates[] = {"/usr/bin/usbip", "/usr/local/bin/usbip", "/bin/usbip"};

const char *resolveUsbip() {
  for (const auto *candidate : kUsbipCandidates) {
    if (::access(candidate, X_OK) == 0) {
      return candidate;
    }
  }
  return nullptr;
}

/**
 * Run a command and bring back BOTH streams separately.
 *
 * Separate, not merged, and that is not tidiness. The reason a tool refused arrives on stderr while
 * the exit code says only "no", and the caller classifies this result — a merged buffer turns a
 * diagnosable refusal into an anonymous one. The exporter half already learned this the hard way.
 */
RunResult runProcess(const std::vector<std::string> &argv) {
  RunResult result;

  int outPipe[2] = {-1, -1};
  int errPipe[2] = {-1, -1};
  if (::pipe(outPipe) != 0 || ::pipe(errPipe) != 0) {
    result.code = -1;
    result.err = "could not create a pipe";
    return result;
  }

  const pid_t child = ::fork();
  if (child < 0) {
    result.code = -1;
    result.err = "could not fork";
    return result;
  }

  if (child == 0) {
    ::dup2(outPipe[1], STDOUT_FILENO);
    ::dup2(errPipe[1], STDERR_FILENO);
    ::close(outPipe[0]);
    ::close(outPipe[1]);
    ::close(errPipe[0]);
    ::close(errPipe[1]);

    std::vector<char *> cargv;
    cargv.reserve(argv.size() + 1);
    for (const auto &part : argv) {
      cargv.push_back(const_cast<char *>(part.c_str()));
    }
    cargv.push_back(nullptr);
    ::execv(cargv[0], cargv.data());

    // execv only returns on failure.
    std::cerr << kSelf << ": could not run " << argv[0] << ": " << std::strerror(errno) << "\n";
    ::_exit(127);
  }

  ::close(outPipe[1]);
  ::close(errPipe[1]);

  // Read both to EOF. If the child fills the stderr buffer while we are waiting on stdout, the
  // other pipe is still open and we will get to it - the child blocks, it does not deadlock against
  // us - but we must finish BOTH before reaping, or the buffered remainder is lost.
  auto drain = [](const int fd, std::string &into) {
    char buffer[4096];
    ssize_t read = 0;
    while ((read = ::read(fd, buffer, sizeof(buffer))) > 0) {
      into.append(buffer, static_cast<std::size_t>(read));
    }
  };
  drain(outPipe[0], result.out);
  drain(errPipe[0], result.err);

  ::close(outPipe[0]);
  ::close(errPipe[0]);

  int status = 0;
  ::waitpid(child, &status, 0);
  result.code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
  return result;
}

/// Print what the tool said, so the caller (and the log) keeps the tool's own words. The streams
/// are passed in rather than written to directly, because in socket mode they are captured and
/// framed instead of going to the terminal.
void echo(const RunResult &result, std::ostream &out, std::ostream &err) {
  if (!result.out.empty()) {
    out << result.out;
    if (result.out.back() != '\n') {
      out << "\n";
    }
  }
  if (!result.err.empty()) {
    err << result.err;
    if (result.err.back() != '\n') {
      err << "\n";
    }
  }
}

/// The vhci-hcd module is the gate on Linux, not the binary: a machine can have usbip installed and
/// still be unable to import anything because the module is not loaded. Loading it is privileged,
/// which is part of why this helper exists at all.
bool ensureImporterModule() {
  if (::access("/sys/module/vhci_hcd", F_OK) == 0) {
    return true;
  }
  const std::vector<std::string> modprobe = {"/usr/sbin/modprobe", "vhci_hcd"};
  const auto result = runProcess(modprobe);
  return result.code == 0 && ::access("/sys/module/vhci_hcd", F_OK) == 0;
}

bool parsePort(const std::string &text, int &port) {
  if (text.empty() || text.size() > 3) {
    return false;
  }
  for (const char c : text) {
    if (c < '0' || c > '9') {
      return false;
    }
  }
  port = std::stoi(text);
  // The Linux tool numbers ports FROM 0, unlike usbip-win2 which starts at 1 - so 0 must be
  // accepted here or the first port on every Linux machine could never be released.
  return port >= 0 && port <= 255;
}

/**
 * One verb, against the given streams. This is the whole program, and both entry points below run
 * it: argv when an administrator runs it by hand while diagnosing, and one request packet when
 * systemd is running it for the host.
 */
int runVerb(const std::string &verb, const std::vector<std::string> &args,
            std::ostream &out, std::ostream &err) {
  if (verb == "status") {
    const char *usbip = resolveUsbip();
    if (usbip == nullptr) {
      err << kSelf << ": no USB/IP client on this machine\n";
      return 2;
    }
    const auto result = runProcess({usbip, "port"});
    echo(result, out, err);
    return result.code == 0 ? 0 : 1;
  }

  if (verb == "attach") {
    if (args.size() != 2) {
      err << "usage: " << kSelf << " attach <exporter> <busid>\n";
      return 2;
    }
    const std::string &exporter = args[0];
    const std::string &busid = args[1];

    // Both of these are the CALLER'S input, and the caller is unprivileged. Validated with the
    // policy's own rules, linked in - the same functions the app uses to decide it is safe to ask.
    if (!is_usable_exporter(exporter)) {
      err << kSelf << ": refusing an unusable exporter address '" << exporter << "'\n";
      return 2;
    }
    if (!is_valid_busid(busid)) {
      err << kSelf << ": refusing '" << busid << "' - not a busid\n";
      return 2;
    }

    if (::geteuid() != 0) {
      err << kSelf << ": attaching needs administrator rights; this ran without them. "
                     "Nothing was changed.\n";
      return 3;
    }

    const char *usbip = resolveUsbip();
    if (usbip == nullptr) {
      err << kSelf << ": no USB/IP client on this machine\n";
      return 2;
    }

    if (!ensureImporterModule()) {
      err << kSelf << ": could not load the vhci-hcd module, so no device can arrive\n";
      return 1;
    }

    // argv, never a shell string. Nothing here can be re-split on the way to exec.
    const auto result = runProcess({usbip, "attach", "-r", exporter, "-b", busid});
    echo(result, out, err);
    return result.code == 0 ? 0 : 1;
  }

  if (verb == "detach" || verb == "detach-all") {
    if (verb == "detach" && args.size() != 1) {
      err << "usage: " << kSelf << " detach <port>\n";
      return 2;
    }

    int port = 0;
    if (verb == "detach" && !parsePort(args[0], port)) {
      err << kSelf << ": refusing '" << args[0] << "' - not a port\n";
      return 2;
    }

    if (::geteuid() != 0) {
      err << kSelf << ": giving a device back needs administrator rights; this ran without "
                     "them. Nothing was changed.\n";
      return 3;
    }

    const char *usbip = resolveUsbip();
    if (usbip == nullptr) {
      err << kSelf << ": no USB/IP client on this machine\n";
      return 2;
    }

    // detach takes a PORT and nothing else, which is why the session has to remember busid -> port.
    // `detach-all` is the crash-recovery sweep: if ArtLight died mid-session the devices are still
    // attached here and the exporter has lost them until someone intervenes.
    const auto result = verb == "detach-all" ? runProcess({usbip, "detach", "-a"})
                                             : runProcess({usbip, "detach", "-p", std::to_string(port)});
    echo(result, out, err);
    return result.code == 0 ? 0 : 1;
  }

  err << "usage: " << kSelf << " attach <exporter> <busid>\n"
      << "       " << kSelf << " detach <port>\n"
      << "       " << kSelf << " detach-all\n"
      << "       " << kSelf << " status\n";
  return 2;
}

/**
 * The socket entry point. systemd hands us ONE accepted connection on stdin, and the request is ONE
 * packet: `<verb> [arg [arg]]`. The reply is ONE packet, framed so the caller can hand stdout and
 * stderr back to the host as the two separate streams it classifies on. Both encodings come from
 * src/usbip_helper_protocol.h, shared with the caller, so the two ends cannot disagree.
 */
int runFromSocket() {
  std::vector<char> raw(helper_protocol::kMaxRequest);
  const ssize_t got = ::read(STDIN_FILENO, raw.data(), raw.size());
  if (got <= 0) {
    std::cerr << kSelf << ": empty request, nothing done\n";
    return 2;
  }

  helper_protocol::Request request;
  if (!decode_request(std::string_view {raw.data(), static_cast<std::size_t>(got)}, request)) {
    std::cerr << kSelf << ": empty request, nothing done\n";
    return 2;
  }

  std::ostringstream captured_out;
  std::ostringstream captured_err;
  const int code = runVerb(request.verb, request.args, captured_out, captured_err);

  const std::string packet = encode_reply(code, captured_out.str(), captured_err.str());

  // One write, one packet. A SOCK_SEQPACKET write is atomic, so a short write here would mean the
  // packet was too big for the buffer - reported rather than silently short-delivered.
  const ssize_t sent = ::write(STDOUT_FILENO, packet.data(), packet.size());
  if (sent != static_cast<ssize_t>(packet.size())) {
    std::cerr << kSelf << ": could not deliver the reply packet (" << sent << " of "
              << packet.size() << " bytes): " << std::strerror(errno) << "\n";
    return 1;
  }
  return 0;
}

}  // namespace

int main(int argc, char **argv) {
  // Fix what execv will find, before anything can run. This program runs as root and is started on
  // an unprivileged caller's request; systemd already gives it a clean PATH, but that is a promise
  // about systemd, and this is a fact about this file. It sits above the argument parsing so no
  // path through the program can precede it.
  ::setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1);

  const std::string verb = argc > 1 ? argv[1] : "";

  // systemd's entry point. One connection, one verb, and the result framed back over the socket.
  if (verb == "--stdin") {
    return runFromSocket();
  }

  // An administrator's entry point, kept deliberately: when this path misbehaves, the first useful
  // thing anyone does is run the verb by hand as root and read what it says. That must stay
  // possible without a socket in the way.
  const std::vector<std::string> args {argv + 2, argv + argc};
  return runVerb(verb, args, std::cout, std::cerr);
}