Skip to content

Commit 93ce151

Browse files
address code reviews
1 parent 5933d7c commit 93ce151

4 files changed

Lines changed: 71 additions & 35 deletions

File tree

charmcraft.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2025 Canonical Ltd.
22
# See LICENSE file for licensing details.
33
# This file configures Charmcraft.
4-
# See https://canonical-charmcraft.readthedocs-hosted.com/stable/howto/manage-charmcraft/
4+
# See https://canonical-charmcraft.readthedocs-hosted.com/stable/howto/manage-charmcraft/
55
# for guidance.
66

77
type: charm
@@ -25,7 +25,7 @@ description: |
2525
2626
base: ubuntu@24.04
2727
build-base: ubuntu@24.04
28-
platforms:
28+
platforms:
2929
amd64:
3030
build-on: [amd64]
3131
build-for: [amd64]
@@ -48,7 +48,7 @@ config:
4848
4949
For example: "1.2.3.4:8888" or "1.2.3.4".
5050
If no proxy is specified, the default proxy is the principal charm's juju-https-proxy or juju-http-proxy relation data.
51-
If no port is specified, the default port value of 3128 will be used.
51+
If no port is specified, the default port value of 80 will be used.
5252
type: string
5353
exclude-addresses-from-proxy:
5454
description: Comma-separated list of IP or hostname addresses that should bypass the proxy.

src/aproxy.py

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@
3636
logger = logging.getLogger(__name__)
3737

3838
# Files and constants
39-
NFT_CONF_DIR = Path("/etc/aproxy")
39+
NFT_CONF_DIR = Path("/opt/aproxy-charm")
4040
NFT_CONF_FILE = NFT_CONF_DIR / "nftables.conf"
4141
SYSTEMD_UNIT_PATH = Path("/etc/systemd/system/aproxy-nftables.service")
4242
APROXY_LISTEN_PORT = 8443
4343
APROXY_SNAP_NAME = "aproxy"
4444
APROXY_SNAP_CHANNEL = "edge"
45-
DEFAULT_PROXY_PORT = 3128
45+
DEFAULT_PROXY_PORT = 80
4646
RELATION_NAME = "juju-info"
4747

4848
# ^\.? : one leading dot allowed
@@ -55,6 +55,12 @@
5555
r"^\.?(?!-)(?!.*--)(?!.*-$)([a-zA-Z0-9-]{1,63}\.)*([a-zA-Z0-9-]{1,63})\.?$"
5656
)
5757

58+
# ^ : start of the string
59+
# [a-zA-Z] : match letter
60+
# [a-zA-Z0-9+\-.]* : match letters, digits, +, -, ., zero or more times
61+
# :// : match literal "://"
62+
URI_SCHEME_PREFIX_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://")
63+
5864

5965
class AproxyConfig(BaseModel):
6066
"""Configuration model for aproxy charm.
@@ -63,7 +69,7 @@ class AproxyConfig(BaseModel):
6369
model_config: Pydantic config to forbid extra fields.
6470
proxy_address: The target proxy address (hostname or IP).
6571
proxy_port: The target proxy port.
66-
no_proxy: Comma-separated list of IPs, CIDRs, or hostnames to
72+
exclude_addresses: Comma-separated list of IPs, CIDRs, or hostnames to
6773
exclude from interception.
6874
intercept_ports_list: List of ports to intercept as strings.
6975
"""
@@ -72,7 +78,7 @@ class AproxyConfig(BaseModel):
7278

7379
proxy_address: str
7480
proxy_port: int = DEFAULT_PROXY_PORT
75-
no_proxy: List[str] = []
81+
exclude_addresses: List[str] = []
7682
intercept_ports_list: List[str]
7783

7884
@classmethod
@@ -106,10 +112,12 @@ def from_charm(cls, charm: ops.CharmBase) -> "AproxyConfig":
106112
) from exc
107113

