ae8715473c
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
306 lines
13 KiB
C++
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;
|
|
}
|