Use user-writable default path for control socket (#3551)

The previous default /run/gunicorn.ctl requires root permissions.
Now uses $XDG_RUNTIME_DIR/gunicorn.ctl if available, otherwise
$HOME/.gunicorn/gunicorn.ctl. This works on Linux, FreeBSD, OpenBSD,
and macOS without requiring elevated privileges.

- Add _get_default_control_socket() helper in config.py
- Create parent directory automatically with 0o700 permissions
- Update gunicornc CLI to use the same default path
- Add unit tests for path selection and directory creation
This commit is contained in:
Benoit Chesneau 2026-03-23 20:08:03 +01:00 committed by GitHub
parent 3667a10478
commit 0ad47db800
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 131 additions and 10 deletions

View File

@ -54,7 +54,9 @@ A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``.
**Command line:** `--control-socket PATH`
**Default:** `'/run/gunicorn.ctl'`
**Default:**
$XDG_RUNTIME_DIR/gunicorn.ctl or $HOME/.gunicorn/gunicorn.ctl
Unix socket path for control interface.
@ -62,9 +64,9 @@ The control socket allows runtime management of Gunicorn via the
``gunicornc`` command-line tool. Commands include viewing worker
status, adjusting worker count, and graceful reload/shutdown.
By default, creates ``/run/gunicorn.ctl`` (requires write access to
``/run``). For user-level deployments, specify a different path such
as ``/tmp/gunicorn.ctl`` or ``~/.gunicorn.ctl``.
Default: ``$XDG_RUNTIME_DIR/gunicorn.ctl`` if XDG_RUNTIME_DIR is set,
otherwise ``$HOME/.gunicorn/gunicorn.ctl``. The parent directory is
created automatically if needed.
Use ``--no-control-socket`` to disable.

View File

@ -3128,13 +3128,32 @@ class DirtyWorkerExit(Setting):
# Control Socket Settings
def _get_default_control_socket():
"""Get default control socket path based on available directories.
Prefers XDG_RUNTIME_DIR if available (standard on Linux, sometimes BSD),
falls back to $HOME/.gunicorn/ directory.
"""
# Prefer XDG_RUNTIME_DIR if available
xdg_runtime = os.environ.get('XDG_RUNTIME_DIR')
if xdg_runtime and os.path.isdir(xdg_runtime):
return os.path.join(xdg_runtime, 'gunicorn.ctl')
# Fall back to $HOME/.gunicorn/
home = os.path.expanduser('~')
gunicorn_dir = os.path.join(home, '.gunicorn')
return os.path.join(gunicorn_dir, 'gunicorn.ctl')
class ControlSocket(Setting):
name = "control_socket"
section = "Control"
cli = ["--control-socket"]
meta = "PATH"
validator = validate_string
default = "/run/gunicorn.ctl"
default = _get_default_control_socket()
default_doc = "$XDG_RUNTIME_DIR/gunicorn.ctl or $HOME/.gunicorn/gunicorn.ctl"
desc = """\
Unix socket path for control interface.
@ -3142,9 +3161,9 @@ class ControlSocket(Setting):
``gunicornc`` command-line tool. Commands include viewing worker
status, adjusting worker count, and graceful reload/shutdown.
By default, creates ``/run/gunicorn.ctl`` (requires write access to
``/run``). For user-level deployments, specify a different path such
as ``/tmp/gunicorn.ctl`` or ``~/.gunicorn.ctl``.
Default: ``$XDG_RUNTIME_DIR/gunicorn.ctl`` if XDG_RUNTIME_DIR is set,
otherwise ``$HOME/.gunicorn/gunicorn.ctl``. The parent directory is
created automatically if needed.
Use ``--no-control-socket`` to disable.

View File

@ -13,6 +13,7 @@ import json
import os
import sys
from gunicorn.config import _get_default_control_socket
from gunicorn.ctl.client import ControlClient, ControlClientError, parse_command
@ -405,8 +406,8 @@ Examples:
parser.add_argument(
'-s', '--socket',
default='gunicorn.ctl',
help='Control socket path (default: gunicorn.ctl in current directory)'
default=_get_default_control_socket(),
help='Control socket path (default: auto-detected based on XDG_RUNTIME_DIR or ~/.gunicorn/)'
)
parser.add_argument(

View File

@ -187,6 +187,11 @@ class ControlSocketServer:
"""Main async server loop."""
self._loop = asyncio.get_running_loop()
# Create parent directory if needed (for ~/.gunicorn/)
socket_dir = os.path.dirname(self.socket_path)
if socket_dir and not os.path.exists(socket_dir):
os.makedirs(socket_dir, mode=0o700)
# Remove socket if it exists
if os.path.exists(self.socket_path):
os.unlink(self.socket_path)

View File

@ -361,3 +361,59 @@ class TestControlSocketServerPermissions:
assert mode == 0o660
finally:
server.stop()
class TestControlSocketServerDirectoryCreation:
"""Tests for automatic directory creation."""
def test_creates_parent_directory(self):
"""Test that server creates parent directory if it doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a path with a non-existent subdirectory
subdir = os.path.join(tmpdir, '.gunicorn')
socket_path = os.path.join(subdir, 'gunicorn.ctl')
assert not os.path.exists(subdir)
arbiter = MockArbiter()
server = ControlSocketServer(arbiter, socket_path)
server.start()
# Wait for socket to exist
for _ in range(50):
if os.path.exists(socket_path):
break
time.sleep(0.1)
try:
# Directory should have been created
assert os.path.isdir(subdir)
# Directory should have restricted permissions (0o700)
mode = os.stat(subdir).st_mode & 0o777
assert mode == 0o700
# Socket should exist
assert os.path.exists(socket_path)
finally:
server.stop()
def test_works_with_existing_directory(self):
"""Test that server works when parent directory already exists."""
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, 'gunicorn.ctl')
arbiter = MockArbiter()
server = ControlSocketServer(arbiter, socket_path)
server.start()
# Wait for socket to exist
for _ in range(50):
if os.path.exists(socket_path):
break
time.sleep(0.1)
try:
assert os.path.exists(socket_path)
finally:
server.stop()

