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.
- py_a1_router-0.1.0/.github/workflows/publish.yml +35 -0
- py_a1_router-0.1.0/.gitignore +6 -0
- py_a1_router-0.1.0/PKG-INFO +6 -0
- py_a1_router-0.1.0/README.md +3 -0
- py_a1_router-0.1.0/pyproject.toml +17 -0
- py_a1_router-0.1.0/setup.cfg +4 -0
- py_a1_router-0.1.0/src/a1_router/__init__.py +3 -0
- py_a1_router-0.1.0/src/a1_router/core/base.py +8 -0
- py_a1_router-0.1.0/src/a1_router/core/crypto.py +31 -0
- py_a1_router-0.1.0/src/a1_router/nat/__init__.py +3 -0
- py_a1_router-0.1.0/src/a1_router/nat/manager.py +13 -0
- py_a1_router-0.1.0/src/a1_router/nat/port_forwarding.py +83 -0
- py_a1_router-0.1.0/src/a1_router/network/__init__.py +3 -0
- py_a1_router-0.1.0/src/a1_router/network/manager.py +10 -0
- py_a1_router-0.1.0/src/a1_router/network/wan.py +5 -0
- py_a1_router-0.1.0/src/a1_router/router.py +88 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/PKG-INFO +6 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/SOURCES.txt +21 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/dependency_links.txt +1 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/requires.txt +2 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/scm_file_list.json +18 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/scm_version.json +8 -0
- py_a1_router-0.1.0/src/py_a1_router.egg-info/top_level.txt +1 -0
|
@@ -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,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,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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
a1_router
|