Skip to content

Commit ac85427

Browse files
committed
Add keep-alive mode on target machine.
1 parent 7e0e9e9 commit ac85427

7 files changed

Lines changed: 262 additions & 5 deletions

File tree

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ __pycache__/
66
.ruff_cache/
77
.mypy_cache/
88
wolsocketproxy.conf
9+
wolsocketproxy-keepalive.conf
910
*.egg-info/
1011
.vscode/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ __pycache__/
66
.ruff_cache/
77
.mypy_cache/
88
wolsocketproxy.conf
9+
wolsocketproxy-keepalive.conf
910
*.egg-info/
1011
.vscode/

docker/docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,11 @@ services:
1111
# If you only use IPMI, then you do not need this
1212
network_mode: host
1313

14+
# Keep-alive daemon on target machine
15+
wolsocketproxy-keepalive:
16+
image: ghcr.io/notsyncing/wol-socket-proxy:main
17+
volumes:
18+
- /mnt/data/wolsocketproxy/config:/app/config:Z
19+
restart: always
20+
# Required to override original command
21+
command: ["python", "-m", "wolsocketproxy", "-k", "-c", "/app/config/wolsocketproxy.conf"]

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies = [
2323
"dataclass-wizard==0.39.1",
2424
"icmplib==3.0.4",
2525
"redfish==3.3.5",
26+
"setproctitle==1.3.7",
2627
"wakeonlan==3.1.0",
2728
]
2829

