wolsocketproxy 0.3.0__tar.gz → 0.4.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.
Files changed (18) hide show
  1. {wolsocketproxy-0.3.0/src/wolsocketproxy.egg-info → wolsocketproxy-0.4.0}/PKG-INFO +27 -2
  2. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/README.md +25 -1
  3. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/pyproject.toml +3 -1
  4. wolsocketproxy-0.4.0/src/wolsocketproxy/common.py +1 -0
  5. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy/keepalive.py +7 -8
  6. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy/proxy.py +253 -7
  7. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0/src/wolsocketproxy.egg-info}/PKG-INFO +27 -2
  8. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy.egg-info/SOURCES.txt +1 -0
  9. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy.egg-info/requires.txt +1 -0
  10. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/LICENSE +0 -0
  11. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/setup.cfg +0 -0
  12. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy/__init__.py +0 -0
  13. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy/__main__.py +0 -0
  14. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy/monitor.py +0 -0
  15. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy/utils.py +0 -0
  16. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy.egg-info/dependency_links.txt +0 -0
  17. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy.egg-info/entry_points.txt +0 -0
  18. {wolsocketproxy-0.3.0 → wolsocketproxy-0.4.0}/src/wolsocketproxy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wolsocketproxy
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A socket proxy with wake-on-lan feature.
5
5
  Author-email: Song Fuchang <song.fc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -14,6 +14,7 @@ Requires-Python: >=3.12
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
16
  Requires-Dist: aiohttp==3.13.5
17
+ Requires-Dist: croniter==6.0.0
17
18
  Requires-Dist: dataclass-wizard==0.39.1
18
19
  Requires-Dist: icmplib==3.0.4
19
20
  Requires-Dist: redfish==3.3.5
@@ -100,7 +101,16 @@ The `wolsocketproxy.conf` has the following structure:
100
101
  "online_check_timeout": 300, // Timeout when checking machine is online, seconds, optional, default is 60
101
102
  "online_check_http_expected_code": 200, // Expected HTTP status code when using "http" method, optional, default is 200
102
103
  "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
+ "ipmi_max_reset_try_count": 3, // Max IPMI power reset retry count before giving-up, optional, default is 3
105
+ "keep_alive_mode": true, // Indicates whether this machine has a keep-alive daemon, optional, default is false
106
+ // If you enable this, wolsocketproxy will send a request when there is any traffic
107
+ "keep_alive_mode_base_url": "http://192.168.1.124:8080", // Keep-alive daemon provided URL, optional
108
+ "scheduled_power_up_times": [ // Scheduled power-up times, optional
109
+ {
110
+ "cron": "0 7 * * 1-5", // Cron expression for auto power-up
111
+ "keep_alive_time": 7200 // Send keep-alive requests for N seconds after power-up
112
+ }
113
+ ]
104
114
  },
105
115
  // ... more machines ...
106
116
  },
@@ -162,3 +172,18 @@ The config file `wolsocketproxy.conf` should look like the following:
162
172
  You need to periodically send an HTTP GET request to `/watchdog/feed` at the `listen_port` in `watchdog_feed_interval` time,
163
173
  or the special process will be killed.
164
174
  You can combine this mode with `circadian`'s `process_block` config to keep it from auto-suspending.
175
+
176
+ If you enable `keep_alive_mode` in proxy mode's config, the proxy will send requests to `/watchdog/feed` of this machine whenever there is any traffic towards this machine through it.
177
+
178
+ ### Scheduled power-up
179
+
180
+ You can configure `scheduled_power_up_times` in each machine entry to automatically power up the machine at specified times using
181
+ a cron expression. After the machine comes online, the proxy sends keep-alive requests to the machine's keep-alive daemon
182
+ (for the duration specified by `keep_alive_time`) to prevent it from auto-suspending during the expected usage window.
183
+
184
+ Each entry in `scheduled_power_up_times` contains:
185
+ - `cron`: A standard 5-field cron expression (e.g. `"0 7 * * 1-5"` for weekdays at 7:00)
186
+ - `keep_alive_time`: Duration in seconds to send keep-alive requests after power-up (e.g. `7200` for 2 hours)
187
+
188
+ If multiple entries overlap in time, the keep-alive period is automatically extended to cover the longest window, and no
189
+ duplicate keep-alive requests are sent.
@@ -69,7 +69,16 @@ The `wolsocketproxy.conf` has the following structure:
69
69
  "online_check_timeout": 300, // Timeout when checking machine is online, seconds, optional, default is 60
