/* * VWE fork: `backend=steam` -- EthernetConnection over the vwesteam:: tunnel. * See ethernet_steam.h for why this exists, steamlink.h for the wire format, * and ../steam/SESSION-CONTRACT.md for the session file and env handoff. */ #include "config.h" #ifdef WIN32 #include "ethernet_steam.h" #include "steamlink.h" #include "dosbox.h" #include "logging.h" #include "support.h" /* strcasecmp */ #include "control.h" /* [ne2000] macaddr -- required here, not optional */ #include #include #include #include #include #include /* vwesteam:: logs through a plain function pointer so the gateway can send the * same diagnostics to stderr; in the fork they belong wherever every other * device logs. Set before Init() so Init()'s own failures are visible. */ static void SteamLog(const char* msg) { LOG_MSG("%s", msg); } /* ------------------------------------------------------------------ * * Session file (SESSION-CONTRACT section 2) * * A tolerant hand-rolled reader, deliberately NOT a JSON library: the fork * has no JSON dependency and this file is machine-written by TeslaLobby in * one fixed shape, so scanning for the six keys we need is cheaper than * taking on one. What it will not do is half-read a roster -- a peer that * silently went missing looks exactly like a peer that never connected, and * that is the worst thing to be debugging at 9pm. Anything malformed is a * hard failure naming the file and the key. * ------------------------------------------------------------------ */ struct SessionPeer { uint64_t steamId = 0; uint32_t ip = 0; /* host order; 0 = absent */ uint8_t mac[6] = {}; bool macKnown = false; int slot = -1; bool gateway = false; std::string name; }; static const char* SkipWs(const char* p, const char* end) { while (p < end && (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n')) p++; return p; } /* Matching '}' / ']' for the bracket at p. Quote-aware, so a bracket inside a * pilot name cannot unbalance the walk. */ static const char* MatchBracket(const char* p, const char* end) { const char open = *p, close = (open == '{') ? '}' : ']'; int depth = 0; bool instr = false; for (; p < end; p++) { if (instr) { if (*p == '\\') p++; else if (*p == '"') instr = false; continue; } if (*p == '"') instr = true; else if (*p == open) depth++; else if (*p == close && --depth == 0) return p; } return NULL; } /* Scalar value of "key" in [p,end), quotes and escapes stripped. Callers bound * the span to one object, so a flat search cannot wander into a sibling. */ static bool JsonGet(const char* p, const char* end, const char* key, std::string& out) { char pat[32]; snprintf(pat, sizeof(pat), "\"%s\"", key); const size_t klen = strlen(pat); for (const char* q = p; q < end; q++) { q = (const char*)memchr(q, '"', (size_t)(end - q)); if (!q) break; if ((size_t)(end - q) < klen || memcmp(q, pat, klen) != 0) continue; const char* v = SkipWs(q + klen, end); if (v >= end || *v != ':') continue; v = SkipWs(v + 1, end); out.clear(); if (v < end && *v == '"') { for (v++; v < end && *v != '"'; v++) { if (*v == '\\' && v + 1 < end) v++; out += *v; } } else { while (v < end && *v != ',' && *v != '}' && *v != ']' && *v != ' ' && *v != '\t' && *v != '\r' && *v != '\n') out += *v++; } return true; } return false; } /* Contents of the {object} or [array] value of "key", brackets excluded. */ static bool JsonSpan(const char* p, const char* end, const char* key, const char*& bo, const char*& eo) { char pat[32]; snprintf(pat, sizeof(pat), "\"%s\"", key); const size_t klen = strlen(pat); for (const char* q = p; q < end; q++) { q = (const char*)memchr(q, '"', (size_t)(end - q)); if (!q) break; if ((size_t)(end - q) < klen || memcmp(q, pat, klen) != 0) continue; const char* v = SkipWs(q + klen, end); if (v >= end || *v != ':') continue; v = SkipWs(v + 1, end); if (v >= end || (*v != '{' && *v != '[')) continue; const char* close = MatchBracket(v, end); if (!close) return false; bo = v + 1; eo = close; return true; } return false; } /* Next {object} inside an array span, advancing p past it. */ static bool JsonNextObject(const char*& p, const char* end, const char*& bo, const char*& eo) { p = SkipWs(p, end); while (p < end && *p == ',') p = SkipWs(p + 1, end); if (p >= end || *p != '{') return false; const char* close = MatchBracket(p, end); if (!close) return false; bo = p + 1; eo = close; p = close + 1; return true; } /* The WIDTH-LIMITED %02x is not cosmetic: it is exactly what ne2000.cpp's own * macaddr parser uses, and the emulated NIC's answer has to be the same six * bytes this backend filters on and announces in HELLO. Plain %x diverges * silently on a malformed conf -- measured, not theorised: * * macaddr ne2000.cpp (%02x) plain %x * 02:00:C8:00:00:7100 02:00:c8:00:00:71 02:00:c8:00:00:00 (truncated) * 02:00:3E7:00:00:70 ac:de:48:88:bb:aa 02:00:e7:00:00:70 (its fallback) * * Either row is a pod whose NIC answers to one address while the tunnel routes * to another: no unicast frame ever arrives and nothing says why. %n + the tail * check turns the leftover-digits row into a loud refusal instead of a third * wrong answer. ("02:00:3E7:..." is not hypothetical -- see configure.ps1's own * warning comment about emitting exactly that.) */ static bool ParseMac(const char* s, uint8_t mac[6]) { unsigned int m[6]; int n = -1; if (!s || sscanf(s, "%02x:%02x:%02x:%02x:%02x:%02x%n", &m[0],&m[1],&m[2],&m[3],&m[4],&m[5], &n) != 6 || n < 0) return false; while (s[n] == ' ' || s[n] == '\t' || s[n] == '\r' || s[n] == '\n') n++; if (s[n] != '\0') return false; /* trailing junk = a MAC nobody meant */ for (int i = 0; i < 6; i++) mac[i] = (uint8_t)m[i]; return true; } static bool ParseIp(const char* s, uint32_t& ip) { unsigned int a,b,c,d; if (!s || sscanf(s, "%u.%u.%u.%u", &a,&b,&c,&d) != 4 || (a|b|c|d) > 255) return false; ip = (a << 24) | (b << 16) | (c << 8) | d; return true; } static bool ParseId(const std::string& s, uint64_t& id) { char* e = NULL; id = strtoull(s.c_str(), &e, 10); return id != 0 && e && *e == '\0'; } /* One peer/self object. `where` names it in errors ("peers[1]", "self"). */ static bool ParsePeerObject(const char* bo, const char* eo, SessionPeer& out, const std::string& path, const char* where, std::string& err) { std::string v; if (!JsonGet(bo, eo, "steamId", v) || !ParseId(v, out.steamId)) { err = path + ": " + where + " has no usable steamId"; return false; } if (JsonGet(bo, eo, "ip", v) && !ParseIp(v.c_str(), out.ip)) { err = path + ": " + where + " has a bad ip \"" + v + "\""; return false; } if (JsonGet(bo, eo, "mac", v)) { if (!ParseMac(v.c_str(), out.mac)) { err = path + ": " + where + " has a bad mac \"" + v + "\""; return false; } out.macKnown = true; } if (JsonGet(bo, eo, "slot", v)) out.slot = atoi(v.c_str()); if (JsonGet(bo, eo, "gateway", v)) out.gateway = (strcasecmp(v.c_str(), "true") == 0); JsonGet(bo, eo, "name", out.name); return true; } static bool ParseSessionFile(const std::string& path, SessionPeer& self, std::vector& peers, std::string& err) { FILE* f = fopen(path.c_str(), "rb"); if (!f) { err = path + ": cannot open the session file"; return false; } std::string doc; char buf[4096]; size_t n; while ((n = fread(buf, 1, sizeof(buf), f)) > 0) doc.append(buf, n); fclose(f); if (doc.size() >= 3 && (unsigned char)doc[0] == 0xef) doc.erase(0, 3); /* stray BOM */ const char* p = doc.c_str(); const char* end = p + doc.size(); const char *bo, *eo; /* self is optional: the MAC that matters comes from [ne2000] anyway, and * the IP is only diagnostics. peers is not -- without it there is no * session and no allowlist. */ if (JsonSpan(p, end, "self", bo, eo) && !ParsePeerObject(bo, eo, self, path, "self", err)) return false; if (!JsonSpan(p, end, "peers", bo, eo)) { err = path + ": no \"peers\" array"; return false; } for (const char* q = bo; ; ) { const char *pb, *pe; if (!JsonNextObject(q, eo, pb, pe)) break; SessionPeer peer; char where[24]; snprintf(where, sizeof(where), "peers[%u]", (unsigned)peers.size()); if (!ParsePeerObject(pb, pe, peer, path, where, err)) return false; peers.push_back(peer); } if (peers.empty()) { err = path + ": \"peers\" is empty -- nobody to fly with"; return false; } return true; } /* A conf key left at the literal "env" (the default) defers to `var`. The * confs are install-static -- configure.ps1 renders them once -- while the * roster changes every session, so everything per-session arrives by env or * session file. SESSION-CONTRACT section 2. */ static std::string ConfOrEnv(Section_prop* section, const char* key, const char* var) { const char* v = section->Get_string(key); if (v && *v && strcasecmp(v, "env") != 0) return std::string(v); const char* e = getenv(var); return e ? std::string(e) : std::string(); } static void ParseIdCsv(const std::string& csv, std::vector& out) { size_t i = 0; while (i < csv.size()) { size_t j = csv.find_first_of(",; \t", i); if (j == std::string::npos) j = csv.size(); const std::string tok = csv.substr(i, j - i); i = j + 1; if (tok.empty()) continue; uint64_t id; if (!ParseId(tok, id)) { LOG_MSG("STEAM: ignoring unparseable peer id \"%s\"", tok.c_str()); continue; } if (std::find(out.begin(), out.end(), id) == out.end()) out.push_back(id); } } SteamEthernetConnection::SteamEthernetConnection() : EthernetConnection() { } SteamEthernetConnection::~SteamEthernetConnection() { /* Only if Init() took: Shutdown() is refcounted and ethernet.cpp deletes * us when Initialize() fails. */ if (up) vwesteam::Shutdown(); } bool SteamEthernetConnection::Initialize(Section* config) { Section_prop *section = static_cast(config); const std::string sessionpath = ConfOrEnv(section, "session", "VWE_STEAM_SESSION"); const std::string peerscsv = ConfOrEnv(section, "peers", "VWE_STEAM_PEERS"); const std::string lobbystr = ConfOrEnv(section, "lobby", "VWE_STEAM_LOBBY"); // Where pcap only loses its RX filter without a macaddr, the mesh ROUTES // to this one: peers bind it to our SteamID from the HELLO record and // address unicast frames by it. "random" would hand every peer a new // address on every restart -- so refuse to come up rather than half-work. const char* macstr = NULL; Section_prop *ne2ksec = control ? static_cast(control->GetSection("ne2000")) : NULL; if (ne2ksec) macstr = ne2ksec->Get_string("macaddr"); if (!ParseMac(macstr, mac)) { LOG_MSG("STEAM: [ne2000] macaddr is \"%s\" -- backend=steam needs a fixed MAC, " "exactly six two-digit hex octets (02:00: + the four IP octets, " "see SESSION-CONTRACT section 1)", (macstr && *macstr) ? macstr : "unset"); return false; } vwesteam::Config cfg; uint32_t selfip = 0; uint64_t sessionid = 0; std::string name; if (!sessionpath.empty()) { SessionPeer self; std::vector peers; std::string err; if (!ParseSessionFile(sessionpath, self, peers, err)) { LOG_MSG("STEAM: %s", err.c_str()); return false; } selfip = self.ip; sessionid = self.steamId; name = self.name; if (self.macKnown && memcmp(self.mac, mac, 6) != 0) LOG_MSG("STEAM: session file says our MAC is %02x:%02x:%02x:%02x:%02x:%02x but " "[ne2000] macaddr is %02x:%02x:%02x:%02x:%02x:%02x -- the conf wins, " "peers will route to the conf MAC", self.mac[0],self.mac[1],self.mac[2],self.mac[3],self.mac[4],self.mac[5], mac[0],mac[1],mac[2],mac[3],mac[4],mac[5]); // Peer MACs/IPs are logged but not fed to the transport -- those are // learned from HELLO, which stays correct when a player relaunches into // a different slot. The "gateway": true flag IS passed through: it is // the default route for any unicast we cannot place, which in practice // means everything bound for TeslaConsole at .10 (a host-LAN NIC MAC // that can never appear in the peer table). Without it those frames // would flood, putting the whole egg transfer on every player's uplink. for (size_t i = 0; i < peers.size(); i++) { cfg.peers.push_back(peers[i].steamId); if (peers[i].gateway) { if (cfg.gateway && cfg.gateway != peers[i].steamId) LOG_MSG("STEAM: session file marks more than one peer as the " "gateway; keeping %s", std::to_string(cfg.gateway).c_str()); else cfg.gateway = peers[i].steamId; } LOG_MSG("STEAM: peer slot %d \"%s\" %s ip %u.%u.%u.%u mac %02x:%02x:%02x:%02x:%02x:%02x%s", peers[i].slot, peers[i].name.c_str(), std::to_string(peers[i].steamId).c_str(), (peers[i].ip >> 24) & 0xff, (peers[i].ip >> 16) & 0xff, (peers[i].ip >> 8) & 0xff, peers[i].ip & 0xff, peers[i].mac[0],peers[i].mac[1],peers[i].mac[2], peers[i].mac[3],peers[i].mac[4],peers[i].mac[5], peers[i].gateway ? " (gateway)" : ""); } } // A csv is a union with the session file's roster rather than an override: // peers is an allowlist, and an operator who names an ID means to admit it. ParseIdCsv(peerscsv, cfg.peers); if (!lobbystr.empty() && !ParseId(lobbystr, cfg.lobby)) LOG_MSG("STEAM: ignoring unparseable lobby id \"%s\"", lobbystr.c_str()); cfg.channel = section->Get_int("channel"); const char* envname = getenv("VWE_STEAM_NAME"); cfg.name = (envname && *envname) ? std::string(envname) : name; if (cfg.peers.empty() && cfg.lobby == 0) { LOG_MSG("STEAM: no peers -- set [ethernet, steam] session/peers or the " "VWE_STEAM_SESSION / VWE_STEAM_PEERS environment variables"); return false; } // No session file: recover our IP from the MAC. Every VWE MAC is 02:00: + // the four IP octets (deploy/configure.ps1's formula), so this is exact // for a configured pod and simply stays 0 ("unknown") for anything else. if (!selfip && mac[0] == 0x02 && mac[1] == 0x00) selfip = ((uint32_t)mac[2] << 24) | ((uint32_t)mac[3] << 16) | ((uint32_t)mac[4] << 8) | (uint32_t)mac[5]; vwesteam::SetLog(SteamLog); std::string err; if (!vwesteam::Init(cfg, mac, selfip, err)) { LOG_MSG("STEAM: link did not come up: %s", err.c_str()); return false; } up = true; // Stale session file, or the machine is signed into a different Steam // account than the lobby handed the roster to: every peer's allowlist will // refuse us. Say it here, not at mission start when nobody appears. if (sessionid && vwesteam::SelfSteamID() && sessionid != vwesteam::SelfSteamID()) LOG_MSG("STEAM: session file was written for SteamID %s but this machine is signed in as %s " "-- peers will refuse us", std::to_string(sessionid).c_str(), std::to_string(vwesteam::SelfSteamID()).c_str()); LOG_MSG("STEAM: link up -- self %s mac %02x:%02x:%02x:%02x:%02x:%02x ip %u.%u.%u.%u, " "%u peer(s), channel %d", std::to_string(vwesteam::SelfSteamID()).c_str(), mac[0],mac[1],mac[2],mac[3],mac[4],mac[5], (selfip >> 24) & 0xff, (selfip >> 16) & 0xff, (selfip >> 8) & 0xff, selfip & 0xff, (unsigned)cfg.peers.size(), cfg.channel); return true; } void SteamEthernetConnection::SendPacket(const uint8_t* packet, int len) { static unsigned long txn = 0; ++txn; /* split off: pcap's one-liner form of this is a sequence-point warning */ if ((txn & (txn - 1)) == 0 && len >= 14) /* powers of 2, avoid spam */ LOG_MSG("STEAM TX #%lu len=%d dst=%02x:%02x:%02x src=%02x:%02x:%02x", txn, len, packet[0],packet[1],packet[2], packet[6],packet[7],packet[8]); vwesteam::SendFrame(packet, len); } void SteamEthernetConnection::GetPackets(std::function callback) { // The one and only pump site. NE2000_Poller is a TIMER tick handler, so // this runs once per emulated millisecond -- which is why the Steam link // needs no thread and no timer of its own anywhere. vwesteam::Pump(); static unsigned long rxn = 0; vwesteam::PollFrames([&](const uint8_t* packet, int len) { // Acceptance mirrors the pcap backend's BPF clause for clause so the // guest cannot tell the two backends apart. if (len < 14 || len > vwesteam::VWE_MAX_FRAME) return; const bool bcast = (packet[0] & packet[1] & packet[2] & packet[3] & packet[4] & packet[5]) == 0xff; if (!bcast) { if (packet[0] & 1) return; /* multicast: the BPF never matched it */ if (memcmp(packet, mac, 6) != 0) return; /* ether dst */ } // "and not ether src ", the BPF's third clause. steamlink.h // calls this structurally impossible because a peer never echoes; // the GATEWAY can, though. Its capture filter is bare // "ether broadcast" until the first HELLO lands (steam-gw // BuildFilter()), with no "not ether src" clause to build yet -- so // in that window it captures its own npcap injection of OUR // broadcast and tunnels it straight back to us. A real NIC never // hears its own transmit, and a boot-ARP that returns to its sender // is duplicate-address bait, so drop it here for one memcmp. if (memcmp(packet + 6, mac, 6) == 0) return; ++rxn; if ((rxn & (rxn - 1)) == 0) LOG_MSG("STEAM RX #%lu len=%d dst=%02x:%02x:%02x src=%02x:%02x:%02x", rxn, len, packet[0],packet[1],packet[2], packet[6],packet[7],packet[8]); callback(packet, len); }); } #endif