mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
The web app (wsgi.py), novncd and socketiod set SIGCHLD to SIG_IGN at
import. The kernel then reaps every child at once, so subprocess loses
the exit status and a failing command reports 0, in the server and in
anything else run in that process: the test suite had to reset the
signal in two tests.
It was added in 2023 against one defunct bash per gunicorn worker and
per novncd. That bash came from the settings template of the time,
which ran .dec_ldap_pwd.sh with Popen and never waited for it; the
template stopped doing that in 561fedf. A settings.py still holding
that line leaves at most one zombie per process, not a growing number.
- wsgi.py, novncd, socketiod: the global SIG_IGN is removed. subprocess
reaps its own children, websockify installs its own SIGCHLD reaper
for novncd's connection processes, and novncd's SSH tunnels wait for
their ssh process.
- socketiod: the pty child execs consolecallback instead of running it
and then returning into the server's code; disconnect reaps that
child (waitpid) instead of any child (wait).
- The settings template drops its unused subprocess import; the two
test workarounds are removed.
New tests load the web app and novncd in a fresh interpreter and check
that SIGCHLD is untouched and a failing command still returns 1; both
fail on the previous code. A patched novncd in the test container
served five connections and left no child behind.
130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
GSTFSD = Path(__file__).resolve().parent.parent / "conf" / "daemon" / "gstfsd"
|
|
|
|
# A stand-in for libguestfs: one partition whose files live in a JSON file.
|
|
FAKE_GUESTFS = '''
|
|
import json, os
|
|
STATE = os.environ["FAKE_GUESTFS_STATE"]
|
|
def _load():
|
|
with open(STATE) as f:
|
|
return json.load(f)
|
|
def _save(state):
|
|
with open(STATE, "w") as f:
|
|
json.dump(state, f)
|
|
class GuestFS:
|
|
def __init__(self, **kwargs): pass
|
|
def add_domain(self, name): pass
|
|
def launch(self): pass
|
|
def list_partitions(self): return ["/dev/sda1"]
|
|
def mount(self, part, mountpoint): pass
|
|
def umount(self, part): pass
|
|
def shutdown(self): pass
|
|
def close(self): pass
|
|
def is_file(self, path): return path in _load()["files"]
|
|
def is_dir(self, path): return path in _load()["dirs"]
|
|
def cat(self, path): return _load()["files"][path]
|
|
def write(self, path, content):
|
|
state = _load(); state["files"][path] = content; _save(state)
|
|
def mkdir(self, path):
|
|
state = _load(); state["dirs"].append(path); _save(state)
|
|
def chmod(self, mode, path): pass
|
|
'''
|
|
|
|
|
|
def free_port(family=socket.AF_INET, host="127.0.0.1"):
|
|
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
s.bind((host, 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
class GstfsdTestCase(unittest.TestCase):
|
|
"""gstfsd hardening."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
Path(self.tmp.name, "guestfs.py").write_text(FAKE_GUESTFS)
|
|
self.state = Path(self.tmp.name, "state.json")
|
|
self.state.write_text(json.dumps({
|
|
"files": {"/etc/shadow": "root:x:1::::::\n", "/root/.ssh/authorized_keys": "ssh-rsa OLD old@host\n"},
|
|
"dirs": ["/root/.ssh"],
|
|
}))
|
|
|
|
def _env(self, **extra):
|
|
return dict(
|
|
os.environ,
|
|
PYTHONPATH=self.tmp.name,
|
|
FAKE_GUESTFS_STATE=str(self.state),
|
|
**extra,
|
|
)
|
|
|
|
def _start(self, host, port, family=socket.AF_INET):
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(GSTFSD)],
|
|
env=self._env(GSTFSD_BIND_HOST=host, GSTFSD_PORT=str(port)),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
self.addCleanup(proc.wait)
|
|
self.addCleanup(proc.kill)
|
|
for _ in range(100):
|
|
if proc.poll() is not None:
|
|
self.fail("gstfsd exited: " + proc.stderr.read())
|
|
try:
|
|
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
s.connect((host, port))
|
|
return proc
|
|
except OSError:
|
|
time.sleep(0.05)
|
|
self.fail("gstfsd did not start listening")
|
|
|
|
def _request(self, host, port, data, family=socket.AF_INET):
|
|
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
s.settimeout(10)
|
|
s.connect((host, port))
|
|
s.sendall(json.dumps(data).encode())
|
|
return json.loads(s.recv(1024))
|
|
|
|
def test_refuses_non_loopback_bind_without_opt_in(self):
|
|
result = subprocess.run(
|
|
[sys.executable, str(GSTFSD)],
|
|
env=self._env(GSTFSD_BIND_HOST="0.0.0.0"),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("GSTFSD_ALLOW_REMOTE", result.stderr)
|
|
|
|
def test_public_key_is_appended_not_overwritten(self):
|
|
port = free_port()
|
|
self._start("127.0.0.1", port)
|
|
reply = self._request(
|
|
"127.0.0.1", port, {"action": "publickey", "vname": "vm", "key": "ssh-ed25519 NEW new@host"}
|
|
)
|
|
self.assertEqual(reply["return"], "success")
|
|
keys = json.loads(self.state.read_text())["files"]["/root/.ssh/authorized_keys"]
|
|
self.assertEqual(keys.splitlines(), ["ssh-rsa OLD old@host", "ssh-ed25519 NEW new@host"])
|
|
|
|
@unittest.skipUnless(socket.has_ipv6, "no IPv6")
|
|
def test_listens_on_ipv6_loopback(self):
|
|
try:
|
|
port = free_port(socket.AF_INET6, "::1")
|
|
except OSError:
|
|
self.skipTest("IPv6 loopback not available")
|
|
self._start("::1", port, socket.AF_INET6)
|
|
reply = self._request(
|
|
"::1", port, {"action": "publickey", "vname": "vm", "key": "ssh-ed25519 V6 v6@host"},
|
|
socket.AF_INET6,
|
|
)
|
|
self.assertEqual(reply["return"], "success")
|