70
70
  "online_check_http_expected_code": 200, // Expected HTTP status code when using "http" method, optional, default is 200
71
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
72
+ "ipmi_max_reset_try_count": 3, // Max IPMI power reset retry count before giving-up, optional, default is 3
73
+ "keep_alive_mode": true, // Indicates whether this machine has a keep-alive daemon, optional, default is false
74
+ // If you enable this, wolsocketproxy will send a request when there is any traffic
75
+ "keep_alive_mode_base_url": "http://192.168.1.124:8080", // Keep-alive daemon provided URL, optional
76
+ "scheduled_power_up_times": [ // Scheduled power-up times, optional
77
+ {
78
+ "cron": "0 7 * * 1-5", // Cron expression for auto power-up
79
+ "keep_alive_time": 7200 // Send keep-alive requests for N seconds after power-up
80
+ }
81
+ ]
73
82
  },
74
83
  // ... more machines ...
75
84
  },
@@ -131,3 +140,18 @@ The config file `wolsocketproxy.conf` should look like the following:
131
140
  You need to periodically send an HTTP GET request to `/watchdog/feed` at the `listen_port` in `watchdog_feed_interval` time,
132
141
  or the special process will be killed.
133
142
  You can combine this mode with `circadian`'s `process_block` config to keep it from auto-suspending.
