wolsocketproxy 0.1.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,13 @@
1
+ Copyright 2024 Song Fuchang
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.1
2
+ Name: wolsocketproxy
3
+ Version: 0.1.0
4
+ Summary: A socket proxy with wake-on-lan feature.
5
+ License: Apache-2.0
6
+ Author: Song Fuchang
7
+ Author-email: song.fc@gmail.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: System :: Networking
18
+ Requires-Dist: dataclass-wizard (==0.28.0)
19
+ Requires-Dist: icmplib (==3.0.4)
20
+ Requires-Dist: wakeonlan (==3.1.0)
21
+ Project-URL: Changelog, https://github.com/notsyncing/wol-socket-proxy/releases
22
+ Project-URL: Issues, https://github.com/notsyncing/wol-socket-proxy/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # wol-socket-proxy
26
+
27
+ A socket proxy with wake-on-lan feature.
28
+
29
+ It can forward TCP(bidirectional) and UDP(send-only) traffic from local machine to remote machine, and send a magic packet to wake it (wake-on-lan) before forwarding traffic if the remote machine does not respond to ICMP ping.
30
+
31
+ ## Requirements
32
+
33
+ Python 3.11+
34
+
35
+ The remote machine must accept and respond to ICMP ping requests.
36
+
37
+ ## Usage
38
+
39
+ You can install it from PyPI or use a prebuilt docker image.
40
+
41
+ ### Install from PyPI
42
+
43
+ Simply install it:
44
+
45
+ ```
46
+ pip install wolsocketproxy
47
+ ```
48
+
49
+ Then create a config file:
50
+
51
+ ```
52
+ cp wolsocketproxy.conf.example /etc/wolsocketproxy.conf
53
+ ```
54
+
55
+ Modify the config file as your requirements.
56
+
57
+ Finally, run it
58
+
59
+ ```
60
+ wolsocketproxy
61
+ ```
62
+
63
+ ### Use prebuilt docker image
64
+
65
+ See `docker/docker-compose.yml` for more details.
66
+
67
+ ## Config
68
+
69
+ The `wolsocketproxy.conf` has the following structure:
70
+
71
+ ```json5
72
+ {
73
+ // NOTE: Comments are not allowed in actual config file
74
+ // Define forwarding routes
75
+ "routes": [
76
+ {
77
+ "local_address": "0.0.0.0", // The local address to listen
78
+ "local_port": 12345, // The local port to listen
79
+ "target_address": "192.168.0.100", // The target address forwarding to
80
+ "target_port": 22, // The target port forwarding to
81
+ "protocol": "tcp" // The protocol to use
82
+ },
83
+ // ... more routes ...
84
+ ],
85
+
86
+ // Tell the program about the MAC address of each IP in routes
87
+ "mac_mappings": {
88
+ // Key is IP address, and value is MAC address, case-insensitive
89
+ "192.168.0.100": "11:22:33:44:55:66",
90
+ // ... more items ...
91
+ }
92
+ }
93
+ ```
94
+
@@ -0,0 +1,69 @@
1
+ # wol-socket-proxy
2
+
3
+ A socket proxy with wake-on-lan feature.
4
+
5
+ It can forward TCP(bidirectional) and UDP(send-only) traffic from local machine to remote machine, and send a magic packet to wake it (wake-on-lan) before forwarding traffic if the remote machine does not respond to ICMP ping.
6
+
7
+ ## Requirements
8
+
9
+ Python 3.11+
10
+
11
+ The remote machine must accept and respond to ICMP ping requests.
12
+
13
+ ## Usage
14
+
15
+ You can install it from PyPI or use a prebuilt docker image.
16
+
17
+ ### Install from PyPI
18
+
19
+ Simply install it:
20
+
21
+ ```
22
+ pip install wolsocketproxy
23
+ ```
24
+
25
+ Then create a config file:
26
+
27
+ ```
28
+ cp wolsocketproxy.conf.example /etc/wolsocketproxy.conf
29
+ ```
30
+
31
+ Modify the config file as your requirements.
32
+
33
+ Finally, run it
34
+
35
+ ```
36
+ wolsocketproxy
37
+ ```
38
+
39
+ ### Use prebuilt docker image
40
+
41
+ See `docker/docker-compose.yml` for more details.
42
+
43
+ ## Config
44
+
45
+ The `wolsocketproxy.conf` has the following structure:
46
+
47
+ ```json5
48
+ {
49
+ // NOTE: Comments are not allowed in actual config file
50
+ // Define forwarding routes
51
+ "routes": [
52
+ {
53
+ "local_address": "0.0.0.0", // The local address to listen
54
+ "local_port": 12345, // The local port to listen
55
+ "target_address": "192.168.0.100", // The target address forwarding to
56
+ "target_port": 22, // The target port forwarding to
57
+ "protocol": "tcp" // The protocol to use
58
+ },
59
+ // ... more routes ...
60
+ ],
61
+
62
+ // Tell the program about the MAC address of each IP in routes
63
+ "mac_mappings": {
64
+ // Key is IP address, and value is MAC address, case-insensitive
65
+ "192.168.0.100": "11:22:33:44:55:66",
66
+ // ... more items ...
67
+ }
68
+ }
69
+ ```
@@ -0,0 +1,69 @@
1
+ [tool.poetry]
2
+ name = "wolsocketproxy"
3
+ version = "0.1.0"
4
+ description = "A socket proxy with wake-on-lan feature."
5
+ authors = ["Song Fuchang <song.fc@gmail.com>"]
6
+ license = "Apache-2.0"
7
+ readme = "README.md"
8
+ classifiers = [
9
+ "Development Status :: 5 - Production/Stable",
10
+ "Intended Audience :: Developers",
11
+ "Intended Audience :: System Administrators",
12
+ "Topic :: System :: Networking"
13
+ ]
14
+
15
+ [tool.poetry.urls]
16
+ Changelog = "https://github.com/notsyncing/wol-socket-proxy/releases"
17
+ Issues = "https://github.com/notsyncing/wol-socket-proxy/issues"
18
+
19
+ [tool.poetry.dependencies]
20
+ python = "^3.11"
21
+ wakeonlan = "3.1.0"
22
+ dataclass-wizard = "0.28.0"
23
+ icmplib = "3.0.4"
24
+
25
+ [tool.poetry.dev-dependencies]
26
+ mypy = { version = "*", python = ">=3.11" }
27
+ ruff = { version = "*", python = ">=3.11" }
28
+
29
+ [tool.poetry.scripts]
30
+ wolsocketproxy = "wolsocketproxy:main"
31
+
32
+ [tool.ruff]
33
+ line-length = 120
34
+ src = ["wolsocketproxy"]
35
+ target-version = "py311"
36
+ lint.select = ["ALL"]
37
+ lint.ignore = [
38
+ "COM812",
39
+ "D100",
40
+ "D101",
41
+ "D102",
42
+ "D103",
43
+ "D104",
44
+ "D107",
45
+ "D203",
46
+ "D211",
47
+ "D213",
48
+ "EM102",
49
+ "FBT001",
50
+ "FBT003",
51
+ "ISC001",
52
+ "TRY003",
53
+ "TRY201",
54
+ ]
55
+
56
+ [tool.mypy]
57
+ disallow_untyped_defs = true # Functions need to be annotated
58
+ warn_unused_ignores = true
59
+ ignore_missing_imports = true
60
+
61
+
62
+ [[tool.poetry.source]]
63
+ name = "mirrors"
64
+ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"
65
+ priority = "primary"
66
+
67
+ [build-system]
68
+ requires = ["poetry-core"]
69
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,44 @@
1
+ import json
2
+ import logging
3
+ from argparse import ArgumentParser
4
+ from pathlib import Path
5
+
6
+ import dataclass_wizard
7
+
8
+ from wolsocketproxy.proxy import Proxy, ProxyConfig
9
+
10
+
11
+ def main() -> None:
12
+ logging.basicConfig(level=logging.INFO)
13
+ log = logging.getLogger()
14
+
15
+ parser = ArgumentParser(prog="wolsocketproxy", description="A socket proxy with wake-on-lan feature.")
16
+
17
+ parser.add_argument(
18
+ "-c",
19
+ "--config",
20
+ help="The config file to use, default lookup order: /etc/wolsocketproxy.conf, ./wolsocketproxy.conf",
21
+ )
22
+
23
+ args = parser.parse_args()
24
+ config_path: Path
25
+
26
+ if "config" in args and args.config is not None:
27
+ config_path = Path(args.config)
28
+ else:
29
+ config_path = Path("/etc/wolsocketproxy.conf")
30
+
31
+ if not config_path.exists():
32
+ config_path = Path("./wolsocketproxy.conf")
33
+
34
+ if not config_path.exists():
35
+ log.error("Config file path %s does not exist!", config_path)
36
+ return
37
+
38
+ config_data = json.loads(config_path.read_text())
39
+ config = dataclass_wizard.fromdict(ProxyConfig, config_data)
40
+
41
+ log.info("Loaded config from %s", config_path)
42
+
43
+ proxy = Proxy(config)
44
+ proxy.start()
@@ -0,0 +1,4 @@
1
+ from wolsocketproxy import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,48 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import Collection
4
+ from logging import Logger
5
+ from threading import Thread
6
+
7
+ from icmplib import multiping
8
+
9
+
10
+ class Monitor:
11
+ _log: Logger = logging.getLogger()
12
+ _ip_state: dict[str, bool]
13
+
14
+ def __init__(self, watching_ip_list: Collection[str]) -> None:
15
+ self._ip_state = {}
16
+
17
+ for ip in watching_ip_list:
18
+ self._ip_state[ip] = False
19
+
20
+ def start(self) -> None:
21
+ t = Thread(target=self.__check_ip_state)
22
+ t.daemon = True
23
+ t.start()
24
+
25
+ def __check_ip_state(self) -> None:
26
+ self._log.info("IP monitor is started.")
27
+
28
+ while True:
29
+ ip_list = self._ip_state.keys()
30
+ results = multiping(ip_list, count=3, timeout=2, privileged=False)
31
+
32
+ for result in results:
33
+ original_state = self._ip_state[result.address]
34
+ self._ip_state[result.address] = result.is_alive
35
+
36
+ if original_state != result.is_alive:
37
+ if original_state is False:
38
+ self._log.info("Target %s now online.", result.address)
39
+ else:
40
+ self._log.info("Target %s now offline.", result.address)
41
+
42
+ time.sleep(1)
43
+
44
+ def report_availablity(self, ip: str, available: bool) -> None:
45
+ self._ip_state[ip] = available
46
+
47
+ def is_available(self, ip: str) -> bool:
48
+ return self._ip_state.get(ip, False)
@@ -0,0 +1,175 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import time
5
+ from dataclasses import dataclass
6
+ from logging import Logger
7
+ from typing import Any, Literal, override
8
+
9
+ import wakeonlan
10
+
11
+ from wolsocketproxy.monitor import Monitor
12
+
13
+ WOL_MAX_WAIT_COUNT = 60
14
+
15
+
16
+ @dataclass
17
+ class ProxyRoute:
18
+ local_address: str
19
+ local_port: int
20
+ target_address: str
21
+ target_port: int
22
+ protocol: Literal["tcp", "udp"]
23
+
24
+
25
+ @dataclass
26
+ class ProxyConfig:
27
+ routes: list[ProxyRoute]
28
+ mac_mappings: dict[str, str]
29
+
30
+
31
+ class ProxyUdpProtocol(asyncio.DatagramProtocol):
32
+ _proxy: "Proxy"
33
+ _monitor: Monitor
34
+ _transport: asyncio.transports.DatagramTransport
35
+ _target_address: str
36
+ _target_port: int
37
+ _target_pair: tuple[str, int]
38
+
39
+ def __init__(self, proxy: "Proxy", monitor: Monitor, target_address: str, target_port: int) -> None:
40
+ self._proxy = proxy
41
+ self._monitor = monitor
42
+ self._target_address = target_address
43
+ self._target_port = target_port
44
+ self._target_pair = (target_address, target_port)
45
+
46
+ @override
47
+ def connection_made(self, transport: asyncio.transports.DatagramTransport) -> None: # type: ignore[override]
48
+ self._transport = transport
49
+
50
+ @override
51
+ def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None:
52
+ if addr == self._target_address:
53
+ return
54
+
55
+ if not self._monitor.is_available(self._target_address):
56
+ self._proxy._wake_up_target(self._target_address) # noqa: SLF001
57
+
58
+ self._transport.sendto(data, self._target_pair)
59
+
60
+
61
+ class Proxy:
62
+ _log: Logger = logging.getLogger()
63
+ _config: ProxyConfig
64
+ _monitor: Monitor
65
+ _routes: list[ProxyRoute]
66
+ _mac_mappings: dict[str, str]
67
+
68
+ def __init__(self, config: ProxyConfig) -> None:
69
+ self._config = config
70
+ self._routes = config.routes
71
+ self._mac_mappings = config.mac_mappings
72
+
73
+ target_ip_set = set()
74
+
75
+ for r in self._routes:
76
+ target_ip_set.add(r.target_address)
77
+
78
+ self._monitor = Monitor(watching_ip_list=target_ip_set)
79
+
80
+ def start(self) -> None:
81
+ self._monitor.start()
82
+
83
+ for route in self._routes:
84
+ self.__create_route(route)
85
+
86
+ loop = asyncio.get_event_loop()
87
+
88
+ self._log.info("Proxy server started.")
89
+
90
+ with contextlib.suppress(KeyboardInterrupt):
91
+ loop.run_forever()
92
+
93
+ self._log.info("Proxy server is stopping...")
94
+
95
+ loop.close()
96
+
97
+ def __create_route(self, route: ProxyRoute) -> None:
98
+ if route.protocol == "tcp":
99
+ self.__create_tcp_route(route)
100
+ elif route.protocol == "udp":
101
+ self.__create_udp_route(route)
102
+ else:
103
+ raise ValueError(
104
+ f"Unsupported protocol {route.protocol} in route from {route.local_address}:{route.local_port} to "
105
+ f"{route.target_address}:{route.target_port}"
106
+ )
107
+
108
+ def __create_tcp_route(self, route: ProxyRoute) -> None:
109
+ cr = asyncio.start_server(
110
+ self.__make_tcp_route_handler(route.target_address, route.target_port),
111
+ route.local_address,
112
+ route.local_port,
113
+ )
114
+
115
+ loop = asyncio.get_event_loop()
116
+ loop.run_until_complete(cr)
117
+
118
+ self._log.info(
119
+ f"Created {route.protocol} proxy from {route.local_address}:{route.local_port} to "
120
+ f"{route.target_address}:{route.target_port}"
121
+ )
122
+
123
+ async def __pipe(self, target_address: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
124
+ try:
125
+ while not reader.at_eof():
126
+ writer.write(await reader.read(2048))
127
+ except ConnectionResetError:
128
+ self._log.warning("Connection reset by target %s", target_address)
129
+ finally:
130
+ writer.close()
131
+
132
+ def __make_tcp_route_handler(self, target_address: str, target_port: int) -> Any: # noqa: ANN401
133
+ async def handler(local_reader: asyncio.StreamReader, local_writer: asyncio.StreamWriter) -> None:
134
+ if not self._monitor.is_available(target_address):
135
+ self._wake_up_target(target_address)
136
+
137
+ try:
138
+ target_reader, target_writer = await asyncio.open_connection(target_address, target_port)
139
+ except OSError as e:
140
+ self._monitor.report_availablity(target_address, False)
141
+ self._log.error("Unable to open connection to %s:%d", target_address, target_port)
142
+ raise e
143
+
144
+ send_pipe = self.__pipe(target_address, local_reader, target_writer)
145
+ recv_pipe = self.__pipe(target_address, target_reader, local_writer)
146
+ await asyncio.gather(send_pipe, recv_pipe)
147
+
148
+ return handler
149
+
150
+ def __create_udp_route(self, route: ProxyRoute) -> None:
151
+ loop = asyncio.get_event_loop()
152
+
153
+ cr = loop.create_datagram_endpoint(
154
+ lambda: ProxyUdpProtocol(self, self._monitor, route.target_address, route.target_port),
155
+ local_addr=(route.local_address, route.local_port),
156
+ )
157
+
158
+ loop.run_until_complete(cr)
159
+
160
+ def _wake_up_target(self, target_address: str) -> None:
161
+ target_mac_addr = self._mac_mappings[target_address]
162
+
163
+ self._log.info("Waking up target %s at %s", target_address, target_mac_addr)
164
+
165
+ wakeonlan.send_magic_packet(target_mac_addr)
166
+
167
+ count = 0
168
+
169
+ while not self._monitor.is_available(target_address):
170
+ count = count + 1
171
+ time.sleep(1)
172
+
173
+ if count > WOL_MAX_WAIT_COUNT:
174
+ self._log.warning("Target %s still not online after %d seconds!", target_address, count)
175
+ return