py-a1-router 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,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ name: Upload release to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/project/py-a1-router
14
+ permissions:
15
+ id-token: write
16
+ contents: read
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ with:
20
+ fetch-tags: true
21
+ fetch-depth: 0
22
+
23
+ - name: Set up Python
24
+ uses: actions/setup-python@v7
25
+ with:
26
+ python-version: "3.x"
27
+
28
+ - name: Install build
29
+ run: pip install build
30
+
31
+ - name: Build package
32
+ run: python -m build
33
+
34
+ - name: Publish to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ *.egg-info/
2
+ dist/
3
+ build/
4
+ __pycache__/
5
+ .venv/
6
+ env/
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-a1-router
3
+ Version: 0.1.0
4
+ Summary: A configuration wrapper for A1 routers
5
+ Requires-Dist: requests
6
+ Requires-Dist: pycryptodome
@@ -0,0 +1,3 @@
1
+ # A1 Router Wrapper Python Package
2
+
3
+ documentation coming soon...
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 84.0.0", "setuptools_scm >= 10.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "py-a1-router"
7
+ dynamic = ["version"]
8
+ description = "A configuration wrapper for A1 routers"
9
+ dependencies = [
10
+ "requests",
11
+ "pycryptodome"
12
+ ]
13
+
14
+ [tool.setuptools.packages.find]
15
+ where = ["src"]
16
+
17
+ [tool.setuptools_scm]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .router import Router
2
+
3
+ __all__ = ["Router"]
@@ -0,0 +1,8 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from ..router import Router
5
+
6
+ class BaseModule:
7
+ def __init__(self, router: "Router"):
8
+ self._router = router
@@ -0,0 +1,31 @@
1
+ import os, json, base64
2
+ from Crypto.Cipher import AES, PKCS1_v1_5
3
+ from Crypto.Util.Padding import pad, unpad
4
+
5
+ def encrypt_payload(payload: dict, aes_key_bytes: bytes, b64_aes_key: str, rsa_key) -> dict:
6
+ iv_bytes = os.urandom(16)
7
+ b64_iv = base64.b64encode(iv_bytes).decode("utf-8")
8
+ plaintext = json.dumps(payload).encode("utf-8")
9
+
10
+ cipher_aes = AES.new(aes_key_bytes, AES.MODE_CBC, iv_bytes)
11
+ ciphertext_bytes = cipher_aes.encrypt(pad(plaintext, AES.block_size))
12
+ b64_content = base64.b64encode(ciphertext_bytes).decode("utf-8")
13
+
14
+ cipher_rsa = PKCS1_v1_5.new(rsa_key)
15
+ enc_aes_key = cipher_rsa.encrypt(b64_aes_key.encode("utf-8"))
16
+ b64_rsa_key = base64.b64encode(enc_aes_key).decode("utf-8")
17
+
18
+ return {
19
+ "content": b64_content,
20
+ "key": b64_rsa_key,
21
+ "iv": b64_iv
22
+ }
23
+
24
+ def decrypt_response(json_response: dict, aes_key_bytes: bytes) -> dict:
25
+ iv_bytes = base64.b64decode(json_response["iv"])[:16]
26
+ ciphertext_bytes = base64.b64decode(json_response["content"])
27
+
28
+ cipher = AES.new(aes_key_bytes, AES.MODE_CBC, iv_bytes)
29
+ plaintext_bytes = unpad(cipher.decrypt(ciphertext_bytes), AES.block_size)
30
+
31
+ return json.loads(plaintext_bytes.decode("utf-8"))
@@ -0,0 +1,3 @@
1
+ from .manager import NATManager
2
+
3
+ __all__ = ["NATManager"]
@@ -0,0 +1,13 @@
1
+ from typing import TYPE_CHECKING
2
+ from .port_forwarding import PortForwarding
3
+
4
+ if TYPE_CHECKING:
5
+ from ..router import Router
6
+
7
+ class NATManager:
8
+ def __init__(self, router: "Router"):
9
+ self._router = router
10
+ self.port_forwarding = PortForwarding(router)
11
+ # Future domain additions:
12
+ # self.dmz = DMZ(router)
13
+ # self.triggering = PortTriggering(router)
@@ -0,0 +1,83 @@
1
+ from typing import Literal
2
+
3
+ from ..core.base import BaseModule
4
+
5
+ class PortForwarding(BaseModule):
6
+ def get_all(self):
7
+ return self._router._encrypted_request("GET", "/cgi-bin/DAL?oid=nat")
8
+
9
+ def get_one(self, index: int):
10
+ try:
11
+ index = int(index)
12
+ except (ValueError, TypeError):
13
+ raise ValueError("[nat.port_forwarding.get_one] Index must be int")
14
+ return self._router._encrypted_request("GET", f"/cgi-bin/DAL?oid=nat&DalGetOneObject=y&Index={index}")
15
+
16
+ def add(
17
+ self,
18
+ active: bool,
19
+ name: str,
20
+ external_port_start: int,
21
+ external_port_end: int,
22
+ internal_port_start: int,
23
+ internal_port_end: int,
24
+ internal_client_ip: str,
25
+ protocol: Literal["TCP", "UDP", "ALL"] = "TCP",
26
+ originating_ip: str = ""
27
+ ):
28
+ wan_ip = self._router.network.wan.get_ip()
29
+ rule = {
30
+ "Index": 0,
31
+ "referenceIndex": -1,
32
+ "Enable": active,
33
+ "Protocol": protocol,
34
+ "Description": name,
35
+ "Interface": "",
36
+ "ExternalPortStart": external_port_start,
37
+ "ExternalPortEnd": external_port_end,
38
+ "InternalPortStart": internal_port_start,
39
+ "InternalPortEnd": internal_port_end,
40
+ "InternalClient": internal_client_ip,
41
+ "SetOriginatingIP": bool(originating_ip),
42
+ "OriginatingIpAddress": originating_ip,
43
+ "WANIP": wan_ip,
44
+ "X_ZYXEL_AutoDetectWanStatus": True
45
+ }
46
+ return self._router._encrypted_request("POST", "/cgi-bin/DAL?oid=nat", rule)
47
+
48
+ def delete(self, index: int):
49
+ try:
50
+ index = int(index)
51
+ except (ValueError, TypeError):
52
+ raise ValueError("[nat.port_forwarding.delete] Index must be int")
53
+ return self._router._encrypted_request("DELETE", f"/cgi-bin/DAL?oid=nat&Index={index}")
54
+
55
+ def edit(
56
+ self,
57
+ index: int,
58
+ active: bool,
59
+ name: str,
60
+ external_port_start: int,
61
+ external_port_end: int,
62
+ internal_port_start: int,
63
+ internal_port_end: int,
64
+ internal_client_ip: str,
65
+ protocol: Literal["TCP", "UDP", "ALL"] = "TCP",
66
+ originating_ip: str = ""
67
+ ):
68
+ rule = {
69
+ "Index": index,
70
+ "Enable": active,
71
+ "Protocol": protocol,
72
+ "Description": name,
73
+ "Interface": "",
74
+ "ExternalPortStart": external_port_start,
75
+ "ExternalPortEnd": external_port_end,
76
+ "InternalPortStart": internal_port_start,
77
+ "InternalPortEnd": internal_port_end,
78
+ "InternalClient": internal_client_ip,
79
+ "SetOriginatingIP": bool(originating_ip),
80
+ "OriginatingIpAddress": originating_ip,
81
+ "X_ZYXEL_AutoDetectWanStatus": True
82
+ }
83
+ return self._router._encrypted_request("PUT", "/cgi-bin/DAL?oid=nat", rule)
@@ -0,0 +1,3 @@
1
+ from .manager import NetworkManager
2
+
3
+ __all__ = ["NetworkManager"]
@@ -0,0 +1,10 @@
1
+ from typing import TYPE_CHECKING
2
+ from .wan import WAN
3
+
4
+ if TYPE_CHECKING:
5
+ from ..router import Router
6
+
7
+ class NetworkManager:
8
+ def __init__(self, router: "Router"):
9
+ self._router = router
10
+ self.wan = WAN(router)
@@ -0,0 +1,5 @@
1
+ from ..core.base import BaseModule
2
+
3
+ class WAN(BaseModule):
4
+ def get_ip(self) -> str:
5
+ return self._router._encrypted_request("GET", "/cgi-bin/getDefaultWANIP")
@@ -0,0 +1,88 @@
1
+ import os, json, base64, requests
2
+ from Crypto.PublicKey import RSA
3
+ from typing import Literal
4
+
5
+ from .core import crypto
6
+ from .network import NetworkManager
7
+ from .nat import NATManager
8
+
9
+ class Router:
10
+ def __init__(self, ip: str, username: str, password: str, debug=False):
11
+ self.ip = ip
12
+ self.username = username
13
+ self.password = password
14
+ self.debug = debug
15
+
16
+ self.session = requests.Session()
17
+ self.rsa_key = None
18
+ self.aes_key_bytes = None
19
+ self.b64_aes_key = None
20
+
21
+ self.network = NetworkManager(self)
22
+ self.nat = NATManager(self)
23
+
24
+ def _log(self, msg):
25
+ if self.debug:
26
+ print(msg)
27
+
28
+ def login(self):
29
+ self._log("[*] Fetching RSAPublicKey...")
30
+ response = self.session.get(f"http://{self.ip}/getRSAPublickKey")
31
+ response.raise_for_status()
32
+
33
+ rsa_pub_pem = response.json().get("RSAPublicKey", "")
34
+
35
+ if not rsa_pub_pem:
36
+ raise ConnectionError(f"[status_code {response.status_code}] Unable to fetch RSAPublicKey.")
37
+
38
+ self.rsa_key = RSA.import_key(rsa_pub_pem)
39
+ self.aes_key_bytes = os.urandom(32)
40
+ self.b64_aes_key = base64.b64encode(self.aes_key_bytes).decode("utf-8")
41
+
42
+ encoded_password = base64.b64encode(self.password.encode("utf-8")).decode("utf-8")
43
+ login_payload = {
44
+ "Input_Account": self.username,
45
+ "Input_Passwd": encoded_password,
46
+ "RememberPassword": 0,
47
+ "SHA512_password": False
48
+ }
49
+
50
+ response = self._encrypted_request("POST", "/UserLogin", login_payload)
51
+
52
+ session_key = response.get("sessionkey")
53
+
54
+ if not session_key:
55
+ raise PermissionError("Login failed: No session key returned.")
56
+
57
+ self.session.headers.update({
58
+ "CSRFToken": session_key,
59
+ "X-Requested-With": "XMLHttpRequest",
60
+ "Content-Type": "application/json"
61
+ })
62
+
63
+ self._log(f"\n[*] Cookies: {self.session.cookies.get_dict()}")
64
+ self._log("[+] Login successful.")
65
+
66
+ def _encrypt_payload(self, payload: dict):
67
+ return crypto.encrypt_payload(payload, self.aes_key_bytes, self.b64_aes_key, self.rsa_key)
68
+
69
+ def _decrypt_response(self, json_response: dict):
70
+ return crypto.decrypt_response(json_response, self.aes_key_bytes)
71
+
72
+ def _encrypted_request(self, method: Literal["GET", "POST", "PUT", "DELETE"], location: str, payload: dict = None):
73
+ url = f"http://{self.ip}{location}"
74
+
75
+ encrypted_payload = self._encrypt_payload(payload) if payload is not None else None
76
+
77
+ response = self.session.request(method, url, json=encrypted_payload)
78
+ response.raise_for_status()
79
+
80
+ decrypted_response = self._decrypt_response(response.json())
81
+
82
+ if decrypted_response.get("result") != "ZCFG_SUCCESS":
83
+ raise RuntimeError(f"Router rejected request: {decrypted_response}")
84
+
85
+ self._log(f"\n[*] {method} {location}")
86
+ self._log(json.dumps(decrypted_response, indent=4))
87
+
88
+ return decrypted_response
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-a1-router
3
+ Version: 0.1.0
4
+ Summary: A configuration wrapper for A1 routers
5
+ Requires-Dist: requests
6
+ Requires-Dist: pycryptodome
@@ -0,0 +1,21 @@
1
+ .gitignore
2
+ README.md
3
+ pyproject.toml
4
+ .github/workflows/publish.yml
5
+ src/a1_router/__init__.py
6
+ src/a1_router/router.py
7
+ src/a1_router/core/base.py
8
+ src/a1_router/core/crypto.py
9
+ src/a1_router/nat/__init__.py
10
+ src/a1_router/nat/manager.py
11
+ src/a1_router/nat/port_forwarding.py
12
+ src/a1_router/network/__init__.py
13
+ src/a1_router/network/manager.py
14
+ src/a1_router/network/wan.py
15
+ src/py_a1_router.egg-info/PKG-INFO
16
+ src/py_a1_router.egg-info/SOURCES.txt
17
+ src/py_a1_router.egg-info/dependency_links.txt
18
+ src/py_a1_router.egg-info/requires.txt
19
+ src/py_a1_router.egg-info/scm_file_list.json
20
+ src/py_a1_router.egg-info/scm_version.json
21
+ src/py_a1_router.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ requests
2
+ pycryptodome
@@ -0,0 +1,18 @@
1
+ {
2
+ "files": [
3
+ ".github/workflows/publish.yml",
4
+ ".gitignore",
5
+ "README.md",
6
+ "pyproject.toml",
7
+ "src/a1_router/__init__.py",
8
+ "src/a1_router/core/base.py",
9
+ "src/a1_router/core/crypto.py",
10
+ "src/a1_router/nat/__init__.py",
11
+ "src/a1_router/nat/manager.py",
12
+ "src/a1_router/nat/port_forwarding.py",
13
+ "src/a1_router/network/__init__.py",
14
+ "src/a1_router/network/manager.py",
15
+ "src/a1_router/network/wan.py",
16
+ "src/a1_router/router.py"
17
+ ]
18
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.1.0",
3
+ "distance": 0,
4
+ "node": "gafcc989b64b69cb6c879777805224e7e7566b613",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-25"
8
+ }