143
+
144
+ If you enable `keep_alive_mode` in proxy mode's config, the proxy will send requests to `/watchdog/feed` of this machine whenever there is any traffic towards this machine through it.
145
+
146
+ ### Scheduled power-up
147
+
148
+ You can configure `scheduled_power_up_times` in each machine entry to automatically power up the machine at specified times using
149
+ a cron expression. After the machine comes online, the proxy sends keep-alive requests to the machine's keep-alive daemon
150
+ (for the duration specified by `keep_alive_time`) to prevent it from auto-suspending during the expected usage window.
151
+
152
+ Each entry in `scheduled_power_up_times` contains:
153
+ - `cron`: A standard 5-field cron expression (e.g. `"0 7 * * 1-5"` for weekdays at 7:00)
154
+ - `keep_alive_time`: Duration in seconds to send keep-alive requests after power-up (e.g. `7200` for 2 hours)
155
+
156
+ If multiple entries overlap in time, the keep-alive period is automatically extended to cover the longest window, and no
157
+ duplicate keep-alive requests are sent.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wolsocketproxy"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "A socket proxy with wake-on-lan feature."
9
9
  authors = [
10
10
  {name = "Song Fuchang", email = "song.fc@gmail.com"}
@@ -20,6 +20,7 @@ classifiers = [
20
20
  requires-python = ">=3.12"
21
21
  dependencies = [
22
22
  "aiohttp==3.13.5",
23
+ "croniter==6.0.0",
23
24
  "dataclass-wizard==0.39.1",
24
25
  "icmplib==3.0.4",
25
26
  "redfish==3.3.5",
@@ -72,4 +73,5 @@ lint.ignore = [
72
73
  "ISC001",
73
74
  "TRY003",
74
75
  "TRY201",
76
+ "PLR0913",
75
77
  ]
@@ -0,0 +1 @@
1
+ URL_WATCHDOG_FEED = "/watchdog/feed"
@@ -12,6 +12,8 @@ from aiohttp import web
12
12
  from aiohttp.web import Request, Response
13
13
  from setproctitle import setproctitle
14
14
 
15
+ from wolsocketproxy.common import URL_WATCHDOG_FEED
16
+
15
17
 
16
18
  @dataclass
17
19
  class KeepAliveConfig:
@@ -41,11 +43,7 @@ class KeepAliveDaemon:
41
43
 
42
44
  self._web_app = web.Application()
43
45
 
44
- self._web_app.add_routes(
45
- [
46
- web.get("/watchdog/feed", self._handle_watchdog_feed)
47
- ]
48
- )
46
+ self._web_app.add_routes([web.get(URL_WATCHDOG_FEED, self._handle_watchdog_feed)])
49
47
 
50
48
  async def _watchdog_timer(self) -> None:
51
49
  while True:
@@ -97,8 +95,7 @@ class KeepAliveDaemon:
97
95
  return True
98
96
 
99
97
  self._log.info(
100
- "Started special process with name %s, PID %d",
101
- self._config.special_process_name, self._special_process_id
98
+ "Started special process with name %s, PID %d", self._config.special_process_name, self._special_process_id
102
99
  )
103
100
 
104
101
  return False
@@ -123,7 +120,9 @@ class KeepAliveDaemon:
123
120
 
124
121
  self._log.info(
125
122
  "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
123
+ self._config.listen_address,
124
+ self._config.listen_port,
125
+ self._config.watchdog_feed_interval,
127
126
  )
128
127
 
129
128
  web.run_app(self._web_app, host=self._config.listen_address, port=self._config.listen_port)
@@ -1,17 +1,30 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import logging
4
+ import time
5
+ from collections.abc import Callable, Coroutine
4
6
  from dataclasses import dataclass
7
+ from datetime import datetime
5
8
  from logging import Logger
9
+ from threading import Event, Thread
6
10
  from typing import Any, Literal, override
7
11
 
12
+ import aiohttp
8
13
  import wakeonlan
14
+ from croniter import croniter
9
15
  from redfish.rest.v1 import HttpClient, redfish_client
10
16
 
17
+ from wolsocketproxy.common import URL_WATCHDOG_FEED
11
18
  from wolsocketproxy.monitor import Monitor, MonitorConfig
12
19
  from wolsocketproxy.utils import perform_ipmi_action
13
20
 
14
21
 
22
+ @dataclass
23
+ class ScheduledPowerUpTime:
24
+ cron: str
25
+ keep_alive_time: int = 0
26
+
27
+
15
28
  @dataclass
16
29
  class MachineConfig:
17
30
  wake_up_method: Literal["ipmi", "wol"] = "wol"
@@ -29,6 +42,12 @@ class MachineConfig:
29
42
  online_check_http_expected_code: int = 200
30
43
  online_check_timeout: int = 60
31
44
 
45
+ keep_alive_mode: bool = False
46
+ keep_alive_mode_base_url: str | None = None
47
+ keep_alive_min_interval: int = 1
48
+
49
+ scheduled_power_up_times: list[ScheduledPowerUpTime] | None = None
50
+
32
51
 
33
52
  @dataclass
34
53
  class ProxyRoute:
@@ -56,6 +75,168 @@ class ProxyConfig:
56
75
  ipmi_configs: list[IPMIConfig] | None = None
57
76
 
58
77
 
78
+ class TargetKeepAliveSender:
79
+ _log: logging.Logger = logging.getLogger(__name__)
80
+
81
+ _target_url: str
82
+ _keep_alive_min_interval: int
83
+ _loop: asyncio.AbstractEventLoop
84
+ _queue: asyncio.Queue
85
+ _stop_event: Event
86
+
87
+ def __init__(self, target_base_url: str, keep_alive_min_interval: int) -> None:
88
+ self._target_url = target_base_url.removesuffix("/") + URL_WATCHDOG_FEED
89
+ self._keep_alive_min_interval = keep_alive_min_interval
90
+
91
+ self._loop = asyncio.new_event_loop()
92
+ self._queue = asyncio.Queue(1)
93
+ self._stop_event = Event()
94
+
95
+ def _run_loop() -> None:
96
+ asyncio.set_event_loop(self._loop)
97
+ self._loop.run_until_complete(self._send_worker())
98
+
99
+ Thread(target=_run_loop, daemon=True).start()
100
+
101
+ async def _send_worker(self) -> None:
102
+ while not self._stop_event.is_set():
103
+ await self._queue.get()
104
+ if self._stop_event.is_set():
105
+ break
106
+ await self._send()
107
+ await asyncio.sleep(self._keep_alive_min_interval)
108
+
109
+ async def _send(self) -> None:
110
+ try:
111
+ async with aiohttp.request(
112
+ "GET",
113
+ self._target_url,
114
+ timeout=aiohttp.ClientTimeout(total=5),
115
+ ) as resp:
116
+ await resp.json()
117
+ except (aiohttp.ClientError, aiohttp.ClientResponseError):
118
+ self._log.warning("Failed to send target keep alive request to %s", self._target_url, exc_info=True)
119
+
120
+ def schedule_send(self) -> None:
121
+ def _no_exception_put() -> None:
122
+ with contextlib.suppress(asyncio.QueueFull):
123
+ self._queue.put_nowait(1)
124
+
125
+ self._loop.call_soon_threadsafe(_no_exception_put)
126
+
127
+ def stop(self) -> None:
128
+ self._stop_event.set()
129
+ with contextlib.suppress(asyncio.QueueFull):
130
+ self._loop.call_soon_threadsafe(lambda: self._queue.put_nowait(None))
131
+
132
+
133
+ class ScheduledPowerUpManager:
134
+ _log: Logger = logging.getLogger(__name__)
135
+
136
+ _machines: dict[str, MachineConfig]
137
+ _wake_up_callback: Callable[[str], Coroutine[Any, Any, None]]
138
+ _loop: asyncio.AbstractEventLoop
139
+ _keep_alive_end_times: dict[str, float]
140
+
141
+ def __init__(
142
+ self,
143
+ machines: dict[str, MachineConfig],
144
+ wake_up_callback: Callable[[str], Coroutine[Any, Any, None]],
145
+ ) -> None:
146
+ self._machines = machines
147
+ self._wake_up_callback = wake_up_callback
148
+ self._keep_alive_end_times = {}
149
+
150
+ def start(self) -> None:
151
+ self._loop = asyncio.new_event_loop()
152
+
153
+ def _run_loop() -> None:
154
+ asyncio.set_event_loop(self._loop)
155
+ self._loop.run_forever()
156
+
157
+ Thread(target=_run_loop, daemon=True).start()
158
+
159
+ for machine_name, machine in self._machines.items():
160
+ schedules = machine.scheduled_power_up_times
161
+ if not schedules:
162
+ continue
163
+
164
+ if machine.keep_alive_mode_base_url is None:
165
+ self._log.warning(
166
+ "Machine %s has scheduled_power_up_times but no keep_alive_mode_base_url configured, "
167
+ "keep-alive after power-up will be skipped",
168
+ machine_name,
169
+ )
170
+
171
+ for schedule in schedules:
172
+ asyncio.run_coroutine_threadsafe(
173
+ self._process_schedule(machine_name, machine, schedule),
174
+ self._loop,
175
+ )
176
+
177
+ async def _process_schedule(
178
+ self,
179
+ machine_name: str,
180
+ machine: MachineConfig,
181
+ schedule: ScheduledPowerUpTime,
182
+ ) -> None:
183
+ while True:
184
+ now = datetime.now()
185
+ cron = croniter(schedule.cron, now)
186
+ next_time = cron.get_next(datetime)
187
+
188
+ delay = (next_time - datetime.now()).total_seconds()
189
+ if delay > 0:
190
+ await asyncio.sleep(delay)
191
+
192
+ self._log.info(
193
+ "Scheduled power-up triggered for %s (cron: %s)",
194
+ machine_name,
195
+ schedule.cron,
196
+ )
197
+
198
+ try:
199
+ await self._wake_up_callback(machine_name)
200
+ except ConnectionAbortedError:
201
+ self._log.error(
202
+ "Scheduled power-up failed for %s, will retry at next schedule time",
203
+ machine_name,
204
+ )
205
+ continue
206
+
207
+ if schedule.keep_alive_time > 0 and machine.keep_alive_mode_base_url is not None:
208
+ await self._run_keep_alive(machine_name, machine, schedule.keep_alive_time)
209
+
210
+
211
+ async def _run_keep_alive(
212
+ self,
213
+ machine_name: str,
214
+ machine: MachineConfig,
215
+ keep_alive_time: int,
216
+ ) -> None:
217
+ assert machine.keep_alive_mode_base_url is not None
218
+ new_end = time.monotonic() + keep_alive_time
219
+ current_end = self._keep_alive_end_times.get(machine_name, 0.0)
220
+
221
+ if new_end > current_end:
222
+ self._keep_alive_end_times[machine_name] = new_end
223
+
224
+ if current_end > time.monotonic():
225
+ return
226
+
227
+ sender = TargetKeepAliveSender(
228
+ machine.keep_alive_mode_base_url,
229
+ machine.keep_alive_min_interval,
230
+ )
231
+
232
+ try:
233
+ while time.monotonic() < self._keep_alive_end_times.get(machine_name, 0.0):
234
+ sender.schedule_send()
235
+ await asyncio.sleep(machine.keep_alive_min_interval)
236
+ finally:
237
+ sender.stop()
238
+
239
+
59
240
  class ProxyUdpProtocol(asyncio.DatagramProtocol):
60
241
  _proxy: "Proxy"
61
242
  _monitor: Monitor
@@ -63,16 +244,24 @@ class ProxyUdpProtocol(asyncio.DatagramProtocol):
63
244
  _target_machine_name: str
64
245
  _target_address: str
65
246
  _target_port: int
247
+ _target_keep_alive_sender: TargetKeepAliveSender | None = None
66
248
  _target_pair: tuple[str, int]
67
249
 
68
250
  def __init__(
69
- self, proxy: "Proxy", monitor: Monitor, target_machine_name: str, target_address: str, target_port: int
251
+ self,
252
+ proxy: "Proxy",
253
+ monitor: Monitor,
254
+ target_machine_name: str,
255
+ target_address: str,
256
+ target_port: int,
257
+ target_keep_alive_sender: TargetKeepAliveSender | None = None,
70
258
  ) -> None:
71
259
  self._proxy = proxy
72
260
  self._monitor = monitor
73
261
  self._target_machine_name = target_machine_name
74
262
  self._target_address = target_address
75
263
  self._target_port = target_port
264
+ self._target_keep_alive_sender = target_keep_alive_sender
76
265
  self._target_pair = (target_address, target_port)
77
266
 
78
267
  @override
@@ -92,6 +281,9 @@ class ProxyUdpProtocol(asyncio.DatagramProtocol):
92
281
 
93
282
  self._transport.sendto(data, self._target_pair)
94
283
 
284
+ if self._target_keep_alive_sender is not None:
285
+ self._target_keep_alive_sender.schedule_send()
286
+
95
287
 
96
288
  class Proxy:
97
289
  _log: Logger = logging.getLogger()
@@ -100,6 +292,7 @@ class Proxy:
100
292
  _machines: dict[str, MachineConfig]
101
293
  _routes: list[ProxyRoute]
102
294
  _ipmi_configs: dict[str, IPMIConfig]
295
+ _scheduled_power_up_manager: ScheduledPowerUpManager
103
296
 
104
297
  def __init__(self, config: ProxyConfig) -> None:
105
298
  self._config = config
@@ -139,6 +332,11 @@ class Proxy:
139
332
  }
140
333
  )
141
334
 
335
+ self._scheduled_power_up_manager = ScheduledPowerUpManager(
336
+ machines=self._machines,
337
+ wake_up_callback=self._wake_up_target,
338
+ )
339
+
142
340
  def start(self) -> None:
143
341
  self._monitor.start()
144
342
 
@@ -147,6 +345,8 @@ class Proxy:
147
345
 
148
346
  loop = asyncio.get_event_loop()
149
347
 
348
+ self._scheduled_power_up_manager.start()
349
+
150
350
  self._log.info("Proxy server started.")
151
351
 
152
352
  with contextlib.suppress(KeyboardInterrupt):
@@ -180,9 +380,23 @@ class Proxy:
180
380
 
181
381
  def __create_tcp_route(self, route: ProxyRoute) -> None:
182
382
  assert route.target_machine_name is not None
383
+ machine_config = self._machines[route.target_machine_name]
384
+ target_keep_alive_sender = None
385
+
386
+ if machine_config.keep_alive_mode:
387
+ assert machine_config.keep_alive_mode_base_url is not None
388
+
389
+ target_keep_alive_sender = TargetKeepAliveSender(
390
+ machine_config.keep_alive_mode_base_url, machine_config.keep_alive_min_interval
391
+ )
183
392
 
184
393
  cr = asyncio.start_server(
185
- self.__make_tcp_route_handler(route.target_machine_name, route.target_address, route.target_port),
394
+ self.__make_tcp_route_handler(
395
+ route.target_machine_name,
396
+ route.target_address,
397
+ route.target_port,
398
+ target_keep_alive_sender,
399
+ ),
186
400
  route.local_address,
187
401
  route.local_port,
188
402
  )
@@ -190,16 +404,31 @@ class Proxy:
190
404
  loop = asyncio.get_event_loop()
191
405
  loop.run_until_complete(cr)
192
406
 
193
- async def __pipe(self, target_address: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
407
+ async def __pipe(
408
+ self,
409
+ target_address: str,
410
+ reader: asyncio.StreamReader,
411
+ writer: asyncio.StreamWriter,
412
+ target_keep_alive_sender: TargetKeepAliveSender | None = None,
413
+ ) -> None:
194
414
  try:
195
415
  while not reader.at_eof():
196
416
  writer.write(await reader.read(2048))
417
+
418
+ if target_keep_alive_sender is not None:
419
+ target_keep_alive_sender.schedule_send()
197
420
  except ConnectionResetError:
198
421
  self._log.warning("Connection reset by target %s", target_address)
199
422
  finally:
200
423
  writer.close()
201
424
 
202
- def __make_tcp_route_handler(self, target_machine_name: str, target_address: str, target_port: int) -> Any: # noqa: ANN401
425
+ def __make_tcp_route_handler(
426
+ self,
427
+ target_machine_name: str,
428
+ target_address: str,
429
+ target_port: int,
430
+ target_keep_alive_sender: TargetKeepAliveSender | None = None,
431
+ ) -> Any: # noqa: ANN401
203
432
  async def handler(local_reader: asyncio.StreamReader, local_writer: asyncio.StreamWriter) -> None:
204
433
  if not self._monitor.is_available(target_machine_name):
205
434
  await self._wake_up_target(target_machine_name)
@@ -211,8 +440,8 @@ class Proxy:
211
440
  self._log.error("Unable to open connection to %s:%d", target_address, target_port)
212
441
  raise e
213
442
 
214
- send_pipe = self.__pipe(target_address, local_reader, target_writer)
215
- recv_pipe = self.__pipe(target_address, target_reader, local_writer)
443
+ send_pipe = self.__pipe(target_address, local_reader, target_writer, target_keep_alive_sender)
444
+ recv_pipe = self.__pipe(target_address, target_reader, local_writer, target_keep_alive_sender)
216
445
  await asyncio.gather(send_pipe, recv_pipe)
217
446
 
218
447
  return handler
@@ -221,10 +450,27 @@ class Proxy:
221
450
  target_machine_name = route.target_machine_name
222
451
  assert target_machine_name is not None
223
452
 
453
+ machine_config = self._machines[target_machine_name]
454
+ target_keep_alive_sender = None
455
+
456
+ if machine_config.keep_alive_mode:
457
+ assert machine_config.keep_alive_mode_base_url is not None
458
+
459
+ target_keep_alive_sender = TargetKeepAliveSender(
460
+ machine_config.keep_alive_mode_base_url, machine_config.keep_alive_min_interval
461
+ )
462
+
224
463
  loop = asyncio.get_event_loop()
225
464
 
226
465
  cr = loop.create_datagram_endpoint(
227
- lambda: ProxyUdpProtocol(self, self._monitor, target_machine_name, route.target_address, route.target_port),
466
+ lambda: ProxyUdpProtocol(
467
+ self,
468
+ self._monitor,
469
+ target_machine_name,
470
+ route.target_address,
471
+ route.target_port,
472
+ target_keep_alive_sender,
473
+ ),
228
474
  local_addr=(route.local_address, route.local_port),
229
475
  )
230
476
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wolsocketproxy
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A socket proxy with wake-on-lan feature.
5
5
  Author-email: Song Fuchang <song.fc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -14,6 +14,7 @@ Requires-Python: >=3.12
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
16
  Requires-Dist: aiohttp==3.13.5
17
+ Requires-Dist: croniter==6.0.0
17
18
  Requires-Dist: dataclass-wizard==0.39.1
18
19
  Requires-Dist: icmplib==3.0.4
19
20
  Requires-Dist: redfish==3.3.5
@@ -100,7 +101,16 @@ The `wolsocketproxy.conf` has the following structure:
100
101
  "online_check_timeout": 300, // Timeout when checking machine is online, seconds, optional, default is 60
101
102
  "online_check_http_expected_code": 200, // Expected HTTP status code when using "http" method, optional, default is 200
102
103
  "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
+ "ipmi_max_reset_try_count": 3, // Max IPMI power reset retry count before giving-up, optional, default is 3
105
+ "keep_alive_mode": true, // Indicates whether this machine has a keep-alive daemon, optional, default is false
106
+ // If you enable this, wolsocketproxy will send a request when there is any traffic
107
+ "keep_alive_mode_base_url": "http://192.168.1.124:8080", // Keep-alive daemon provided URL, optional
108
+ "scheduled_power_up_times": [ // Scheduled power-up times, optional
109
+ {
110
+ "cron": "0 7 * * 1-5", // Cron expression for auto power-up
111
+ "keep_alive_time": 7200 // Send keep-alive requests for N seconds after power-up
112
+ }
113
+ ]
104
114
  },
105
115
  // ... more machines ...
106
116
  },
