Skip to content

Commit f36af85

Browse files
committed
feat(process): add listening pid resolver
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent b8e9152 commit f36af85

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Libresign\Service\Process;
10+
11+
use OCA\Libresign\Vendor\Symfony\Component\Process\Process;
12+
13+
class ListeningPidResolver {
14+
/**
15+
* @return int[]
16+
*/
17+
public function findListeningPids(int $port): array {
18+
if ($port <= 0) {
19+
throw new \RuntimeException('Invalid port to inspect process listeners.');
20+
}
21+
22+
$usedStrategy = false;
23+
$pids = [];
24+
25+
$ss = $this->findListeningPidsUsingSs($port);
26+
if ($ss !== null) {
27+
$usedStrategy = true;
28+
$pids = array_merge($pids, $ss);
29+
}
30+
31+
$lsof = $this->findListeningPidsUsingLsof($port);
32+
if ($lsof !== null) {
33+
$usedStrategy = true;
34+
$pids = array_merge($pids, $lsof);
35+
}
36+
37+
$proc = $this->findListeningPidsUsingProc($port);
38+
if ($proc !== null) {
39+
$usedStrategy = true;
40+
$pids = array_merge($pids, $proc);
41+
}
42+
43+
if (!$usedStrategy) {
44+
throw new \RuntimeException('Unable to inspect listening process PIDs: no strategy available.');
45+
}
46+
47+
return array_values(array_filter(
48+
array_unique(array_map('intval', $pids)),
49+
static fn (int $pid): bool => $pid > 0,
50+
));
51+
}
52+
53+
/**
54+
* @return int[]|null null means strategy unavailable
55+
*/
56+
protected function findListeningPidsUsingSs(int $port): ?array {
57+
if (!$this->commandIsAvailable('ss')) {
58+
return null;
59+
}
60+
61+
$process = $this->createProcess(['ss', '-ltnp', 'sport = :' . $port]);
62+
$process->run();
63+
if (!$process->isSuccessful()) {
64+
return [];
65+
}
66+
67+
$output = $process->getOutput();
68+
preg_match_all('/pid=(\d+)/', $output, $matches);
69+
if (!isset($matches[1]) || !is_array($matches[1])) {
70+
return [];
71+
}
72+
73+
return array_map('intval', $matches[1]);
74+
}
75+
76+
/**
77+
* @return int[]|null null means strategy unavailable
78+
*/
79+
protected function findListeningPidsUsingLsof(int $port): ?array {
80+
if (!$this->commandIsAvailable('lsof')) {
81+
return null;
82+
}
83+
84+
$process = $this->createProcess(['lsof', '-ti', 'tcp:' . $port, '-sTCP:LISTEN']);
85+
$process->run();
86+
if (!$process->isSuccessful()) {
87+
return [];
88+
}
89+
90+
$lines = preg_split('/\R/', trim($process->getOutput()));
91+
if (!is_array($lines)) {
92+
return [];
93+
}
94+
95+
return array_map('intval', array_filter($lines, static fn (string $line): bool => $line !== ''));
96+
}
97+
98+
/**
99+
* @return int[]|null null means strategy unavailable
100+
*/
101+
protected function findListeningPidsUsingProc(int $port): ?array {
102+
if (PHP_OS_FAMILY !== 'Linux') {
103+
return null;
104+
}
105+
106+
if (!is_readable('/proc/net/tcp') && !is_readable('/proc/net/tcp6')) {
107+
return null;
108+
}
109+
110+
$inodesByPort = $this->getListeningSocketInodesByPort($port);
111+
if (empty($inodesByPort)) {
112+
return [];
113+
}
114+
115+
$fdPaths = glob('/proc/[0-9]*/fd/[0-9]*');
116+
if (!is_array($fdPaths)) {
117+
return [];
118+
}
119+
120+
$pids = [];
121+
foreach ($fdPaths as $fdPath) {
122+
$target = @readlink($fdPath);
123+
if (!is_string($target) || !preg_match('/^socket:\\[(\\d+)\\]$/', $target, $matches)) {
124+
continue;
125+
}
126+
127+
$inode = $matches[1] ?? '';
128+
if ($inode === '' || !isset($inodesByPort[$inode])) {
129+
continue;
130+
}
131+
132+
if (preg_match('#^/proc/([0-9]+)/fd/[0-9]+$#', $fdPath, $pidMatches)) {
133+
$pids[] = (int)$pidMatches[1];
134+
}
135+
}
136+
137+
return $pids;
138+
}
139+
140+
/**
141+
* @return array<string, true>
142+
*/
143+
protected function getListeningSocketInodesByPort(int $port): array {
144+
$portHex = strtoupper(str_pad(dechex($port), 4, '0', STR_PAD_LEFT));
145+
$inodes = [];
146+
147+
foreach (['/proc/net/tcp', '/proc/net/tcp6'] as $path) {
148+
foreach ($this->readListeningInodesFromProcTable($path, $portHex) as $inode) {
149+
$inodes[$inode] = true;
150+
}
151+
}
152+
153+
return $inodes;
154+
}
155+
156+
/**
157+
* @return string[]
158+
*/
159+
protected function readListeningInodesFromProcTable(string $path, string $portHex): array {
160+
if (!is_readable($path)) {
161+
return [];
162+
}
163+
164+
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
165+
if (!is_array($lines) || count($lines) <= 1) {
166+
return [];
167+
}
168+
169+
$inodes = [];
170+
foreach (array_slice($lines, 1) as $line) {
171+
$columns = preg_split('/\s+/', trim($line));
172+
if (!is_array($columns) || count($columns) < 10) {
173+
continue;
174+
}
175+
176+
$localAddress = $columns[1];
177+
$state = $columns[3];
178+
$inode = $columns[9] ?? '';
179+
180+
if ($state !== '0A' || !is_string($inode) || $inode === '') {
181+
continue;
182+
}
183+
184+
$addressParts = explode(':', $localAddress);
185+
if (count($addressParts) !== 2) {
186+
continue;
187+
}
188+
189+
if (strtoupper($addressParts[1]) !== $portHex) {
190+
continue;
191+
}
192+
193+
$inodes[] = $inode;
194+
}
195+
196+
return $inodes;
197+
}
198+
199+
protected function commandIsAvailable(string $command): bool {
200+
$process = $this->createProcess(['/bin/sh', '-lc', 'command -v ' . escapeshellarg($command) . ' >/dev/null 2>&1']);
201+
$process->run();
202+
return $process->isSuccessful();
203+
}
204+
205+
/**
206+
* @param string[] $command
207+
*/
208+
protected function createProcess(array $command): Process {
209+
return new Process($command);
210+
}
211+
}

0 commit comments

Comments
 (0)