@@ -52,6 +53,7 @@ src = ["src"]
5253
target-version = "py312"
5354
lint.select = ["ALL"]
5455
lint.ignore = [
56+
"ASYNC110",
5557
"COM812",
5658
"D100",
5759
"D101",
@@ -62,6 +64,7 @@ lint.ignore = [
6264
"D203",
6365
"D211",
6466
"D213",
67+
"DTZ005",
6568
"S101",
6669
"EM102",
6770
"FBT001",

src/wolsocketproxy/__init__.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,40 @@
22
import logging
33
from argparse import ArgumentParser
44
from pathlib import Path
5+
from sys import stdout
56

67
import dataclass_wizard
78

9+
from wolsocketproxy.keepalive import KeepAliveConfig, KeepAliveDaemon
810
from wolsocketproxy.proxy import Proxy, ProxyConfig
911

1012

1113
def main() -> None:
12-
logging.basicConfig(level=logging.INFO)
14+
logging.basicConfig(
15+
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
16+
level=logging.INFO,
17+
datefmt="%Y-%m-%d %H:%M:%S",
18+
handlers=[
19+
logging.StreamHandler(
20+
stream=stdout,
21+
),
22+
],
23+
force=True,
24+
)
25+
1326
log = logging.getLogger()
1427

1528
parser = ArgumentParser(prog="wolsocketproxy", description="A socket proxy with wake-on-lan feature.")
1629

30+
parser.add_argument(
31+
"-k",
32+
"--keep-alive",
33+
dest="keep_alive_mode",
34+
default=False,
35+
action="store_true",
36+
help="Start in keep-alive daemon mode",
37+
)
38+
1739
parser.add_argument(
1840
"-c",
1941
"--config",
@@ -36,9 +58,18 @@ def main() -> None:
3658
return
3759

3860
config_data = json.loads(config_path.read_text())
39-
config = dataclass_wizard.fromdict(ProxyConfig, config_data)
4061

41-
log.info("Loaded config from %s", config_path)
62+
if args.keep_alive_mode:
63+
config = dataclass_wizard.fromdict(KeepAliveConfig, config_data)
64+
65+
log.info("Loaded keep-alive mode config from %s", config_path)
66+
67+
keep_alive_daemon = KeepAliveDaemon(config)
68+
keep_alive_daemon.start()
69+
else:
70+
config = dataclass_wizard.fromdict(ProxyConfig, config_data)
71+
72+
log.info("Loaded proxy mode config from %s", config_path)
4273

43-
proxy = Proxy(config)
44-
proxy.start()
74+
proxy = Proxy(config)
75+
proxy.start()

src/wolsocketproxy/keepalive.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import asyncio
2+
import logging
3+
import os
4+
import signal
5+
import time
6+
from dataclasses import dataclass
7+
from datetime import datetime
8+
from threading import Thread
9+
from typing import Literal
10+
11+
from aiohttp import web
12+
from aiohttp.web import Request, Response
13+
from setproctitle import setproctitle
14+
15+
16+
@dataclass
17+
class KeepAliveConfig:
18+
listen_address: str = "127.0.0.1"
19+
listen_port: int = 18080
20+
21+
watchdog_feed_interval: int = 600
22+
23+
keep_alive_method: Literal["special_process"] = "special_process"
24+
25+
special_process_name: str | None = "wsp-keepalive"
26+
27+
28+
class KeepAliveDaemon:
29+
_log: logging.Logger = logging.getLogger(__name__)
30+
31+
_config: KeepAliveConfig
32+
_web_app: web.Application
33+
34+
_watchdog_last_feed_time: datetime
35+
_watchdog_is_hungry: bool = False
36+
37+
_special_process_id: int = -1
38+
39+
def __init__(self, config: KeepAliveConfig) -> None:
40+
self._config = config
41+
42+
self._web_app = web.Application()
43+
44+
self._web_app.add_routes(
45+
[
46+
web.get("/watchdog/feed", self._handle_watchdog_feed)
47+
]
48+
)
49+
50+
async def _watchdog_timer(self) -> None:
51+
while True:
52+
previous_is_hungry = self._watchdog_is_hungry
53+
last_feed_interval = datetime.now() - self._watchdog_last_feed_time
54+
55+
if last_feed_interval.total_seconds() > self._config.watchdog_feed_interval:
56+
self._watchdog_is_hungry = True
57+
else:
58+
self._watchdog_is_hungry = False
59+
60+
if not previous_is_hungry and self._watchdog_is_hungry:
61+
Thread(target=self._on_watchdog_hungry, daemon=True).start()
62+
elif previous_is_hungry and not self._watchdog_is_hungry:
63+
Thread(target=self._on_watchdog_feed, daemon=True).start()
64+
65+
await asyncio.sleep(1)
66+
67+
def _on_watchdog_hungry(self) -> None:
68+
self._log.warning("Watchdog is hungry!")
69+
70+
if self._special_process_id > 0:
71+
os.kill(self._special_process_id, signal.SIGKILL)
72+
os.waitpid(self._special_process_id, 0)
73+
self._log.warning("Killed special process PID %d", self._special_process_id)
74+
self._special_process_id = -1
75+
76+
def _on_watchdog_feed(self) -> None:
77+
self._log.info("Watchdog is feed.")
78+
79+
if self._config.keep_alive_method == "special_process" and self._special_process_id < 0:
80+
self._start_special_process()
81+
82+
def _special_process(self) -> None:
83+
assert self._config.special_process_name is not None
84+
setproctitle(self._config.special_process_name)
85+
86+
while True:
87+
time.sleep(60)
88+
89+
if os.getppid() == 1:
90+
os._exit(0)
91+
92+
def _start_special_process(self) -> bool:
93+
self._special_process_id = os.fork()
94+
95+
if self._special_process_id == 0:
96+
self._special_process()
97+
return True
98+
99+
self._log.info(
100+
"Started special process with name %s, PID %d",
101+
self._config.special_process_name, self._special_process_id
102+
)
103+
104+
return False
105+
106+
def start(self) -> None:
107+
self._watchdog_last_feed_time = datetime.now()
108+
self._watchdog_is_hungry = False
109+
110+
if self._config.keep_alive_method == "special_process": # noqa: SIM102
111+
if self._start_special_process():
112+
return
113+
114+
loop = asyncio.new_event_loop()
115+
116+
def _start_loop() -> None:
117+
asyncio.set_event_loop(loop)
118+
loop.run_forever()
119+
120+
Thread(target=_start_loop, daemon=True).start()
121+
122+
watchdog_task = asyncio.run_coroutine_threadsafe(self._watchdog_timer(), loop)
123+
124+
self._log.info(
125+
"Keep-alive daemon started at %s:%d, watchdog feed interval %ds.",
126+
self._config.listen_address, self._config.listen_port, self._config.watchdog_feed_interval
127+
)
128+
129+
web.run_app(self._web_app, host=self._config.listen_address, port=self._config.listen_port)
130+
131+
self._log.info("Stopping...")
132+
133+
watchdog_task.cancel()
134+
loop.stop()
135+
136+
if self._special_process_id > 0:
137+
os.kill(self._special_process_id, signal.SIGKILL)
138+
os.waitpid(self._special_process_id, 0)
139+
140+
self._log.info("Stopped.")
141+
142+
async def _handle_watchdog_feed(self, _: Request) -> Response:
143+
last_feed = self._watchdog_last_feed_time
144+
self._watchdog_last_feed_time = datetime.now()
145+
interval = (self._watchdog_last_feed_time - last_feed).total_seconds()
146+
147+
return web.json_response(
148+
{
149+
"status": "ok",
150+
"last_feed": last_feed.strftime("%Y-%m-%d %H:%M:%S"),
151+
"interval": interval,
152+
}
153+
)

0 commit comments

Comments
 (0)