108114
# Parse no proxy list
109-
no_proxy = []
110-
raw_no_proxy = str(conf.get("exclude-addresses-from-proxy", ""))
111-
if raw_no_proxy:
112-
no_proxy = [entry.strip() for entry in raw_no_proxy.split(",") if entry.strip()]
115+
exclude_addresses = []
116+
raw_exclude_addresses = str(conf.get("exclude-addresses-from-proxy", ""))
117+
if raw_exclude_addresses:
118+
exclude_addresses = [
119+
entry.strip() for entry in raw_exclude_addresses.split(",") if entry.strip()
120+
]
113121

114122
# Parse intercept ports
115123
intercept_ports_raw = str(conf.get("intercept-ports", ""))
@@ -120,7 +128,7 @@ def from_charm(cls, charm: ops.CharmBase) -> "AproxyConfig":
120128
return cls(
121129
proxy_address=proxy_address,
122130
proxy_port=proxy_port,
123-
no_proxy=no_proxy,
131+
exclude_addresses=exclude_addresses,
124132
intercept_ports_list=intercept_ports_list,
125133
)
126134
except ValidationError as exc:
@@ -146,25 +154,25 @@ def _validate_proxy_port(cls, proxy_port: int) -> int:
146154
raise ValueError(f"proxy port must be between 1 and 65535 instead of {proxy_port}")
147155
return proxy_port
148156

149-
@field_validator("no_proxy")
150-
def _validate_no_proxy(cls, no_proxy: List[str]) -> List[str]:
151-
"""Validate no_proxy entries are valid IPs, CIDRs, or hostnames."""
152-
valid_no_proxy = []
153-
for entry in no_proxy:
157+
@field_validator("exclude_addresses")
158+
def _validate_exclude_addresses(cls, exclude_addresses: List[str]) -> List[str]:
159+
"""Validate exclude_addresses entries are valid IPs, CIDRs, or hostnames."""
160+
valid_exclude_addresses = []
161+
for entry in exclude_addresses:
154162
entry = entry.strip()
155163
if not entry:
156164
continue
157165
try:
158166
# Check if it's a valid IP or CIDR
159167
ipaddress.ip_network(entry, strict=False)
160-
valid_no_proxy.append(entry)
168+
valid_exclude_addresses.append(entry)
161169
except ValueError as exc:
162170
# If not an IP/CIDR, check if it's a valid hostname
163171
if HOSTNAME_PATTERN.match(entry):
164-
valid_no_proxy.append(entry)
172+
valid_exclude_addresses.append(entry)
165173
else:
166-
raise ValueError(f"invalid no_proxy entry {entry}") from exc
167-
return valid_no_proxy
174+
raise ValueError(f"invalid exclude_addresses entry {entry}") from exc
175+
return valid_exclude_addresses
168176

169177
@field_validator("intercept_ports_list")
170178
def _validate_and_merge_ports(cls, ports: List[str]) -> List[str]:
@@ -228,8 +236,8 @@ def _get_principal_proxy_address(cls) -> str:
228236

229237
proxy_conf = https_proxy or http_proxy or ""
230238

231-
# Strip any leading http:// or https://
232-
return re.sub(r"^https?://", "", proxy_conf)
239+
# Strip prefix like http://, https://, socks5h://, ftp://, etc.
240+
return URI_SCHEME_PREFIX_RE.sub("", proxy_conf)
233241

234242

235243
class AproxyManager:
@@ -276,14 +284,23 @@ def configure_target_proxy(self) -> None:
276284
Raises:
277285
ConnectionError: If the target proxy is not reachable.
278286
"""
287+
snap_cache = snap.SnapCache()
288+
aproxy_snap = snap_cache[APROXY_SNAP_NAME]
289+
290+
# Check if current config is the same as the target config
291+
current_proxy = aproxy_snap.get("proxy")
279292
target_proxy = f"{self.config.proxy_address}:{self.config.proxy_port}"
293+
if current_proxy == target_proxy:
294+
logger.info("Proxy is already set to %s, skipping reconfiguration", target_proxy)
295+
return
296+
297+
# Check if target proxy is reachable
280298
if not self._is_proxy_reachable(self.config.proxy_address, self.config.proxy_port):
281299
logger.error("Proxy is not reachable at %s", target_proxy)
282300
raise ConnectionError(f"Proxy is not reachable at {target_proxy}")
283301

284302
logger.info("Configuring snap: proxy=%s", target_proxy)
285-
snap_cache = snap.SnapCache()
286-
snap_cache[APROXY_SNAP_NAME].set({"proxy": target_proxy})
303+
aproxy_snap.set({"proxy": target_proxy})
287304

288305
def _is_proxy_reachable(self, host: str, port: int = DEFAULT_PROXY_PORT) -> bool:
289306
"""Check if the target proxy is reachable on the specified port.
@@ -342,7 +359,7 @@ def _render_nft_rules(self) -> str:
342359
[
343360
"127.0.0.0/8", # private loopback range
344361
]
345-
+ self.config.no_proxy
362+
+ self.config.exclude_addresses
346363
)
347364