@@ -162,3 +172,18 @@ The config file `wolsocketproxy.conf` should look like the following:
162
172
  You need to periodically send an HTTP GET request to `/watchdog/feed` at the `listen_port` in `watchdog_feed_interval` time,
163
173
  or the special process will be killed.
164
174
  You can combine this mode with `circadian`'s `process_block` config to keep it from auto-suspending.
175
+
176
+ If you enable `keep_alive_mode` in proxy mode's config, the proxy will send requests to `/watchdog/feed` of this machine whenever there is any traffic towards this machine through it.
177
+
178
+ ### Scheduled power-up
179
+
180
+ You can configure `scheduled_power_up_times` in each machine entry to automatically power up the machine at specified times using
181
+ a cron expression. After the machine comes online, the proxy sends keep-alive requests to the machine's keep-alive daemon
182
+ (for the duration specified by `keep_alive_time`) to prevent it from auto-suspending during the expected usage window.
183
+
184
+ Each entry in `scheduled_power_up_times` contains:
185
+ - `cron`: A standard 5-field cron expression (e.g. `"0 7 * * 1-5"` for weekdays at 7:00)
186
+ - `keep_alive_time`: Duration in seconds to send keep-alive requests after power-up (e.g. `7200` for 2 hours)
187
+
188
+ If multiple entries overlap in time, the keep-alive period is automatically extended to cover the longest window, and no
189
+ duplicate keep-alive requests are sent.
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/wolsocketproxy/__init__.py
5
5
  src/wolsocketproxy/__main__.py
6
+ src/wolsocketproxy/common.py
6
7
  src/wolsocketproxy/keepalive.py
7
8
  src/wolsocketproxy/monitor.py
8
9
  src/wolsocketproxy/proxy.py
@@ -1,4 +1,5 @@
1
1
  aiohttp==3.13.5
2
+ croniter==6.0.0
2
3
  dataclass-wizard==0.39.1
3
4
  icmplib==3.0.4
4
5
  redfish==3.3.5
File without changes
File without changes