View File

@ -5,10 +5,12 @@
import os
import re
import sys
import tempfile
import pytest
from gunicorn import config
from gunicorn.config import _get_default_control_socket
from gunicorn.app.base import Application
from gunicorn.app.wsgiapp import WSGIApplication
from gunicorn.errors import ConfigError
@ -551,3 +553,39 @@ def test_str():
assert False, 'missing expected setting lines? {}'.format(
OUTPUT_MATCH.keys()
)
# Tests for _get_default_control_socket
class TestGetDefaultControlSocket:
"""Tests for the _get_default_control_socket function."""
def test_uses_xdg_runtime_dir_when_set_and_exists(self, monkeypatch):
"""When XDG_RUNTIME_DIR is set and exists, use it."""
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setenv('XDG_RUNTIME_DIR', tmpdir)
result = _get_default_control_socket()
assert result == os.path.join(tmpdir, 'gunicorn.ctl')
def test_falls_back_when_xdg_runtime_dir_not_exists(self, monkeypatch):
"""When XDG_RUNTIME_DIR is set but doesn't exist, fall back to home."""
monkeypatch.setenv('XDG_RUNTIME_DIR', '/nonexistent/path/that/does/not/exist')
monkeypatch.setenv('HOME', '/home/testuser')
result = _get_default_control_socket()
assert result == '/home/testuser/.gunicorn/gunicorn.ctl'
def test_falls_back_when_xdg_runtime_dir_not_set(self, monkeypatch):
"""When XDG_RUNTIME_DIR is not set, use home directory."""
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
monkeypatch.setenv('HOME', '/home/testuser')
result = _get_default_control_socket()
assert result == '/home/testuser/.gunicorn/gunicorn.ctl'
def test_uses_home_directory_structure(self, monkeypatch):
"""Verify the path structure uses .gunicorn subdirectory."""
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setenv('HOME', tmpdir)
result = _get_default_control_socket()
assert result == os.path.join(tmpdir, '.gunicorn', 'gunicorn.ctl')
assert result.endswith('.gunicorn/gunicorn.ctl')