Skip to content

Commit 150da4d

Browse files
committed
Add flag to prevent unlinking of socket file
This is something I tried to properly fix for ages, but I haven't found a very good solution. The commit is not what I'd call a good solution or even a real solution at all, but it should work around problematic cases. So here is (simplified) how ip2unix currently tracks socket file descriptors: * We have an internal registry, which maps the file descriptors of the current process to our internal Socket state. * Whenever we get a new socket() call, we insert the file descriptor into the registry. Note that at this point, we can't make a final decision about how to handle this socket, because we only know the socket family (eg. AF_INET or AF_INET6) but no details about ports or IP addresses. * Once we get a bind() or connect() call, we look up the file descriptor in the registry and see whether we have a matching rule. * If the rule matches, we go ahead and execute the corresponding action (eg. convert to Unix socket, blackhole, reject, etc...), if it doesn't we just remove the file descriptor from the registry and call the real bind() or connect() function. * Once the socket is closed, we remove the file descriptor from the registry and (where applicable) unlink the socket file. This all works fine if everything is run in order and without any multiprocessing involved, but as soon as the application invokes clone(), things start to get ugly. Essentially we have two clone() flags that are problematic: CLONE_VM: If set, the calling process and the child process run in the same memory space. In particular, memory writes performed by the calling process or by the child process are also visible in the other process. If not set, the child process runs in a separate copy of the memory space of the calling process at the time of the clone call. Memory writes or file mappings/unmappings performed by one of the processes do not affect the other. CLONE_FILES: If set, the calling process and the child process share the same file descriptor table. Any file descriptor created by the calling process or by the child process is also valid in the other process. If not set, the child process inherits a copy of all file descriptors opened in the calling process at the time of the clone call. Subsequent operations that open or close file descriptors, or change file descriptor flags, performed by either the calling process or the child process do not affect the other process. Here is how these flags are affecting us: Both CLONE_VM and CLONE_FILES set: Processes share memory and also share file descriptors, which essentially means that we don't need to do anything, because the registry and the file descriptor table is all that we need for properly tracking the state. Only CLONE_VM set: This is a little more tricky since we have one shared registry across both processes, but the file descriptor tables are copied. This means that whenever we're closing a socket in the second process, we can not yet unlink the socket file until the file descriptor is also closed in the first process. We could basically just implement a garbage collector when the used socket file descriptors reach zero. Only CLONE_FILES set: Here we have two registries, but one shared file descriptor table. In this scenario, we could use a dedicated file descriptor that we can use for sharing state between two registries. None of those flags set: Again, two registries, but also a copy of the file descriptor table. This is our nightmare scenario, consider this chain of events: * Process 1 creates a socket * Process 1 clone()s into process 2 * Process 2 closes the socket and ip2unix unlinks the file * ... wait, what!? Process 1 still has to be able to operate on the socked but we essentially made the socket a blackhole since it's no longer reachable via the filesystem. There is also no way to handle this properly without also introducing some side channel that tracks the state from outside. The non-solution I went with is basically adding a new flag called "noremove", which prevents the socket path from being unlinked when the socket is closed. This doesn't fully solve the issue because ip2unix still removes the socket file descriptor from the registry and also has no way to even know if it is a socket it should track. However, this allows us to deal with the issue until we have found a real solution and the test case I added is essentially replicating most programs I found in the wild. Signed-off-by: aszlig <aszlig@nix.build> Closes: #16
1 parent ac6a3d9 commit 150da4d

File tree

11 files changed

+146
-21
lines changed

11 files changed

+146
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog], and this project adheres to
1717
- Support for Linux abstract sockets.
1818
- Support for matching an existing Unix domain socket or abstract socket.
1919
- Add `stream`/`datagram` aliases for `tcp`/`udp` socket types.
20+
- Add flag to prevent unlinking of socket files when closing sockets.
2021

2122
### Changed
2223
- Rule files (`-f`) are now just a list of newline-separated rule (`-r`)

README.adoc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,15 @@ Placeholders are allowed here and are substituted accordingly:
282282
datagram socket)
283283
*%%*;; verbatim `%`
284284

285+
*noremove*::
286+
If this flag is given in conjunction with a <<rule-socket-path,*path*>>, the
287+
socket file is not removed when the socket is closed.
288+
+
289+
This works around an issue with more complex programs that spawn subprocesses
290+
or threads without sharing memory or cloning the file descriptor table. In some
291+
scenarios *ip2unix* might be unable to correctly track sockets and might
292+
accidentally remove the socket file too early.
293+
285294
ifndef::without-abstract[]
286295
*abstract*='NAME'::
287296
An abstract namespace Unix domain socket not being created in the file system

