Files
slocker-lite/src/port_forward.cpp
T
ceamac ae8715473c Add UDP support to -p/--port-forward
Extends the spec syntax with an optional /tcp|udp suffix
([<network>:]<host-port>:<container-port>[/proto]), defaulting to tcp so
every existing -p spec keeps working unchanged. PortForwardSpec and
ActivePortForward carry the resolved PortForwardProtocol; add/remove_port_forward()
use it to pick iptables' own -p tcp/-p udp for both the DNAT and FORWARD
ACCEPT rules. The port-forward state file gains a 4th field for the
protocol; clean_stale_port_forwards() parses per-line rather than chaining
extraction operators, so a pre-UDP 3-field record still gets its rule
removed instead of silently short-circuiting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-09-04 06:48:14 +00:00

306 lines
13 KiB
C++

// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#include "port_forward.h"
#include <algorithm>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <system_error>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "config_file.h"
#include "pid_file.h"
#include "process.h"
namespace {
std::optional<int> parse_port(const std::string& text) {
char* end = nullptr;
long parsed = std::strtol(text.c_str(), &end, 10);
if (text.empty() || !end || *end != '\0' || parsed < 1 || parsed > 65535) {
return std::nullopt;
}
return static_cast<int>(parsed);
}
// The exact string iptables' own -p flag expects.
const char* iptables_proto(PortForwardProtocol protocol) {
return protocol == PortForwardProtocol::udp ? "udp" : "tcp";
}
} // namespace
std::optional<PortForwardSpec> parse_port_forward_spec(const std::string& text) {
std::vector<std::string> parts;
size_t start = 0;
while (true) {
size_t colon = text.find(':', start);
parts.push_back(text.substr(start, colon == std::string::npos ? std::string::npos : colon - start));
if (colon == std::string::npos) {
break;
}
start = colon + 1;
}
if (parts.size() != 2 && parts.size() != 3) {
spdlog::error("invalid -p spec '{}': expected [<network>:]<host-port>:<container-port>", text);
return std::nullopt;
}
std::optional<std::string> network;
const std::string& host_port_str = parts[parts.size() - 2];
std::string container_port_str = parts[parts.size() - 1];
if (parts.size() == 3) {
network = parts[0];
}
PortForwardProtocol protocol = PortForwardProtocol::tcp;
if (auto slash = container_port_str.find('/'); slash != std::string::npos) {
std::string proto_str = container_port_str.substr(slash + 1);
container_port_str.erase(slash);
if (proto_str == "udp") {
protocol = PortForwardProtocol::udp;
} else if (proto_str != "tcp") {
spdlog::error("invalid -p spec '{}': protocol must be 'tcp' or 'udp'", text);
return std::nullopt;
}
}
auto host_port = parse_port(host_port_str);
auto container_port = parse_port(container_port_str);
if (!host_port || !container_port) {
spdlog::error("invalid -p spec '{}': ports must be 1-65535", text);
return std::nullopt;
}
return PortForwardSpec{network, *host_port, *container_port, protocol};
}
std::optional<ActivePortForward> add_port_forward(const PortForwardSpec& spec,
const std::vector<JoinedNetwork>& joined) {
const JoinedNetwork* target = nullptr;
if (spec.network) {
auto it = std::find_if(joined.begin(), joined.end(),
[&](const JoinedNetwork& j) { return j.network.name == *spec.network; });
if (it == joined.end()) {
spdlog::error("-p: network '{}' was not successfully joined", *spec.network);
return std::nullopt;
}
if (it->network.kind != NetworkKind::extern_) {
spdlog::error("-p: network '{}' is intern, not extern -- port forwarding requires an extern network",
*spec.network);
return std::nullopt;
}
target = &*it;
} else {
const JoinedNetwork* found = nullptr;
int extern_count = 0;
for (const auto& j : joined) {
if (j.network.kind == NetworkKind::extern_) {
found = &j;
++extern_count;
}
}
if (extern_count == 0) {
spdlog::error("-p: no extern network was joined to forward into");
return std::nullopt;
}
if (extern_count > 1) {
spdlog::error(
"-p: container joined more than one extern network; specify which with "
"-p <network>:<host-port>:<container-port>");
return std::nullopt;
}
target = found;
}
std::string dest = fmt::format("{}:{}", target->container_ip, spec.container_port);
std::string host_port_str = fmt::to_string(spec.host_port);
std::string container_port_str = fmt::to_string(spec.container_port);
// Both PREROUTING and OUTPUT: PREROUTING only ever sees packets arriving
// from an actual network interface -- a locally-generated packet (e.g.
// `curl <this host's own real IP>:<host-port>`, run *on this same
// host*) goes through OUTPUT instead, never PREROUTING at all.
// Confirmed by testing: PREROUTING-only left that case
// connection-refused, while the container's own address was directly
// reachable the whole time -- the same PREROUTING/OUTPUT split Docker's
// own DNAT setup already accounts for.
//
// Known limitation, not solved here: `curl localhost:<host-port>` (or
// any 127.0.0.0/8 destination) specifically still doesn't work, even
// with both chains covered above -- confirmed by testing that it's a
// separate problem from the PREROUTING/OUTPUT split: NAT hairpinning.
// Once DNAT rewrites the destination to the container's IP, the packet
// still carries its *original* source address (127.0.0.1); the
// container's own kernel sees an inbound packet claiming to be *from*
// loopback arriving on a non-loopback interface (its `eth<N>`) and
// drops it as a martian source -- this is an IP-layer check, so it
// applies identically to both TCP and UDP. A full fix needs source
// masquerading for exactly this case (matching only host-local traffic,
// not genuine external clients -- losing the real client IP for those
// would be a regression) or a userland proxy (the approach Docker
// itself historically used for the same reason) -- out of scope here;
// `curl <this host's real, externally-reachable IP>:<host-port>`
// (verified working, see docs/networking-design.md) is the
// actually-relevant path for external clients, which is what -p exists
// for.
const char* proto = iptables_proto(spec.protocol);
for (auto* chain : {"PREROUTING", "OUTPUT"}) {
if (run_process({"iptables", "-t", "nat", "-A", chain, "-p", proto, "--dport", host_port_str, "-j", "DNAT",
"--to-destination", dest})
.exit_code != 0) {
spdlog::error("-p: failed to add {} DNAT rule for host port {}", chain, spec.host_port);
for (auto* added : {"PREROUTING", "OUTPUT"}) {
if (added == chain) {
break;
}
run_process({"iptables", "-t", "nat", "-D", added, "-p", proto, "--dport", host_port_str, "-j",
"DNAT", "--to-destination", dest});
}
return std::nullopt;
}
}
if (run_process({"iptables", "-A", "FORWARD", "-p", proto, "-d", target->container_ip, "--dport",
container_port_str, "-j", "ACCEPT"})
.exit_code != 0) {
spdlog::error("-p: failed to add FORWARD accept rule for host port {}; removing the DNAT rules too",
spec.host_port);
for (auto* chain : {"PREROUTING", "OUTPUT"}) {
run_process({"iptables", "-t", "nat", "-D", chain, "-p", proto, "--dport", host_port_str, "-j", "DNAT",
"--to-destination", dest});
}
return std::nullopt;
}
return ActivePortForward{spec.host_port, target->container_ip, spec.container_port, spec.protocol};
}
void remove_port_forward(const ActivePortForward& active) {
std::string dest = fmt::format("{}:{}", active.container_ip, active.container_port);
std::string host_port_str = fmt::to_string(active.host_port);
std::string container_port_str = fmt::to_string(active.container_port);
const char* proto = iptables_proto(active.protocol);
for (auto* chain : {"PREROUTING", "OUTPUT"}) {
if (run_process({"iptables", "-t", "nat", "-D", chain, "-p", proto, "--dport", host_port_str, "-j", "DNAT",
"--to-destination", dest})
.exit_code != 0) {
spdlog::warn("failed to remove {} DNAT rule for host port {}", chain, active.host_port);
}
}
if (run_process({"iptables", "-D", "FORWARD", "-p", proto, "-d", active.container_ip, "--dport",
container_port_str, "-j", "ACCEPT"})
.exit_code != 0) {
spdlog::warn("failed to remove FORWARD accept rule for host port {}", active.host_port);
}
}
std::filesystem::path port_forward_state_path(std::string_view container_name, pid_t pid) {
return xdg_state_dir() / "port-forwards" / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
}
void record_port_forwards(std::string_view container_name, pid_t pid, const std::vector<ActivePortForward>& active) {
if (active.empty()) {
return;
}
auto path = port_forward_state_path(container_name, pid);
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
spdlog::warn("failed to create directory {}: {}", path.parent_path().string(), ec.message());
return;
}
std::ofstream out(path);
if (!out) {
spdlog::warn("failed to create {}", path.string());
return;
}
for (const auto& a : active) {
out << a.host_port << ' ' << a.container_ip << ' ' << a.container_port << ' ' << iptables_proto(a.protocol)
<< '\n';
}
}
void remove_port_forward_record(std::string_view container_name, pid_t pid) {
std::error_code ec;
std::filesystem::remove(port_forward_state_path(container_name, pid), ec);
}
std::vector<std::string> clean_stale_port_forwards() {
std::vector<std::string> cleaned;
auto dir = xdg_state_dir() / "port-forwards";
std::error_code ec;
auto it = std::filesystem::directory_iterator(dir, ec);
if (ec) {
return cleaned; // directory doesn't exist yet -- nothing to sweep
}
auto sessions = list_sessions();
for (const auto& entry : it) {
std::string filename = entry.path().filename().string();
// Same naming scheme as session_pid_file_path() (pid_file.h),
// deliberately -- cross-referencing filenames directly against
// list_sessions()'s own SessionInfo::path reuses its liveness check
// rather than re-deriving pid liveness a second, drifting way.
bool session_running = std::any_of(sessions.begin(), sessions.end(), [&](const SessionInfo& session) {
return session.running && session.path.filename().string() == filename;
});
if (session_running) {
continue;
}
// A per-line parse (rather than chaining a 4th `>>` onto one big
// `while`) so a pre-UDP-support 3-field line still gets its rule
// removed -- chaining would fail the 4th extraction and short-
// circuit the whole while condition before the loop body (the
// actual removal) ever ran for that line, silently leaking it.
std::ifstream in(entry.path());
std::string line;
while (std::getline(in, line)) {
std::istringstream iss(line);
int host_port = 0;
int container_port = 0;
std::string container_ip;
if (!(iss >> host_port >> container_ip >> container_port)) {
continue; // malformed line, skip rather than abort the whole file
}
std::string proto_str;
PortForwardProtocol protocol = (iss >> proto_str && proto_str == "udp") ? PortForwardProtocol::udp
: PortForwardProtocol::tcp;
remove_port_forward({host_port, container_ip, container_port, protocol});
}
std::error_code remove_ec;
std::filesystem::remove(entry.path(), remove_ec);
if (remove_ec) {
spdlog::warn("failed to remove stale port-forward record {}: {}", entry.path().string(),
remove_ec.message());
continue;
}
cleaned.push_back(filename);
}
return cleaned;
}