wolsocketproxy 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: wolsocketproxy
3
+ Version: 0.3.0
4
+ Summary: A socket proxy with wake-on-lan feature.
5
+ Author-email: Song Fuchang <song.fc@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: source, https://github.com/notsyncing/wolsocketproxy
8
+ Project-URL: issues, https://github.com/notsyncing/wol-socket-proxy/issues
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Topic :: System :: Networking
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: aiohttp==3.13.5
17
+ Requires-Dist: dataclass-wizard==0.39.1
18
+ Requires-Dist: icmplib==3.0.4
19
+ Requires-Dist: redfish==3.3.5
20
+ Requires-Dist: setproctitle==1.3.7
21
+ Requires-Dist: wakeonlan==3.1.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: wolsocketproxy[lint]; extra == "dev"
24
+ Requires-Dist: wolsocketproxy[build]; extra == "dev"
25
+ Provides-Extra: lint
26
+ Requires-Dist: ty; extra == "lint"
27
+ Requires-Dist: ruff; extra == "lint"
28
+ Provides-Extra: build
29
+ Requires-Dist: build[virtualenv]==1.5.0; extra == "build"
30
+ Dynamic: license-file
31
+
32
+ # wol-socket-proxy
33
+
34
+ A socket proxy with wake-on-lan and IPMI power control feature.
35
+
36
+ It can forward TCP(bidirectional) and UDP(send-only) traffic from local machine to remote machine, and:
37
+
38
+ - send a magic packet to wake it (wake-on-lan)
39
+ - invoke IPMI power on to wake it
40
+
41
+ before forwarding traffic if the remote machine does not respond to ICMP ping or some HTTP URL.
42
+
43
+ ## Requirements
44
+
45
+ Python 3.12+
46
+
47
+ The remote machine must accept and respond to ICMP ping requests if you use `ping` method.
48
+
49
+ ## Usage
50
+
51
+ You can install it from PyPI or use a prebuilt docker image.
52
+
53
+ ### Install from PyPI
54
+
55
+ Simply install it:
56
+
57
+ ```
58
+ pip install wolsocketproxy
59
+ ```
60
+
61
+ Then create a config file:
62
+
63
+ ```
64
+ cp wolsocketproxy.conf.example /etc/wolsocketproxy.conf
65
+ ```
66
+
67
+ Modify the config file as your requirements.
68
+
69
+ Finally, run it:
70
+
71
+ ```
72
+ wolsocketproxy
73
+ ```
74
+
75
+ ### Use prebuilt docker image
76
+
77
+ See `docker/docker-compose.yml` for more details.
78
+
79
+ ## Config
80
+
81
+ The `wolsocketproxy.conf` has the following structure:
82
+
83
+ ```json5
84
+ {
85
+ // NOTE: Comments are not allowed in actual config file
86
+
87
+ // Define machines
88
+ "machines": {
89
+ "some-server": {
90
+ "ip_address": "192.168.1.124", // the IP address of this machine, optional
91
+ "mac_address": "11:22:33:44:55:66", // The MAC address of this machine, optional
92
+ "wake_up_method": "ipmi", // How to wake up this machine
93
+ // "ipmi" - You must specify "ipmi_config_name"
94
+ // "wol" - You must specify "mac_address"
95
+ "ipmi_config_name": "some-server-ipmi", // IPMI config of this machine, must match what defined in "ipmi_configs"
96
+ "online_check_method": "http", // How to check this machine is online
97
+ // "http" - You must specify "online_check_http_url"
98
+ // "ping" - You must specify "ip_address"
99
+ "online_check_http_url": "http://192.168.1.124/health", // The URL used to check if machine is online, optional
100
+ "online_check_timeout": 300, // Timeout when checking machine is online, seconds, optional, default is 60
101
+ "online_check_http_expected_code": 200, // Expected HTTP status code when using "http" method, optional, default is 200
102
+ "ipmi_force_reset_if_power_up_failed": true, // Use IPMI power reset if this machine is not online after timeout, optional, default is false
103
+ "ipmi_max_reset_try_count": 3 // Max IPMI power reset retry count before giving-up, optional, default is 3
104
+ },
105
+ // ... more machines ...
106
+ },
107
+
108
+ // Define forwarding routes
109
+ "routes": [
110
+ {
111
+ "local_address": "0.0.0.0", // The local address to listen
112
+ "local_port": 12345, // The local port to listen
113
+ "target_machine_name": "some-server", // The target machine name, must match what defined in "machines"
114
+ "target_address": "192.168.1.124", // The target address forwarding to
115
+ "target_port": 12345, // The target port forwarding to
116
+ "protocol": "tcp" // The protocol to use, can choose "tcp" or "udp"
117
+ },
118
+ // ... more routes ...
119
+ ],
120
+
121
+ // Define IPMI configs, optional
122
+ "ipmi_configs": [
123
+ {
124
+ "name": "some-server-ipmi", // IPMI config name
125
+ "redfish_url": "https://192.168.1.123/", // IPMI Redfish API base URL (should not contain /redfish/v1)
126
+ "username": "admin", // IPMI Redfish API login username
127
+ "password": "123456" // IPMI Redfish API login password
128
+ },
129
+ // ... more IPMI configs ...
130
+ ]
131
+ }
132
+ ```
133
+
134
+ ## Additional modes
135
+
136
+ ### Keep-alive daemon mode
137
+
138
+ This mode is used for running on target machine with auto-suspend (e.g. running `circadian` or similar), to keep the target
139
+ machine from auto suspending when running tasks.
140
+
141
+ Use the following command on the target machine to enter this mode:
142
+
143
+ ```bash
144
+ wolsocketproxy -k
145
+ ```
146
+
147
+ The config file `wolsocketproxy.conf` should look like the following:
148
+
149
+
150
+ ```json5
151
+ // Keep-alive daemon mode
152
+ {
153
+ "listen_address": "0.0.0.0", // The address to listen for HTTP server, default is 127.0.0.1
154
+ "listen_port": 18080, // The port to listen for HTTP server, default is 18080
155
+ "watchdog_feed_interval": 60, // Watchdog feed interval, seconds, default is 600
156
+ "keep_alive_method": "special_process", // How to keep target machine alive, default is "special_process"
157
+ // "special_process" - Fork a child process with specified name
158
+ "special_process_name": "wsp-keepalive", // The display name of the forked child process, default is "wsp-keepalive"
159
+ }
160
+ ```
161
+
162
+ You need to periodically send an HTTP GET request to `/watchdog/feed` at the `listen_port` in `watchdog_feed_interval` time,
163
+ or the special process will be killed.
164
+ You can combine this mode with `circadian`'s `process_block` config to keep it from auto-suspending.
@@ -0,0 +1,133 @@
1
+ # wol-socket-proxy
2
+
3
+ A socket proxy with wake-on-lan and IPMI power control feature.
4
+
5
+ It can forward TCP(bidirectional) and UDP(send-only) traffic from local machine to remote machine, and:
6
+
7
+ - send a magic packet to wake it (wake-on-lan)
8
+ - invoke IPMI power on to wake it
9
+
10
+ before forwarding traffic if the remote machine does not respond to ICMP ping or some HTTP URL.
11
+
12
+ ## Requirements
13
+
14
+ Python 3.12+
15
+
16
+ The remote machine must accept and respond to ICMP ping requests if you use `ping` method.
17
+
18
+ ## Usage
19
+
20
+ You can install it from PyPI or use a prebuilt docker image.
21
+
22
+ ### Install from PyPI
23
+
24
+ Simply install it:
25
+
26
+ ```
27
+ pip install wolsocketproxy
28
+ ```
29
+
30
+ Then create a config file:
31
+
32
+ ```
33
+ cp wolsocketproxy.conf.example /etc/wolsocketproxy.conf
34
+ ```
35
+
36
+ Modify the config file as your requirements.
37
+
38
+ Finally, run it:
39
+
40
+ ```
41
+ wolsocketproxy
42
+ ```
43
+
44
+ ### Use prebuilt docker image
45
+
46
+ See `docker/docker-compose.yml` for more details.
47
+
48
+ ## Config
49
+
50
+ The `wolsocketproxy.conf` has the following structure:
51
+
52
+ ```json5
53
+ {
54
+ // NOTE: Comments are not allowed in actual config file
55
+
56
+ // Define machines
57
+ "machines": {
58
+ "some-server": {
59
+ "ip_address": "192.168.1.124", // the IP address of this machine, optional
60
+ "mac_address": "11:22:33:44:55:66", // The MAC address of this machine, optional
61
+ "wake_up_method": "ipmi", // How to wake up this machine
62
+ // "ipmi" - You must specify "ipmi_config_name"
63
+ // "wol" - You must specify "mac_address"
64
+ "ipmi_config_name": "some-server-ipmi", // IPMI config of this machine, must match what defined in "ipmi_configs"
65
+ "online_check_method": "http", // How to check this machine is online
66
+ // "http" - You must specify "online_check_http_url"
67
+ // "ping" - You must specify "ip_address"
68
+ "online_check_http_url": "http://192.168.1.124/health", // The URL used to check if machine is online, optional
69
+ "online_check_timeout": 300, // Timeout when checking machine is online, seconds, optional, default is 60
70
+ "online_check_http_expected_code": 200, // Expected HTTP status code when using "http" method, optional, default is 200
71
+ "ipmi_force_reset_if_power_up_failed": true, // Use IPMI power reset if this machine is not online after timeout, optional, default is false
72
+ "ipmi_max_reset_try_count": 3 // Max IPMI power reset retry count before giving-up, optional, default is 3
73
+ },
74
+ // ... more machines ...
75
+ },
76
+
77
+ // Define forwarding routes
78
+ "routes": [
79
+ {
80
+ "local_address": "0.0.0.0", // The local address to listen
81
+ "local_port": 12345, // The local port to listen
82
+ "target_machine_name": "some-server", // The target machine name, must match what defined in "machines"
83
+ "target_address": "192.168.1.124", // The target address forwarding to
84
+ "target_port": 12345, // The target port forwarding to
85
+ "protocol": "tcp" // The protocol to use, can choose "tcp" or "udp"
86
+ },
87
+ // ... more routes ...
88
+ ],
89
+
90
+ // Define IPMI configs, optional
91
+ "ipmi_configs": [
92
+ {
93
+ "name": "some-server-ipmi", // IPMI config name
94
+ "redfish_url": "https://192.168.1.123/", // IPMI Redfish API base URL (should not contain /redfish/v1)
95
+ "username": "admin", // IPMI Redfish API login username
96
+ "password": "123456" // IPMI Redfish API login password
97
+ },
98
+ // ... more IPMI configs ...
99
+ ]
100
+ }
101
+ ```
102
+
103
+ ## Additional modes
104
+
105
+ ### Keep-alive daemon mode
106
+
107
+ This mode is used for running on target machine with auto-suspend (e.g. running `circadian` or similar), to keep the target
108
+ machine from auto suspending when running tasks.
109
+
110
+ Use the following command on the target machine to enter this mode:
111
+
112
+ ```bash
113
+ wolsocketproxy -k
114
+ ```
115
+
116
+ The config file `wolsocketproxy.conf` should look like the following:
117
+
118
+
119
+ ```json5
120
+ // Keep-alive daemon mode
121
+ {
122
+ "listen_address": "0.0.0.0", // The address to listen for HTTP server, default is 127.0.0.1
123
+ "listen_port": 18080, // The port to listen for HTTP server, default is 18080
124
+ "watchdog_feed_interval": 60, // Watchdog feed interval, seconds, default is 600
125
+ "keep_alive_method": "special_process", // How to keep target machine alive, default is "special_process"
126
+ // "special_process" - Fork a child process with specified name
127
+ "special_process_name": "wsp-keepalive", // The display name of the forked child process, default is "wsp-keepalive"
128
+ }
129
+ ```
130
+
131
+ You need to periodically send an HTTP GET request to `/watchdog/feed` at the `listen_port` in `watchdog_feed_interval` time,
132
+ or the special process will be killed.
133
+ You can combine this mode with `circadian`'s `process_block` config to keep it from auto-suspending.
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wolsocketproxy"
7
+ version = "0.3.0"
8
+ description = "A socket proxy with wake-on-lan feature."
9
+ authors = [
10
+ {name = "Song Fuchang", email = "song.fc@gmail.com"}
11
+ ]
12
+ license = "Apache-2.0"
13
+ readme = "README.md"
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "Topic :: System :: Networking"
19
+ ]
20
+ requires-python = ">=3.12"
21
+ dependencies = [
22
+ "aiohttp==3.13.5",
23
+ "dataclass-wizard==0.39.1",
24
+ "icmplib==3.0.4",
25
+ "redfish==3.3.5",
26
+ "setproctitle==1.3.7",
27
+ "wakeonlan==3.1.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "wolsocketproxy[lint]",
33
+ "wolsocketproxy[build]",
34
+ ]
35
+ lint = [
36
+ "ty",
37
+ "ruff",
38
+ ]
39
+ build = [
40
+ "build[virtualenv]==1.5.0",
41
+ ]
42
+
43
+ [project.urls]
44
+ source = "https://github.com/notsyncing/wolsocketproxy"
45
+ issues = "https://github.com/notsyncing/wol-socket-proxy/issues"
46
+
47
+ [project.scripts]
48
+ wolsocketproxy = "wolsocketproxy:main"
49
+
50
+ [tool.ruff]
51
+ line-length = 120
52
+ src = ["src"]
53
+ target-version = "py312"
54
+ lint.select = ["ALL"]
55
+ lint.ignore = [
56
+ "ASYNC110",
57
+ "COM812",
58
+ "D100",
59
+ "D101",
60
+ "D102",
61
+ "D103",
62
+ "D104",
63
+ "D107",
64
+ "D203",
65
+ "D211",
66
+ "D213",
67
+ "DTZ005",
68
+ "S101",
69
+ "EM102",
70
+ "FBT001",
71
+ "FBT003",
72
+ "ISC001",
73
+ "TRY003",
74
+ "TRY201",
75
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,75 @@
1
+ import json
2
+ import logging
3
+ from argparse import ArgumentParser
4
+ from pathlib import Path
5
+ from sys import stdout
6
+
7
+ import dataclass_wizard
8
+
9
+ from wolsocketproxy.keepalive import KeepAliveConfig, KeepAliveDaemon
10
+ from wolsocketproxy.proxy import Proxy, ProxyConfig
11
+
12
+
13
+ def main() -> None:
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
+
26
+ log = logging.getLogger()
27
+
28
+ parser = ArgumentParser(prog="wolsocketproxy", description="A socket proxy with wake-on-lan feature.")
29
+
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
+
39
+ parser.add_argument(
40
+ "-c",
41
+ "--config",
42
+ help="The config file to use, default lookup order: /etc/wolsocketproxy.conf, ./wolsocketproxy.conf",
43
+ )
44
+
45
+ args = parser.parse_args()
46
+ config_path: Path
47
+
48
+ if "config" in args and args.config is not None:
49
+ config_path = Path(args.config)
50
+ else:
51
+ config_path = Path("/etc/wolsocketproxy.conf")
52
+
53
+ if not config_path.exists():
54
+ config_path = Path("./wolsocketproxy.conf")
55
+
56
+ if not config_path.exists():
57
+ log.error("Config file path %s does not exist!", config_path)
58
+ return
59
+
60
+ config_data = json.loads(config_path.read_text())
61
+
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)
73
+
74
+ proxy = Proxy(config)
75
+ proxy.start()
@@ -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
+ )