348365
return f"""#!/usr/sbin/nft -f

tests/unit/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,29 @@ def set(self, config: dict):
4545
args = [f"{k}={v}" for k, v in config.items()]
4646
subprocess.run(["snap", "set", "aproxy"] + args, check=True) # nosec
4747

48+
def get(self, key: str, default=""):
49+
"""Simulate getting snap configuration using subprocess.
50+
51+
Args:
52+
key: Configuration key to retrieve.
53+
default: Default value if key is not set.
54+
55+
Returns:
56+
The value of the configuration key or default if not set.
57+
"""
58+
if not self.present:
59+
return default
60+
61+
result = subprocess.run(
62+
["snap", "get", "aproxy", key],
63+
check=False,
64+
capture_output=True,
65+
text=True,
66+
) # nosec
67+
68+
value = result.stdout.strip() if result.stdout else default
69+
return value if value else default
70+
4871

4972
@pytest.fixture(autouse=True)
5073
def fake_snap(monkeypatch):

tests/unit/test_aproxy_charm_unit.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,7 @@ def test_install_with_proxy_config_should_succeed(patch_proxy_check):
2727

2828
out = ctx.run(ctx.on.install(), state)
2929

30-
assert out.unit_status == testing.ActiveStatus(
31-
"Service ready on target proxy target.proxy:3128"
32-
)
30+
assert out.unit_status == testing.ActiveStatus("Service ready on target proxy target.proxy:80")
3331

3432

3533
def test_install_without_proxy_config_should_fail(patch_proxy_check):
@@ -78,9 +76,7 @@ def test_start_proxy_reachable_should_succeed(patch_proxy_check):
7876

7977
out = ctx.run(ctx.on.start(), state)
8078

81-
assert out.unit_status == testing.ActiveStatus(
82-
"Service ready on target proxy target.proxy:3128"
83-
)
79+
assert out.unit_status == testing.ActiveStatus("Service ready on target proxy target.proxy:80")
8480

8581

8682
def test_start_proxy_unreachable_should_fail(patch_proxy_check):
@@ -96,7 +92,7 @@ def test_start_proxy_unreachable_should_fail(patch_proxy_check):
9692
out = ctx.run(ctx.on.start(), state)
9793

9894
assert out.unit_status == testing.BlockedStatus(
99-
"Failed to configure aproxy: Proxy is not reachable at target.proxy:3128"
95+
"Failed to configure aproxy: Proxy is not reachable at target.proxy:80"
10096
)
10197

10298

@@ -148,7 +144,7 @@ def test_config_changed_should_succeed(patch_proxy_check):
148144
out = ctx.run(ctx.on.config_changed(), state)
149145

150146
assert out.unit_status == testing.ActiveStatus(
151-
"Service ready on target proxy modified.proxy:3128"
147+
"Service ready on target proxy modified.proxy:80"
152148
)
153149

154150

@@ -182,7 +178,7 @@ def test_config_changed_with_unreachable_proxy_should_fail(patch_proxy_check):
182178
out = ctx.run(ctx.on.config_changed(), state)
183179

184180
assert out.unit_status == testing.BlockedStatus(
185-
"Failed to configure aproxy: Proxy is not reachable at modified.proxy:3128"
181+
"Failed to configure aproxy: Proxy is not reachable at modified.proxy:80"
186182
)
187183

188184

0 commit comments

Comments
 (0)