Skip to content

Commit 6c5fde9

Browse files
fix: dedupe host-network service ports across collectors (#86)
1 parent f828822 commit 6c5fde9

File tree

10 files changed

+483
-31
lines changed

10 files changed

+483
-31
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to portracker will be documented in this file.
44

5+
## [1.3.4] - 2026-02-05
6+
7+
### Fixed
8+
9+
- **Host-Network Service Deduplication**: Added logical deduplication for services using host networking so the same listener is not shown multiple times when discovered through both Docker and process-based collection paths (addresses #82)
10+
- **Service Port Rendering**: Updated service view port keys to use full port identity, avoiding repeated port chips when entries share the same host port number
11+
512
## [1.3.3] - 2026-02-05
613

714
### Fixed

backend/collectors/base_collector.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,24 @@ class BaseCollector {
143143
if (hostIp === "::" || hostIp === "[::]" || hostIp === "*") {
144144
hostIp = "0.0.0.0";
145145
}
146+
const parsedPid = parseInt(entry.pid, 10);
147+
const pid = Number.isNaN(parsedPid) ? null : parsedPid;
148+
const pids = Array.isArray(entry.pids)
149+
? entry.pids
150+
.map((candidatePid) => parseInt(candidatePid, 10))
151+
.filter((candidatePid) => !Number.isNaN(candidatePid) && candidatePid > 0)
152+
: pid
153+
? [pid]
154+
: [];
155+
const primaryPid = pid || pids[0] || null;
146156
return {
147157
source: entry.source || this.platform,
148158
owner: entry.owner || "unknown",
149159
protocol: entry.protocol || "tcp",
150160
host_ip: hostIp,
151161
host_port: parseInt(entry.host_port, 10) || 0,
162+
pid: primaryPid,
163+
pids,
152164
target: entry.target || null,
153165
container_id: entry.container_id || null,
154166
vm_id: entry.vm_id || null,

backend/collectors/docker_collector.js

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,119 @@ class DockerCollector extends BaseCollector {
3636
return ip;
3737
}
3838

39+
_getDockerLogicalKey(entry) {
40+
if (!entry || entry.source !== "docker" || !entry.container_id || !entry.host_port) {
41+
return null;
42+
}
43+
const hostPort = parseInt(entry.host_port, 10);
44+
if (Number.isNaN(hostPort) || hostPort <= 0) {
45+
return null;
46+
}
47+
const containerId = String(entry.container_id).substring(0, 12);
48+
const protocol = entry.protocol || "tcp";
49+
return `${containerId}:${hostPort}:${protocol}`;
50+
}
51+
52+
_selectPreferredDockerPortEntry(existingEntry, nextEntry) {
53+
if (!existingEntry) return nextEntry;
54+
if (!nextEntry) return existingEntry;
55+
const score = (entry) => {
56+
let value = 0;
57+
const normalizedIp = this._normalizeHostIp(entry.host_ip);
58+
if (normalizedIp === "0.0.0.0") value += 3;
59+
if (normalizedIp === "127.0.0.1") value += 2;
60+
if (!entry.internal) value += 1;
61+
return value;
62+
};
63+
return score(nextEntry) > score(existingEntry) ? nextEntry : existingEntry;
64+
}
65+
66+
_collapseDockerLogicalDuplicates(entries) {
67+
const passthroughEntries = [];
68+
const publishedDockerEntries = new Map();
69+
const internalDockerEntries = new Map();
70+
71+
entries.forEach((rawEntry) => {
72+
const entry = this.normalizePortEntry(rawEntry);
73+
const logicalKey = this._getDockerLogicalKey(entry);
74+
if (!logicalKey) {
75+
passthroughEntries.push(entry);
76+
return;
77+
}
78+
if (entry.internal) {
79+
internalDockerEntries.set(
80+
logicalKey,
81+
this._selectPreferredDockerPortEntry(internalDockerEntries.get(logicalKey), entry)
82+
);
83+
return;
84+
}
85+
publishedDockerEntries.set(
86+
logicalKey,
87+
this._selectPreferredDockerPortEntry(publishedDockerEntries.get(logicalKey), entry)
88+
);
89+
});
90+
91+
const collapsedEntries = [...passthroughEntries];
92+
for (const entry of publishedDockerEntries.values()) {
93+
collapsedEntries.push(entry);
94+
}
95+
for (const [logicalKey, entry] of internalDockerEntries.entries()) {
96+
if (!publishedDockerEntries.has(logicalKey)) {
97+
collapsedEntries.push(entry);
98+
}
99+
}
100+
return collapsedEntries;
101+
}
102+
103+
_getProcessLogicalKey(entry) {
104+
if (!entry || !entry.host_port) {
105+
return null;
106+
}
107+
const hostPort = parseInt(entry.host_port, 10);
108+
if (Number.isNaN(hostPort) || hostPort <= 0) {
109+
return null;
110+
}
111+
const protocol = entry.protocol || "tcp";
112+
const owner = String(entry.owner || "").trim().toLowerCase();
113+
if (!owner || owner === "unknown") {
114+
const pid =
115+
entry.pid ||
116+
(Array.isArray(entry.pids) && entry.pids.length > 0 ? entry.pids[0] : null);
117+
if (pid) {
118+
return `pid:${pid}:${hostPort}:${protocol}`;
119+
}
120+
if (entry.source === "system" || (entry.source === "docker" && !entry.container_id)) {
121+
return `unknown:${entry.source || "system"}:${hostPort}:${protocol}`;
122+
}
123+
return null;
124+
}
125+
return `owner:${owner}:${hostPort}:${protocol}`;
126+
}
127+
128+
_collapseProcessLogicalDuplicates(entries) {
129+
const passthroughEntries = [];
130+
const processEntries = new Map();
131+
132+
entries.forEach((rawEntry) => {
133+
const entry = this.normalizePortEntry(rawEntry);
134+
if (entry.source === "docker" && entry.container_id) {
135+
passthroughEntries.push(entry);
136+
return;
137+
}
138+
const logicalKey = this._getProcessLogicalKey(entry);
139+
if (!logicalKey) {
140+
passthroughEntries.push(entry);
141+
return;
142+
}
143+
processEntries.set(
144+
logicalKey,
145+
this._selectPreferredDockerPortEntry(processEntries.get(logicalKey), entry)
146+
);
147+
});
148+
149+
return [...passthroughEntries, ...processEntries.values()];
150+
}
151+
39152
async initialize() {
40153
return await this.dockerApi.connect();
41154
}
@@ -266,8 +379,11 @@ class DockerCollector extends BaseCollector {
266379
this.logWarn("Failed to collect and process system ports:", systemErr.message);
267380
}
268381

269-
this.logInfo(`Total unique ports collected: ${allPorts.length}`);
270-
return allPorts;
382+
const collapsedPorts = this._collapseDockerLogicalDuplicates(allPorts);
383+
const deduplicatedPorts =
384+
this._collapseProcessLogicalDuplicates(collapsedPorts);
385+
this.logInfo(`Total unique ports collected: ${deduplicatedPorts.length}`);
386+
return deduplicatedPorts;
271387
} catch (err) {
272388
this.logError("Critical error in getPorts:", err.message, err.stack);
273389
return [

backend/collectors/system_collector.js

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,66 @@ class SystemCollector extends BaseCollector {
2727
this.procParser = new ProcParser();
2828
}
2929

30+
_normalizeHostIp(hostIp) {
31+
if (!hostIp || hostIp === "*" || hostIp === "::" || hostIp === "[::]") {
32+
return "0.0.0.0";
33+
}
34+
return hostIp;
35+
}
36+
37+
_selectPreferredProcessPortEntry(existingEntry, nextEntry) {
38+
if (!existingEntry) return nextEntry;
39+
if (!nextEntry) return existingEntry;
40+
const score = (entry) => {
41+
let value = 0;
42+
const normalizedIp = this._normalizeHostIp(entry.host_ip);
43+
if (normalizedIp === "0.0.0.0") value += 3;
44+
if (normalizedIp === "127.0.0.1") value += 2;
45+
return value;
46+
};
47+
return score(nextEntry) > score(existingEntry) ? nextEntry : existingEntry;
48+
}
49+
50+
_getProcessLogicalKey(entry) {
51+
if (!entry || !entry.host_port) return null;
52+
const hostPort = parseInt(entry.host_port, 10);
53+
if (Number.isNaN(hostPort) || hostPort <= 0) return null;
54+
const protocol = entry.protocol || "tcp";
55+
const owner = String(entry.owner || "").trim().toLowerCase();
56+
if (!owner || owner === "unknown") {
57+
const pid =
58+
entry.pid ||
59+
(Array.isArray(entry.pids) && entry.pids.length > 0 ? entry.pids[0] : null);
60+
if (pid) return `pid:${pid}:${hostPort}:${protocol}`;
61+
return `unknown:${entry.source || "system"}:${hostPort}:${protocol}`;
62+
}
63+
return `owner:${owner}:${hostPort}:${protocol}`;
64+
}
65+
66+
_collapseProcessLogicalDuplicates(entries) {
67+
const passthroughEntries = [];
68+
const processEntries = new Map();
69+
70+
entries.forEach((rawEntry) => {
71+
const entry = this.normalizePortEntry(rawEntry);
72+
if (entry.container_id) {
73+
passthroughEntries.push(entry);
74+
return;
75+
}
76+
const logicalKey = this._getProcessLogicalKey(entry);
77+
if (!logicalKey) {
78+
passthroughEntries.push(entry);
79+
return;
80+
}
81+
processEntries.set(
82+
logicalKey,
83+
this._selectPreferredProcessPortEntry(processEntries.get(logicalKey), entry)
84+
);
85+
});
86+
87+
return [...passthroughEntries, ...processEntries.values()];
88+
}
89+
3090
/**
3191
* Get local system information
3292
* @returns {Promise<Object>} System information
@@ -153,6 +213,7 @@ class SystemCollector extends BaseCollector {
153213
return this.cacheGetOrSet('ports', async () => {
154214
try {
155215
this.log("Collecting system ports");
216+
let collectedPorts = null;
156217

157218
if (!this.isWindows && this.procParser) {
158219
try {
@@ -168,7 +229,7 @@ class SystemCollector extends BaseCollector {
168229

169230
if (allPorts.length >= 3) {
170231
this.log(`Successfully collected ${allPorts.length} ports via /proc (TCP: ${tcpPorts.length}, UDP: ${udpPorts.length})`);
171-
return allPorts.map(port => this.normalizePortEntry({
232+
collectedPorts = allPorts.map((port) => this.normalizePortEntry({
172233
source: "system",
173234
owner: port.owner,
174235
protocol: port.protocol,
@@ -191,11 +252,14 @@ class SystemCollector extends BaseCollector {
191252
}
192253
}
193254

194-
if (this.isWindows) {
195-
return await this.getWindowsPorts();
196-
} else {
197-
return await this.getLinuxPorts();
255+
if (!collectedPorts) {
256+
if (this.isWindows) {
257+
collectedPorts = await this.getWindowsPorts();
258+
} else {
259+
collectedPorts = await this.getLinuxPorts();
260+
}
198261
}
262+
return this._collapseProcessLogicalDuplicates(collectedPorts);
199263
} catch (err) {
200264
this.logError("Error collecting system ports:", err.message, err.stack);
201265
return [

0 commit comments

Comments
 (0)