Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ config:
description: |
Configures the aproxy snap channel to install.
type: string
aproxy-port:
description: |
Configures the port number that the aproxy listens on.
default: 8443
type: int
proxy-address:
description: |
Configures the target proxy IP/hostname address and port for traffic forwarding.
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Each revision is versioned by the date of the revision.

Place any unreleased changes here, that are subject to release in coming versions :).

# 2026-05-19
- Add `aproxy-port` charm configuration to allow customizing the port that aproxy listens on.

## 2026-03-05

- Add landing pages for how-to and reference section.
Expand Down
34 changes: 26 additions & 8 deletions src/aproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
NFT_CONF_DIR = Path("/opt/aproxy-charm")
NFT_CONF_FILE = NFT_CONF_DIR / "nftables.conf"
SYSTEMD_UNIT_PATH = Path("/etc/systemd/system/aproxy-nftables.service")
APROXY_LISTEN_PORT = 8443
DEFAULT_APROXY_PORT = 8443
APROXY_SNAP_NAME = "aproxy"
DEFAULT_PROXY_PORT = 80
RELATION_NAME = "juju-info"
Expand Down Expand Up @@ -70,6 +70,7 @@ class AproxyConfig(BaseModel):
model_config: Pydantic config to forbid extra fields.
proxy_address: The target proxy address (hostname or IP).
proxy_port: The target proxy port.
aproxy_port: The port number that aproxy listens on.
exclude_addresses: Comma-separated list of IPs, CIDRs, or hostnames to
exclude from interception.
intercept_ports_list: List of ports to intercept as strings.
Expand All @@ -80,6 +81,7 @@ class AproxyConfig(BaseModel):
channel: str
proxy_address: str
proxy_port: int = DEFAULT_PROXY_PORT
aproxy_port: int = DEFAULT_APROXY_PORT
exclude_addresses: List[str] = []
intercept_ports_list: List[str]

Expand Down Expand Up @@ -132,6 +134,8 @@ def from_charm(cls, charm: ops.CharmBase) -> AproxyConfig:
intercept_ports_raw = str(conf.get("intercept-ports", ""))
intercept_ports_list = intercept_ports_raw.split(",") if intercept_ports_raw else []

aproxy_port = int(conf.get("aproxy-port", DEFAULT_APROXY_PORT))

channel = charm.config.get("channel")
if channel not in ("latest/edge", "latest/beta", "latest/candidate", "latest/stable"):
raise InvalidCharmConfigError(f"unknown channel configuration: '{channel}'")
Expand All @@ -141,6 +145,7 @@ def from_charm(cls, charm: ops.CharmBase) -> AproxyConfig:
channel=str(channel),
proxy_address=proxy_address,
proxy_port=proxy_port,
aproxy_port=aproxy_port,
exclude_addresses=exclude_addresses,
intercept_ports_list=intercept_ports_list,
)
Expand All @@ -167,6 +172,13 @@ def _validate_proxy_port(cls, proxy_port: int) -> int: # noqa: N805
raise ValueError(f"proxy port must be between 1 and 65535 instead of {proxy_port}")
return proxy_port

@field_validator("aproxy_port")
def _validate_aproxy_port(cls, aproxy_port: int) -> int: # noqa: N805
"""Validate that aproxy_port is a valid port number."""
if not 0 < aproxy_port < 65536:
raise ValueError(f"aproxy port must be between 1 and 65535 instead of {aproxy_port}")
return aproxy_port

@field_validator("exclude_addresses")
def _validate_exclude_addresses(cls, exclude_addresses: List[str]) -> List[str]: # noqa: N805
"""Validate exclude_addresses entries are valid IPs or CIDRs."""
Expand Down Expand Up @@ -310,8 +322,13 @@ def configure_target_proxy(self) -> None:
current_proxy = aproxy_snap.get("proxy-address")
except snap.SnapError:
current_proxy = ""
try:
current_listen = aproxy_snap.get("listen")
except snap.SnapError:
current_listen = ""
target_proxy = f"{self.config.proxy_address}:{self.config.proxy_port}"
if current_proxy == target_proxy:
target_listen = f":{self.config.aproxy_port}"
if current_proxy == target_proxy and current_listen == target_listen:
logger.info("Proxy is already set to %s, skipping reconfiguration", target_proxy)
return

Expand All @@ -320,8 +337,8 @@ def configure_target_proxy(self) -> None:
logger.error("Proxy is not reachable at %s", target_proxy)
raise ConnectionError(f"Proxy is not reachable at {target_proxy}")

logger.info("Configuring snap: proxy=%s", target_proxy)
aproxy_snap.set({"proxy": target_proxy})
logger.info("Configuring snap: proxy=%s, listen=%s", target_proxy, target_listen)
aproxy_snap.set({"proxy": target_proxy, "listen": target_listen})

def _is_proxy_reachable(self, host: str, port: int = DEFAULT_PROXY_PORT) -> bool:
"""Check if the target proxy is reachable on the specified port.
Expand Down Expand Up @@ -378,6 +395,7 @@ def _render_nft_rules(self) -> str:
"""
server_ip = self._get_primary_ip()
ports_clause = ", ".join(self.config.intercept_ports_list)
listen_port = self.config.aproxy_port
excluded_ips = ", ".join(
[
"127.0.0.0/8", # private loopback range
Expand All @@ -398,21 +416,21 @@ def _render_nft_rules(self) -> str:
type nat hook prerouting priority dstnat; policy accept;
fib daddr type local return
ip daddr @excluded_nets return
tcp dport {{ {ports_clause} }} counter dnat to {server_ip}:{APROXY_LISTEN_PORT}
tcp dport {{ {ports_clause} }} counter dnat to {server_ip}:{listen_port}
}}

chain output {{
type nat hook output priority -150; policy accept;
fib daddr type local return
ip daddr @excluded_nets return
tcp dport {{ {ports_clause} }} counter dnat to {server_ip}:{APROXY_LISTEN_PORT}
tcp dport {{ {ports_clause} }} counter dnat to {server_ip}:{listen_port}
}}

chain input {{
type filter hook input priority filter; policy accept;
iif "lo" accept
ip saddr {server_ip} tcp dport {APROXY_LISTEN_PORT} accept
tcp dport {APROXY_LISTEN_PORT} drop
ip saddr {server_ip} tcp dport {listen_port} accept
tcp dport {listen_port} drop
}}
}}
"""
Expand Down
22 changes: 21 additions & 1 deletion tests/unit/test_aproxy_charm_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from ops import testing
from scenario.errors import UncaughtCharmError

from aproxy import NFT_CONF_FILE
from aproxy import NFT_CONF_FILE, AproxyConfig, AproxyManager
from charm import AproxyCharm


Expand Down Expand Up @@ -311,3 +311,23 @@ def test_install_with_juju_model_config_should_succeed(patch_proxy_check, monkey
out = ctx.run(ctx.on.install(), state)

assert out.unit_status == testing.ActiveStatus("Service ready on target proxy juju.proxy:3128")


def test_install_with_custom_aproxy_port_nft_rules(patch_proxy_check):
"""
arrange: create an AproxyConfig with a custom aproxy-port of 9443.
act: render the nftables rules via AproxyManager._render_nft_rules.
assert: the generated nftables rules contain the custom port number.
"""
config = AproxyConfig(
channel="latest/stable",
proxy_address="target.proxy",
proxy_port=80,
aproxy_port=9443,
intercept_ports_list=["80", "443"],
)
manager = AproxyManager(config, None)
manager._get_primary_ip = lambda: "127.0.0.1"
nft_rules = manager._render_nft_rules()

assert "9443" in nft_rules
Loading