src/rules/parse.cc

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ static std::optional<Rule> parse_rule(const std::string &file, int pos,
221221
{
222222
Rule rule;
223223

224+
bool noremove = false;
225+
224226
for (const auto &foo : doc) {
225227
std::string key = foo.first.as<std::string>();
226228
YAML::Node value = foo.second;
@@ -293,6 +295,8 @@ static std::optional<Rule> parse_rule(const std::string &file, int pos,
293295
RULE_CONVERT(rule.action.blackhole, "blackhole", bool, "bool");
294296
} else if (key == "ignore") {
295297
RULE_CONVERT(rule.action.ignore, "ignore", bool, "bool");
298+
} else if (key == "noRemove") {
299+
RULE_CONVERT(noremove, "noRemove", bool, "bool");
296300
} else if (key == "socketPath") {
297301
if (rule.action.socket_path) {
298302
RULE_ERROR("Socket path or abstract name already specified.");
@@ -325,6 +329,19 @@ static std::optional<Rule> parse_rule(const std::string &file, int pos,
325329
}
326330
}
327331

332+
if (noremove) {
333+
if (
334+
rule.action.socket_path &&
335+
rule.action.socket_path->is_real_file()
336+
) {
337+
rule.action.socket_path->unlink = false;
338+
} else {
339+
RULE_ERROR("The noremove flag can only be used for Unix socket"
340+
" paths.");
341+
return std::nullopt;
342+
}
343+
}
344+
328345
std::optional<std::string> errmsg = validate_rule(rule);
329346
if (errmsg) {
330347
RULE_ERROR(errmsg.value());
@@ -414,6 +431,7 @@ std::optional<Rule> parse_rule_arg(size_t rulepos, const std::string &arg)
414431

415432
size_t errpos = 0, valpos = 0;
416433
size_t errlen = 0;
434+
bool noremove = false;
417435

418436
for (size_t i = 0, arglen = arg.length(); i <= arglen; ++i) {
419437
if (key) {
@@ -532,6 +550,8 @@ std::optional<Rule> parse_rule_arg(size_t rulepos, const std::string &arg)
532550
rule.action.blackhole = true;
533551
} else if (buf == "ignore") {
534552
rule.action.ignore = true;
553+
} else if (buf == "noremove") {
554+
noremove = true;
535555
} else {
536556
print_arg_error(rulepos, arg, errpos, errlen, "unknown flag");
537557
return std::nullopt;
@@ -552,6 +572,21 @@ std::optional<Rule> parse_rule_arg(size_t rulepos, const std::string &arg)
552572
buf += arg[i];
553573
}
554574

575+
if (noremove) {
576+
if (
577+
rule.action.socket_path &&
578+
rule.action.socket_path->is_real_file()
579+
) {
580+
rule.action.socket_path->unlink = false;
581+
} else {
582+
print_arg_error(
583+
rulepos, arg, 0, 0,
584+
"The noremove flag can only be used for Unix socket paths."
585+
);
586+
return std::nullopt;
587+
}
588+
}
589+
555590
std::optional<std::string> errmsg = validate_rule(rule);
556591
if (errmsg) {
557592
print_arg_error(rulepos, arg, 0, 0, errmsg.value());
@@ -662,9 +697,13 @@ void print_rules(std::vector<Rule> &rules, std::ostream &out)
662697
<< std::endl;
663698
} else {
664699
#endif
665-
out << " Socket path: "
666-
<< rule.action.socket_path->value
667-
<< std::endl;
700+
out << " Socket path: " << rule.action.socket_path->value;
701+
if (rule.action.socket_path->unlink) {
702+
out << " (will be removed on close)";
703+
} else {
704+
out << " (will not be removed on close)";
705+
}
706+
out << std::endl;
668707
#if defined(__linux__)
669708
}
670709
#endif

src/serial.cc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ void serialise(const SocketPath &path, std::ostream &out)
170170
}
171171

172172
serialise(path.value, out);
173+
serialise(path.unlink, out);
173174
}
174175

175176
MaybeError deserialise(std::istream &in, SocketPath *out)
@@ -195,7 +196,9 @@ MaybeError deserialise(std::istream &in, SocketPath *out)
195196
+ c + "' used as socket path type.";
196197
}
197198

198-
return deserialise(in, &out->value);
199+
MaybeError err;
200+
if ((err = deserialise(in, &out->value))) return err;
201+
return deserialise(in, &out->unlink);
199202
}
200203

201204
void serialise(const Rule &rule, std::ostream &out)

src/socket.cc

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ SocketPath Socket::format_sockpath(const SocketPath &path,
203203
out += path.value[i];
204204
}
205205

206-
return SocketPath(path.type, out);
206+
return SocketPath(path.type, out, path.unlink);
207207
}
208208

209209
/*
@@ -371,8 +371,12 @@ int Socket::bind(const SockAddr &addr, const SocketPath &path)
371371
ret = real::bind(this->fd, dest.cast(), dest.size());
372372
if (ret == 0) {
373373
Socket::sockpath_registry.insert(newpath);
374-
if (newpath.is_real_file())
374+
if (newpath.is_real_file() && newpath.unlink) {
375+
LOG(DEBUG) << "Marking socket file '" << newpath.value
376+
<< "' for deletion when closing fd "
377+
<< this->fd << '.';
375378
this->unlink_sockpath = newpath.value;
379+
}
376380
}
377381
}
378382

@@ -680,13 +684,12 @@ int Socket::close(void)
680684
}
681685
}
682686

683-
Socket::registry.erase(this->fd);
684-
LOG(INFO) << "Socket fd " << this->fd << " unregistered.";
687+
this->unregister();
685688
return ret;
686689
}
687690

688691
void Socket::unregister(void)
689692
{
690-
LOG(DEBUG) << "Unregistering socket fd " << this->fd << '.';
691693
Socket::registry.erase(this->fd);
694+
LOG(INFO) << "Socket fd " << this->fd << " unregistered.";
692695
}

src/socketpath.hh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@
77
struct SocketPath {
88
enum class Type { ABSTRACT, FILESYSTEM };
99

10-
inline SocketPath() : type(Type::FILESYSTEM), value() {}
11-
inline SocketPath(Type t, const std::string &v) : type(t), value(v) {}
10+
inline SocketPath()
11+
: type(Type::FILESYSTEM), value(), unlink(true) {}
12+
inline SocketPath(Type t, const std::string &v)
13+
: type(t), value(v), unlink(true) {}
14+
inline SocketPath(Type t, const std::string &v, bool ul)
15+
: type(t), value(v), unlink(ul) {}
1216

1317
inline bool operator==(const SocketPath &other) const {
1418
return this->type == other.type && this->value == other.value;
@@ -24,6 +28,7 @@ struct SocketPath {
2428

2529
Type type;
2630
std::string value;
31+
bool unlink;
2732
};
2833

2934
namespace std {

tests/test_preliminary_unlink.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import sys
2+
import subprocess
3+
import socket
4+
5+
from helper import IP2UNIX
6+
7+
TESTPROG = r'''
8+
import os
9+
import sys
10+
import socket
11+
12+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
13+
sock.bind(('1.2.3.4', 1234))
14+
sock.listen(10)
15+
16+
childpid = os.fork()
17+
if childpid == 0:
18+
os.closerange(3, 65536)
19+
raise SystemExit
20+
21+
assert os.WEXITSTATUS(os.waitpid(childpid, 0)[1]) == 0
22+
23+
sys.stdout.write('ready\n')
24+
sys.stdout.flush()
25+
26+
with sock.accept()[0] as conn:
27+
assert conn.recv(3) == b'foo'
28+
'''
29+
30+
31+
def test_preliminary_unlink(tmpdir):
32+
"""
33+
Regression test for https://github.com/nixcloud/ip2unix/issues/16
34+
"""
35+
sockfile = str(tmpdir.join('test.sock'))
36+
37+
cmd = [
38+
IP2UNIX, '-r', 'port=1234,addr=1.2.3.4,noremove,path=' + sockfile,
39+
sys.executable, '-c', TESTPROG,
40+
]
41+
42+
with subprocess.Popen(cmd, stdout=subprocess.PIPE) as client:
43+
assert client.stdout.readline() == b'ready\n'
44+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
45+
sock.connect(sockfile)
46+
sock.sendall(b'foo')
47+
assert client.wait() == 0

tests/test_program_args.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ def test_rule_longopts(tmpdir):
4545
stdout = subprocess.check_output(deprecated_cmd,
4646
stderr=subprocess.STDOUT)
4747
assert b"is deprecated" in stdout
48-
assert b"path: /test\n" in stdout
48+
assert b"path: /test (will" in stdout
4949

5050
stdout = subprocess.check_output([IP2UNIX, '-cp', '--rule', 'path=/test'])
51-
assert b"path: /test\n" in stdout
51+
assert b"path: /test (will" in stdout
5252

5353

5454
def test_no_program():

tests/test_rule_args.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_path_subst(self):
4242
}
4343
for val, expect in fixtures.items():
4444
stdout, stderr = self.check_rules("path=" + val)
45-
self.assertIn("Socket path: " + expect + "\n", stdout)
45+
self.assertIn("Socket path: " + expect + " (will", stdout)
4646

4747
def test_good(self):
4848
fixtures = {
@@ -54,9 +54,9 @@ def test_good(self):
5454
"path=/eee\\\\,out": "Direction: outgoing\n",
5555
"path=/fff\\\\,tcp,udp": "IP Type: UDP\n",
5656
"path=/ggg\\\\,in,out": "Direction: outgoing\n",
57-
"path=/hhh\\\\": "Socket path: /hhh\\\n",
57+
"path=/hhh\\\\": "Socket path: /hhh\\ (will",
5858
"path=/iii\\,,out": "IP Type: TCP and UDP\n",
59-
"path=/jjj\\,,in": "Socket path: /jjj,\n",
59+
"path=/jjj\\,,in": "Socket path: /jjj, (will",
6060
"path=/kkk\\\\,stream": "IP Type: TCP\n",
6161
"path=/lll\\\\,datagram": "IP Type: UDP\n",
6262
"path=/mmm\\\\,dgram": "IP Type: UDP\n",
@@ -65,8 +65,9 @@ def test_good(self):
6565
"reject=999999": "calls with errno <unknown>.\n",
6666
"in,blackhole": "Blackhole the socket.\n",
6767
"in,ignore": "Don't handle this socket.\n",
68-
"path=foo": "Socket path: " + os.getcwd() + "/foo\n",
68+
"path=foo": "Socket path: " + os.getcwd() + "/foo (will",
6969
"from-unix=xyz,path=/foo": "domain socket path matching: xyz\n",
70+
"path=bar,noremove": "will not be removed on close",
7071
}
7172
for val, expect in fixtures.items():
7273
stdout, stderr = self.check_rules(val)

tests/test_rule_file.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@ def test_ignore_with_reject(self):
193193
def test_ignore_with_blackhole(self):
194194
self.assert_bad_rules([{'blackhole': True, 'ignore': True}])
195195

196+
def test_noremove_without_sockpath(self):
197+
self.assert_bad_rules([{'reject': True, 'noRemove': True}])
198+
self.assert_bad_rules([{'blackhole': True, 'noRemove': True}])
199+
self.assert_bad_rules([{'ignore': True, 'noRemove': True}])
200+
201+
@systemd_only
202+
def test_noremove_with_systemd(self):
203+
self.assert_bad_rules([{'socketActivation': True, 'noRemove': True}])
204+
205+
@abstract_sockets_only
206+
def test_noremove_with_abstract(self):
207+
self.assert_bad_rules([{'abstract': 'foo', 'noRemove': True}])
208+
209+
def test_noremove_with_sockpath(self):
210+
self.assert_good_rules([{'socketPath': '/foo', 'noRemove': True}])
211+
196212
@systemd_only
197213
def test_ignore_with_systemd(self):
198214
self.assert_bad_rules([{'socketActivation': True, 'ignore': True}])
@@ -274,17 +290,17 @@ def test_new_style_rule_files(self):
274290
b' IP Type: TCP and UDP\n'
275291
b' Address: <any>\n'
276292
b' Port: 1234\n'
277-
b' Socket path: /foo\n'
293+
b' Socket path: /foo (will be removed on close)\n'
278294
b'Rule #2:\n'
279295
b' Direction: incoming\n'
280296
b' IP Type: TCP and UDP\n'
281297
b' Address: 9.8.7.6\n'
282298
b' Port: <any>\n'
283-
b' Socket path: /bar\n'
299+
b' Socket path: /bar (will be removed on close)\n'
284300
b'Rule #3:\n'
285301
b' Direction: outgoing\n'
286302
b' IP Type: TCP and UDP\n'
287303
b' Address: <any>\n'
288304
b' Port: 4321\n'
289-
b' Socket path: /foobar\n'
305+
b' Socket path: /foobar (will be removed on close)\n'
290306
)

0 commit comments

Comments
 (0)