charmarr-lib-vpn 0.2.2__py3-none-any.whl
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.
- charmarr_lib/vpn/__init__.py +50 -0
- charmarr_lib/vpn/_k8s/__init__.py +53 -0
- charmarr_lib/vpn/_k8s/_gateway.py +189 -0
- charmarr_lib/vpn/_k8s/_gateway_client.py +175 -0
- charmarr_lib/vpn/_k8s/_kill_switch.py +212 -0
- charmarr_lib/vpn/_version.py +3 -0
- charmarr_lib/vpn/constants.py +22 -0
- charmarr_lib/vpn/interfaces/__init__.py +20 -0
- charmarr_lib/vpn/interfaces/_vpn_gateway.py +148 -0
- charmarr_lib_vpn-0.2.2.dist-info/METADATA +139 -0
- charmarr_lib_vpn-0.2.2.dist-info/RECORD +12 -0
- charmarr_lib_vpn-0.2.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""VPN gateway charm library for Kubernetes."""
|
|
5
|
+
|
|
6
|
+
from charmarr_lib.vpn._k8s import (
|
|
7
|
+
KillSwitchConfig,
|
|
8
|
+
build_gateway_client_configmap_data,
|
|
9
|
+
build_gateway_client_patch,
|
|
10
|
+
build_gateway_patch,
|
|
11
|
+
is_gateway_client_patched,
|
|
12
|
+
is_gateway_patched,
|
|
13
|
+
reconcile_gateway,
|
|
14
|
+
reconcile_gateway_client,
|
|
15
|
+
reconcile_kill_switch,
|
|
16
|
+
)
|
|
17
|
+
from charmarr_lib.vpn.constants import (
|
|
18
|
+
CLIENT_INIT_CONTAINER_NAME,
|
|
19
|
+
CLIENT_SIDECAR_CONTAINER_NAME,
|
|
20
|
+
DEFAULT_VPN_BLOCK_OTHER_TRAFFIC,
|
|
21
|
+
DEFAULT_VPN_INTERFACE,
|
|
22
|
+
DEFAULT_VXLAN_GATEWAY_FIRST_DYNAMIC_IP,
|
|
23
|
+
DEFAULT_VXLAN_ID,
|
|
24
|
+
DEFAULT_VXLAN_IP_NETWORK,
|
|
25
|
+
GATEWAY_INIT_CONTAINER_NAME,
|
|
26
|
+
GATEWAY_SIDECAR_CONTAINER_NAME,
|
|
27
|
+
POD_GATEWAY_IMAGE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CLIENT_INIT_CONTAINER_NAME",
|
|
32
|
+
"CLIENT_SIDECAR_CONTAINER_NAME",
|
|
33
|
+
"DEFAULT_VPN_BLOCK_OTHER_TRAFFIC",
|
|
34
|
+
"DEFAULT_VPN_INTERFACE",
|
|
35
|
+
"DEFAULT_VXLAN_GATEWAY_FIRST_DYNAMIC_IP",
|
|
36
|
+
"DEFAULT_VXLAN_ID",
|
|
37
|
+
"DEFAULT_VXLAN_IP_NETWORK",
|
|
38
|
+
"GATEWAY_INIT_CONTAINER_NAME",
|
|
39
|
+
"GATEWAY_SIDECAR_CONTAINER_NAME",
|
|
40
|
+
"POD_GATEWAY_IMAGE",
|
|
41
|
+
"KillSwitchConfig",
|
|
42
|
+
"build_gateway_client_configmap_data",
|
|
43
|
+
"build_gateway_client_patch",
|
|
44
|
+
"build_gateway_patch",
|
|
45
|
+
"is_gateway_client_patched",
|
|
46
|
+
"is_gateway_patched",
|
|
47
|
+
"reconcile_gateway",
|
|
48
|
+
"reconcile_gateway_client",
|
|
49
|
+
"reconcile_kill_switch",
|
|
50
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""StatefulSet patching and NetworkPolicy for pod-gateway VPN routing.
|
|
5
|
+
|
|
6
|
+
This module provides functions to patch Kubernetes StatefulSets with
|
|
7
|
+
pod-gateway containers for VXLAN overlay VPN routing, plus NetworkPolicy
|
|
8
|
+
kill switch for traffic protection.
|
|
9
|
+
|
|
10
|
+
Gateway-side patching (for gluetun-k8s):
|
|
11
|
+
- gateway-init: Creates VXLAN tunnel, sets up iptables forwarding
|
|
12
|
+
- gateway-sidecar: Runs DHCP and DNS server for client pods
|
|
13
|
+
|
|
14
|
+
Gateway client patching (for qbittorrent-k8s, sabnzbd-k8s):
|
|
15
|
+
- vpn-route-init: Creates VXLAN interface, gets IP via DHCP
|
|
16
|
+
- vpn-route-sidecar: Monitors gateway connectivity
|
|
17
|
+
|
|
18
|
+
Kill switch (for qbittorrent-k8s, sabnzbd-k8s):
|
|
19
|
+
- NetworkPolicy that blocks egress except cluster CIDRs + DNS
|
|
20
|
+
- Prevents traffic leaks if VXLAN routing fails
|
|
21
|
+
|
|
22
|
+
See ADRs:
|
|
23
|
+
- lib/adr-002-charmarr-vpn.md
|
|
24
|
+
- networking/adr-004-vpn-kill-switch.md
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from charmarr_lib.vpn._k8s._gateway import (
|
|
28
|
+
build_gateway_patch,
|
|
29
|
+
is_gateway_patched,
|
|
30
|
+
reconcile_gateway,
|
|
31
|
+
)
|
|
32
|
+
from charmarr_lib.vpn._k8s._gateway_client import (
|
|
33
|
+
build_gateway_client_configmap_data,
|
|
34
|
+
build_gateway_client_patch,
|
|
35
|
+
is_gateway_client_patched,
|
|
36
|
+
reconcile_gateway_client,
|
|
37
|
+
)
|
|
38
|
+
from charmarr_lib.vpn._k8s._kill_switch import (
|
|
39
|
+
KillSwitchConfig,
|
|
40
|
+
reconcile_kill_switch,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"KillSwitchConfig",
|
|
45
|
+
"build_gateway_client_configmap_data",
|
|
46
|
+
"build_gateway_client_patch",
|
|
47
|
+
"build_gateway_patch",
|
|
48
|
+
"is_gateway_client_patched",
|
|
49
|
+
"is_gateway_patched",
|
|
50
|
+
"reconcile_gateway",
|
|
51
|
+
"reconcile_gateway_client",
|
|
52
|
+
"reconcile_kill_switch",
|
|
53
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Gateway-side StatefulSet patching for pod-gateway.
|
|
5
|
+
|
|
6
|
+
Adds init container and sidecar to the VPN gateway (gluetun-k8s) StatefulSet
|
|
7
|
+
to handle VXLAN tunnel creation and DHCP/DNS services for client pods.
|
|
8
|
+
|
|
9
|
+
Based on validated configuration from vxlan-validation-plan.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from lightkube.models.apps_v1 import StatefulSet
|
|
15
|
+
from lightkube.models.core_v1 import (
|
|
16
|
+
Capabilities,
|
|
17
|
+
Container,
|
|
18
|
+
ContainerPort,
|
|
19
|
+
EnvVar,
|
|
20
|
+
SecurityContext,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from charmarr_lib.krm import K8sResourceManager, ReconcileResult
|
|
24
|
+
from charmarr_lib.vpn.constants import (
|
|
25
|
+
DEFAULT_VXLAN_GATEWAY_FIRST_DYNAMIC_IP,
|
|
26
|
+
GATEWAY_INIT_CONTAINER_NAME,
|
|
27
|
+
GATEWAY_SIDECAR_CONTAINER_NAME,
|
|
28
|
+
POD_GATEWAY_IMAGE,
|
|
29
|
+
)
|
|
30
|
+
from charmarr_lib.vpn.interfaces import VPNGatewayProviderData
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_gateway_env_vars(data: VPNGatewayProviderData, pod_cidr: str) -> list[EnvVar]:
|
|
34
|
+
"""Build environment variables for gateway containers.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
data: VPN gateway provider data from relation.
|
|
38
|
+
pod_cidr: Pod network CIDR for iptables rule (e.g., "10.1.0.0/16").
|
|
39
|
+
"""
|
|
40
|
+
return [
|
|
41
|
+
EnvVar(name="VXLAN_ID", value=str(data.vxlan_id)),
|
|
42
|
+
EnvVar(name="VXLAN_IP_NETWORK", value=data.vxlan_ip_network),
|
|
43
|
+
EnvVar(
|
|
44
|
+
name="VXLAN_GATEWAY_FIRST_DYNAMIC_IP",
|
|
45
|
+
value=str(DEFAULT_VXLAN_GATEWAY_FIRST_DYNAMIC_IP),
|
|
46
|
+
),
|
|
47
|
+
EnvVar(name="VPN_INTERFACE", value="tun0"),
|
|
48
|
+
EnvVar(name="VPN_BLOCK_OTHER_TRAFFIC", value="true"),
|
|
49
|
+
EnvVar(name="NOT_ROUTED_TO_GATEWAY_CIDRS", value=data.cluster_cidrs),
|
|
50
|
+
EnvVar(name="POD_CIDR", value=pod_cidr),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_gateway_init_container(data: VPNGatewayProviderData, pod_cidr: str) -> Container:
|
|
55
|
+
"""Build gateway-init container spec.
|
|
56
|
+
|
|
57
|
+
The init container:
|
|
58
|
+
- Creates VXLAN tunnel interface
|
|
59
|
+
- Sets up iptables forwarding rules
|
|
60
|
+
- Adds iptables rule to accept VXLAN packets from pod CIDR (critical fix)
|
|
61
|
+
- Requires privileged mode for sysctl ip_forward
|
|
62
|
+
"""
|
|
63
|
+
return Container(
|
|
64
|
+
name=GATEWAY_INIT_CONTAINER_NAME,
|
|
65
|
+
image=POD_GATEWAY_IMAGE,
|
|
66
|
+
command=["/bin/sh", "-c"],
|
|
67
|
+
args=[f"/bin/gateway_init.sh && iptables -I INPUT -i eth0 -s {pod_cidr} -j ACCEPT"],
|
|
68
|
+
securityContext=SecurityContext(privileged=True),
|
|
69
|
+
env=_build_gateway_env_vars(data, pod_cidr),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_gateway_sidecar_container(data: VPNGatewayProviderData, pod_cidr: str) -> Container:
|
|
74
|
+
"""Build gateway-sidecar container spec.
|
|
75
|
+
|
|
76
|
+
The sidecar container:
|
|
77
|
+
- Runs DHCP server for client IP allocation
|
|
78
|
+
- Runs DNS server for client pods
|
|
79
|
+
- Requires NET_ADMIN capability (not full privileged)
|
|
80
|
+
"""
|
|
81
|
+
return Container(
|
|
82
|
+
name=GATEWAY_SIDECAR_CONTAINER_NAME,
|
|
83
|
+
image=POD_GATEWAY_IMAGE,
|
|
84
|
+
command=["/bin/gateway_sidecar.sh"],
|
|
85
|
+
securityContext=SecurityContext(capabilities=Capabilities(add=["NET_ADMIN"])),
|
|
86
|
+
env=_build_gateway_env_vars(data, pod_cidr),
|
|
87
|
+
ports=[
|
|
88
|
+
ContainerPort(name="dhcp", containerPort=67, protocol="UDP"),
|
|
89
|
+
ContainerPort(name="dns", containerPort=53, protocol="UDP"),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_gateway_patch(data: VPNGatewayProviderData, pod_cidr: str) -> dict[str, Any]:
|
|
95
|
+
"""Build strategic merge patch for gateway StatefulSet.
|
|
96
|
+
|
|
97
|
+
Adds pod-gateway init container and sidecar to the VPN gateway StatefulSet.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
data: VPN gateway provider data containing VXLAN config.
|
|
101
|
+
pod_cidr: Pod network CIDR for iptables rule (e.g., "10.1.0.0/16").
|
|
102
|
+
This is extracted from cluster_cidrs (first CIDR is typically pods).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Strategic merge patch dict for StatefulSet.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
patch = build_gateway_patch(provider_data, "10.1.0.0/16")
|
|
109
|
+
manager.patch(StatefulSet, "gluetun", patch, "vpn-gateway")
|
|
110
|
+
"""
|
|
111
|
+
init_container = _build_gateway_init_container(data, pod_cidr)
|
|
112
|
+
sidecar = _build_gateway_sidecar_container(data, pod_cidr)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"spec": {
|
|
116
|
+
"template": {
|
|
117
|
+
"spec": {
|
|
118
|
+
"initContainers": [init_container.to_dict()],
|
|
119
|
+
"containers": [sidecar.to_dict()],
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_gateway_patched(sts: StatefulSet) -> bool:
|
|
127
|
+
"""Check if gateway StatefulSet already has pod-gateway containers.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
sts: The StatefulSet to check.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
True if both gateway-init and gateway-sidecar are present.
|
|
134
|
+
"""
|
|
135
|
+
if sts.spec is None or sts.spec.template.spec is None:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
spec = sts.spec.template.spec
|
|
139
|
+
|
|
140
|
+
has_init = False
|
|
141
|
+
if spec.initContainers:
|
|
142
|
+
has_init = any(c.name == GATEWAY_INIT_CONTAINER_NAME for c in spec.initContainers)
|
|
143
|
+
|
|
144
|
+
has_sidecar = False
|
|
145
|
+
if spec.containers:
|
|
146
|
+
has_sidecar = any(c.name == GATEWAY_SIDECAR_CONTAINER_NAME for c in spec.containers)
|
|
147
|
+
|
|
148
|
+
return has_init and has_sidecar
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reconcile_gateway(
|
|
152
|
+
manager: K8sResourceManager,
|
|
153
|
+
statefulset_name: str,
|
|
154
|
+
namespace: str,
|
|
155
|
+
data: VPNGatewayProviderData,
|
|
156
|
+
pod_cidr: str,
|
|
157
|
+
) -> ReconcileResult:
|
|
158
|
+
"""Reconcile pod-gateway containers on a VPN gateway StatefulSet.
|
|
159
|
+
|
|
160
|
+
This function ensures the VPN gateway StatefulSet has the required
|
|
161
|
+
pod-gateway containers for VXLAN tunnel and DHCP/DNS services.
|
|
162
|
+
|
|
163
|
+
Idempotent - if containers are already present, no changes are made.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
manager: K8sResourceManager instance.
|
|
167
|
+
statefulset_name: Name of the StatefulSet (usually self.app.name).
|
|
168
|
+
namespace: Kubernetes namespace (usually self.model.name).
|
|
169
|
+
data: VPN gateway provider data containing VXLAN config.
|
|
170
|
+
pod_cidr: Pod network CIDR for iptables rule (e.g., "10.1.0.0/16").
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
ReconcileResult indicating if changes were made.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ApiError: If the StatefulSet doesn't exist or patch fails.
|
|
177
|
+
"""
|
|
178
|
+
sts = manager.get(StatefulSet, statefulset_name, namespace)
|
|
179
|
+
|
|
180
|
+
if is_gateway_patched(sts):
|
|
181
|
+
return ReconcileResult(changed=False, message="Gateway containers already present")
|
|
182
|
+
|
|
183
|
+
patch = build_gateway_patch(data, pod_cidr)
|
|
184
|
+
manager.patch(StatefulSet, statefulset_name, patch, namespace)
|
|
185
|
+
|
|
186
|
+
return ReconcileResult(
|
|
187
|
+
changed=True,
|
|
188
|
+
message=f"Added pod-gateway containers to {statefulset_name}",
|
|
189
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Gateway client StatefulSet patching for pod-gateway.
|
|
5
|
+
|
|
6
|
+
Adds init container and sidecar to download client StatefulSets (qbittorrent-k8s,
|
|
7
|
+
sabnzbd-k8s) to route traffic through the VPN gateway via VXLAN overlay.
|
|
8
|
+
|
|
9
|
+
These are the "client" containers that connect TO the gateway - not to be confused
|
|
10
|
+
with API clients or download clients.
|
|
11
|
+
|
|
12
|
+
Based on validated configuration from vxlan-validation-plan.md.
|
|
13
|
+
|
|
14
|
+
Note: Client containers use a ConfigMap for settings (K8S_DNS_IPS,
|
|
15
|
+
NOT_ROUTED_TO_GATEWAY_CIDRS) with SPACE-separated CIDRs, unlike gateway
|
|
16
|
+
which uses COMMA-separated. This is a pod-gateway quirk.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from lightkube.models.apps_v1 import StatefulSet
|
|
22
|
+
from lightkube.models.core_v1 import (
|
|
23
|
+
Capabilities,
|
|
24
|
+
ConfigMapVolumeSource,
|
|
25
|
+
Container,
|
|
26
|
+
EnvVar,
|
|
27
|
+
SecurityContext,
|
|
28
|
+
Volume,
|
|
29
|
+
VolumeMount,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from charmarr_lib.krm import K8sResourceManager, ReconcileResult
|
|
33
|
+
from charmarr_lib.vpn.constants import (
|
|
34
|
+
CLIENT_INIT_CONTAINER_NAME,
|
|
35
|
+
CLIENT_SIDECAR_CONTAINER_NAME,
|
|
36
|
+
POD_GATEWAY_IMAGE,
|
|
37
|
+
)
|
|
38
|
+
from charmarr_lib.vpn.interfaces import VPNGatewayProviderData
|
|
39
|
+
|
|
40
|
+
_CONFIG_VOLUME_NAME = "pod-gateway-config"
|
|
41
|
+
_CONFIG_MOUNT_PATH = "/config"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_gateway_client_init_container(gateway_dns_name: str) -> Container:
|
|
45
|
+
"""Build vpn-route-init container spec."""
|
|
46
|
+
return Container(
|
|
47
|
+
name=CLIENT_INIT_CONTAINER_NAME,
|
|
48
|
+
image=POD_GATEWAY_IMAGE,
|
|
49
|
+
command=["/bin/client_init.sh"],
|
|
50
|
+
securityContext=SecurityContext(capabilities=Capabilities(add=["NET_ADMIN"])),
|
|
51
|
+
env=[EnvVar(name="gateway", value=gateway_dns_name)],
|
|
52
|
+
volumeMounts=[VolumeMount(name=_CONFIG_VOLUME_NAME, mountPath=_CONFIG_MOUNT_PATH)],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_gateway_client_sidecar_container(gateway_dns_name: str) -> Container:
|
|
57
|
+
"""Build vpn-route-sidecar container spec."""
|
|
58
|
+
return Container(
|
|
59
|
+
name=CLIENT_SIDECAR_CONTAINER_NAME,
|
|
60
|
+
image=POD_GATEWAY_IMAGE,
|
|
61
|
+
command=["/bin/client_sidecar.sh"],
|
|
62
|
+
securityContext=SecurityContext(capabilities=Capabilities(add=["NET_ADMIN"])),
|
|
63
|
+
env=[EnvVar(name="gateway", value=gateway_dns_name)],
|
|
64
|
+
volumeMounts=[VolumeMount(name=_CONFIG_VOLUME_NAME, mountPath=_CONFIG_MOUNT_PATH)],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_config_volume(configmap_name: str) -> Volume:
|
|
69
|
+
"""Build ConfigMap volume for gateway client settings."""
|
|
70
|
+
return Volume(
|
|
71
|
+
name=_CONFIG_VOLUME_NAME,
|
|
72
|
+
configMap=ConfigMapVolumeSource(name=configmap_name),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_gateway_client_configmap_data(
|
|
77
|
+
dns_server_ip: str,
|
|
78
|
+
cluster_cidrs: str,
|
|
79
|
+
) -> dict[str, str]:
|
|
80
|
+
"""Build ConfigMap data for gateway client pod-gateway settings.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
dns_server_ip: Kubernetes DNS server IP (e.g., "10.152.183.10").
|
|
84
|
+
cluster_cidrs: Space-separated cluster CIDRs for bypass.
|
|
85
|
+
Note: Must be SPACE-separated for client, not comma!
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dict with settings.sh content for ConfigMap data field.
|
|
89
|
+
"""
|
|
90
|
+
settings = f'K8S_DNS_IPS="{dns_server_ip}"\nNOT_ROUTED_TO_GATEWAY_CIDRS="{cluster_cidrs}"'
|
|
91
|
+
return {"settings.sh": settings}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_gateway_client_patch(
|
|
95
|
+
data: VPNGatewayProviderData,
|
|
96
|
+
configmap_name: str,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Build strategic merge patch for gateway client StatefulSet."""
|
|
99
|
+
init_container = _build_gateway_client_init_container(data.gateway_dns_name)
|
|
100
|
+
sidecar = _build_gateway_client_sidecar_container(data.gateway_dns_name)
|
|
101
|
+
config_volume = _build_config_volume(configmap_name)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"spec": {
|
|
105
|
+
"template": {
|
|
106
|
+
"spec": {
|
|
107
|
+
"initContainers": [init_container.to_dict()],
|
|
108
|
+
"containers": [sidecar.to_dict()],
|
|
109
|
+
"volumes": [config_volume.to_dict()],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_gateway_client_patched(sts: StatefulSet) -> bool:
|
|
117
|
+
"""Check if StatefulSet already has pod-gateway client containers."""
|
|
118
|
+
if sts.spec is None or sts.spec.template.spec is None:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
spec = sts.spec.template.spec
|
|
122
|
+
|
|
123
|
+
has_init = False
|
|
124
|
+
if spec.initContainers:
|
|
125
|
+
has_init = any(c.name == CLIENT_INIT_CONTAINER_NAME for c in spec.initContainers)
|
|
126
|
+
|
|
127
|
+
has_sidecar = False
|
|
128
|
+
if spec.containers:
|
|
129
|
+
has_sidecar = any(c.name == CLIENT_SIDECAR_CONTAINER_NAME for c in spec.containers)
|
|
130
|
+
|
|
131
|
+
return has_init and has_sidecar
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def reconcile_gateway_client(
|
|
135
|
+
manager: K8sResourceManager,
|
|
136
|
+
statefulset_name: str,
|
|
137
|
+
namespace: str,
|
|
138
|
+
data: VPNGatewayProviderData,
|
|
139
|
+
configmap_name: str,
|
|
140
|
+
) -> ReconcileResult:
|
|
141
|
+
"""Reconcile pod-gateway client containers on a StatefulSet.
|
|
142
|
+
|
|
143
|
+
This function ensures the download client StatefulSet has the required
|
|
144
|
+
pod-gateway containers to route traffic through the VPN gateway.
|
|
145
|
+
|
|
146
|
+
Idempotent - if containers are already present, no changes are made.
|
|
147
|
+
|
|
148
|
+
Note: The charm must create a ConfigMap with settings BEFORE calling this.
|
|
149
|
+
Use build_gateway_client_configmap_data() to generate ConfigMap content.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
manager: K8sResourceManager instance.
|
|
153
|
+
statefulset_name: Name of the StatefulSet (usually self.app.name).
|
|
154
|
+
namespace: Kubernetes namespace (usually self.model.name).
|
|
155
|
+
data: VPN gateway provider data from relation.
|
|
156
|
+
configmap_name: Name of the ConfigMap containing pod-gateway settings.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
ReconcileResult indicating if changes were made.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ApiError: If the StatefulSet doesn't exist or patch fails.
|
|
163
|
+
"""
|
|
164
|
+
sts = manager.get(StatefulSet, statefulset_name, namespace)
|
|
165
|
+
|
|
166
|
+
if is_gateway_client_patched(sts):
|
|
167
|
+
return ReconcileResult(changed=False, message="Gateway client containers already present")
|
|
168
|
+
|
|
169
|
+
patch = build_gateway_client_patch(data, configmap_name)
|
|
170
|
+
manager.patch(StatefulSet, statefulset_name, patch, namespace)
|
|
171
|
+
|
|
172
|
+
return ReconcileResult(
|
|
173
|
+
changed=True,
|
|
174
|
+
message=f"Added pod-gateway client containers to {statefulset_name}",
|
|
175
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""NetworkPolicy kill switch for VPN gateway clients.
|
|
5
|
+
|
|
6
|
+
Creates a NetworkPolicy that blocks all egress traffic EXCEPT:
|
|
7
|
+
- Traffic to cluster pod CIDR (VXLAN-encapsulated traffic)
|
|
8
|
+
- Traffic to cluster service CIDR (K8s services)
|
|
9
|
+
- DNS traffic to kube-system (CoreDNS)
|
|
10
|
+
|
|
11
|
+
This is Layer 1 of the two-layer VPN kill switch. If VXLAN routing fails
|
|
12
|
+
(init container error, interface down, routes deleted), traffic cannot escape
|
|
13
|
+
to the internet because the destination IP isn't in the allowed cluster CIDRs.
|
|
14
|
+
|
|
15
|
+
Why VXLAN traffic passes through:
|
|
16
|
+
- VXLAN encapsulates external traffic with outer destination = gateway pod IP
|
|
17
|
+
- Gateway pod IP is in the cluster pod CIDR
|
|
18
|
+
- NetworkPolicy evaluates outer packet headers, sees cluster CIDR → ALLOWED
|
|
19
|
+
|
|
20
|
+
See ADR: networking/adr-004-vpn-kill-switch.md
|
|
21
|
+
Validated: vxlan-validation-plan.md (2025-12-11)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from lightkube.models.meta_v1 import LabelSelector, ObjectMeta
|
|
25
|
+
from lightkube.models.networking_v1 import (
|
|
26
|
+
IPBlock,
|
|
27
|
+
NetworkPolicy,
|
|
28
|
+
NetworkPolicyEgressRule,
|
|
29
|
+
NetworkPolicyPeer,
|
|
30
|
+
NetworkPolicyPort,
|
|
31
|
+
NetworkPolicySpec,
|
|
32
|
+
)
|
|
33
|
+
from lightkube.resources.networking_v1 import NetworkPolicy as NetworkPolicyResource
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
|
|
36
|
+
from charmarr_lib.krm import K8sResourceManager, ReconcileResult
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KillSwitchConfig(BaseModel):
|
|
40
|
+
"""Configuration for VPN kill switch NetworkPolicy.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
config = KillSwitchConfig(
|
|
44
|
+
app_name="qbittorrent",
|
|
45
|
+
namespace="downloads",
|
|
46
|
+
cluster_cidrs=["10.42.0.0/16", "10.96.0.0/12"],
|
|
47
|
+
)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
app_name: str = Field(
|
|
51
|
+
description="Application name for pod selector (app.kubernetes.io/name label)",
|
|
52
|
+
)
|
|
53
|
+
namespace: str = Field(
|
|
54
|
+
description="Kubernetes namespace for the NetworkPolicy",
|
|
55
|
+
)
|
|
56
|
+
cluster_cidrs: list[str] = Field(
|
|
57
|
+
description="List of cluster CIDRs to allow (pod CIDR, service CIDR)",
|
|
58
|
+
)
|
|
59
|
+
dns_namespace: str = Field(
|
|
60
|
+
default="kube-system",
|
|
61
|
+
description="Namespace containing DNS service (usually kube-system)",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _policy_name(app_name: str) -> str:
|
|
66
|
+
"""Generate NetworkPolicy name for an application."""
|
|
67
|
+
return f"{app_name}-vpn-killswitch"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_kill_switch_policy(config: KillSwitchConfig) -> NetworkPolicy:
|
|
71
|
+
"""Build a NetworkPolicy for VPN kill switch.
|
|
72
|
+
|
|
73
|
+
Creates an egress-only NetworkPolicy that allows:
|
|
74
|
+
- Traffic to cluster CIDRs (pod/service networks) - each CIDR as separate rule
|
|
75
|
+
- DNS traffic to kube-system namespace (UDP and TCP port 53)
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
config: Kill switch configuration.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
NetworkPolicy lightkube model ready for apply.
|
|
82
|
+
|
|
83
|
+
Example NetworkPolicy produced:
|
|
84
|
+
apiVersion: networking.k8s.io/v1
|
|
85
|
+
kind: NetworkPolicy
|
|
86
|
+
metadata:
|
|
87
|
+
name: qbittorrent-vpn-killswitch
|
|
88
|
+
namespace: downloads
|
|
89
|
+
spec:
|
|
90
|
+
podSelector:
|
|
91
|
+
matchLabels:
|
|
92
|
+
app.kubernetes.io/name: qbittorrent
|
|
93
|
+
policyTypes:
|
|
94
|
+
- Egress
|
|
95
|
+
egress:
|
|
96
|
+
- to:
|
|
97
|
+
- ipBlock:
|
|
98
|
+
cidr: 10.42.0.0/16
|
|
99
|
+
- to:
|
|
100
|
+
- ipBlock:
|
|
101
|
+
cidr: 10.96.0.0/12
|
|
102
|
+
- to:
|
|
103
|
+
- namespaceSelector:
|
|
104
|
+
matchLabels:
|
|
105
|
+
kubernetes.io/metadata.name: kube-system
|
|
106
|
+
ports:
|
|
107
|
+
- protocol: UDP
|
|
108
|
+
port: 53
|
|
109
|
+
- protocol: TCP
|
|
110
|
+
port: 53
|
|
111
|
+
"""
|
|
112
|
+
egress_rules: list[NetworkPolicyEgressRule] = []
|
|
113
|
+
|
|
114
|
+
for cidr in config.cluster_cidrs:
|
|
115
|
+
egress_rules.append(
|
|
116
|
+
NetworkPolicyEgressRule(
|
|
117
|
+
to=[NetworkPolicyPeer(ipBlock=IPBlock(cidr=cidr))],
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
egress_rules.append(
|
|
122
|
+
NetworkPolicyEgressRule(
|
|
123
|
+
to=[
|
|
124
|
+
NetworkPolicyPeer(
|
|
125
|
+
namespaceSelector=LabelSelector(
|
|
126
|
+
matchLabels={"kubernetes.io/metadata.name": config.dns_namespace}
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
],
|
|
130
|
+
ports=[
|
|
131
|
+
NetworkPolicyPort(protocol="UDP", port=53),
|
|
132
|
+
NetworkPolicyPort(protocol="TCP", port=53),
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return NetworkPolicy(
|
|
138
|
+
metadata=ObjectMeta(
|
|
139
|
+
name=_policy_name(config.app_name),
|
|
140
|
+
namespace=config.namespace,
|
|
141
|
+
),
|
|
142
|
+
spec=NetworkPolicySpec(
|
|
143
|
+
podSelector=LabelSelector(matchLabels={"app.kubernetes.io/name": config.app_name}),
|
|
144
|
+
policyTypes=["Egress"],
|
|
145
|
+
egress=egress_rules,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def reconcile_kill_switch(
|
|
151
|
+
manager: K8sResourceManager,
|
|
152
|
+
app_name: str,
|
|
153
|
+
namespace: str,
|
|
154
|
+
config: KillSwitchConfig | None = None,
|
|
155
|
+
) -> ReconcileResult:
|
|
156
|
+
"""Reconcile VPN kill switch NetworkPolicy.
|
|
157
|
+
|
|
158
|
+
When config is provided, creates or updates the NetworkPolicy.
|
|
159
|
+
When config is None, deletes the NetworkPolicy if it exists.
|
|
160
|
+
|
|
161
|
+
Uses server-side apply for clean ownership and idempotent updates.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
manager: K8sResourceManager instance.
|
|
165
|
+
app_name: Application name (used to derive policy name).
|
|
166
|
+
namespace: Kubernetes namespace.
|
|
167
|
+
config: Kill switch configuration, or None to remove the policy.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
ReconcileResult indicating if changes were made.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ApiError: If NetworkPolicy creation/update/deletion fails.
|
|
174
|
+
|
|
175
|
+
Example - create/update:
|
|
176
|
+
config = KillSwitchConfig(
|
|
177
|
+
app_name="qbittorrent",
|
|
178
|
+
namespace="downloads",
|
|
179
|
+
cluster_cidrs=["10.42.0.0/16", "10.96.0.0/12"],
|
|
180
|
+
)
|
|
181
|
+
result = reconcile_kill_switch(manager, "qbittorrent", "downloads", config)
|
|
182
|
+
|
|
183
|
+
Example - remove (when VPN relation is broken):
|
|
184
|
+
result = reconcile_kill_switch(manager, "qbittorrent", "downloads", config=None)
|
|
185
|
+
"""
|
|
186
|
+
policy_name = _policy_name(app_name)
|
|
187
|
+
|
|
188
|
+
if config is None:
|
|
189
|
+
if not manager.exists(NetworkPolicyResource, policy_name, namespace):
|
|
190
|
+
return ReconcileResult(
|
|
191
|
+
changed=False,
|
|
192
|
+
message=f"Kill switch NetworkPolicy {policy_name} not present",
|
|
193
|
+
)
|
|
194
|
+
manager.delete(NetworkPolicyResource, policy_name, namespace)
|
|
195
|
+
return ReconcileResult(
|
|
196
|
+
changed=True,
|
|
197
|
+
message=f"Deleted kill switch NetworkPolicy {policy_name}",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
policy = _build_kill_switch_policy(config)
|
|
201
|
+
existed = manager.exists(NetworkPolicyResource, policy_name, namespace)
|
|
202
|
+
manager.apply(policy)
|
|
203
|
+
|
|
204
|
+
if existed:
|
|
205
|
+
return ReconcileResult(
|
|
206
|
+
changed=True,
|
|
207
|
+
message=f"Updated kill switch NetworkPolicy {policy_name}",
|
|
208
|
+
)
|
|
209
|
+
return ReconcileResult(
|
|
210
|
+
changed=True,
|
|
211
|
+
message=f"Created kill switch NetworkPolicy {policy_name}",
|
|
212
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Pod-gateway constants and defaults."""
|
|
5
|
+
|
|
6
|
+
# Pod-gateway container image
|
|
7
|
+
POD_GATEWAY_IMAGE = "ghcr.io/angelnu/pod-gateway:v1.13.0"
|
|
8
|
+
|
|
9
|
+
# VXLAN defaults
|
|
10
|
+
DEFAULT_VXLAN_ID = 42
|
|
11
|
+
DEFAULT_VXLAN_IP_NETWORK = "172.16.0"
|
|
12
|
+
DEFAULT_VXLAN_GATEWAY_FIRST_DYNAMIC_IP = 20
|
|
13
|
+
|
|
14
|
+
# Gateway environment variable defaults
|
|
15
|
+
DEFAULT_VPN_INTERFACE = "tun0"
|
|
16
|
+
DEFAULT_VPN_BLOCK_OTHER_TRAFFIC = True
|
|
17
|
+
|
|
18
|
+
# Container names for StatefulSet patching
|
|
19
|
+
GATEWAY_INIT_CONTAINER_NAME = "gateway-init"
|
|
20
|
+
GATEWAY_SIDECAR_CONTAINER_NAME = "gateway-sidecar"
|
|
21
|
+
CLIENT_INIT_CONTAINER_NAME = "vpn-route-init"
|
|
22
|
+
CLIENT_SIDECAR_CONTAINER_NAME = "vpn-route-sidecar"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""Juju relation interface implementations for VPN gateway."""
|
|
5
|
+
|
|
6
|
+
from charmarr_lib.vpn.interfaces._vpn_gateway import (
|
|
7
|
+
VPNGatewayChangedEvent,
|
|
8
|
+
VPNGatewayProvider,
|
|
9
|
+
VPNGatewayProviderData,
|
|
10
|
+
VPNGatewayRequirer,
|
|
11
|
+
VPNGatewayRequirerData,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"VPNGatewayChangedEvent",
|
|
16
|
+
"VPNGatewayProvider",
|
|
17
|
+
"VPNGatewayProviderData",
|
|
18
|
+
"VPNGatewayRequirer",
|
|
19
|
+
"VPNGatewayRequirerData",
|
|
20
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Copyright 2025 The Charmarr Project
|
|
2
|
+
# See LICENSE file for licensing details.
|
|
3
|
+
|
|
4
|
+
"""VPN gateway interface for VXLAN overlay routing through a VPN gateway."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ops import EventBase, EventSource, Object, ObjectEvents
|
|
9
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
10
|
+
|
|
11
|
+
from charmarr_lib.vpn.constants import DEFAULT_VXLAN_ID, DEFAULT_VXLAN_IP_NETWORK
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VPNGatewayProviderData(BaseModel):
|
|
15
|
+
"""Data published by VPN gateway provider."""
|
|
16
|
+
|
|
17
|
+
routing_method: str = Field(
|
|
18
|
+
default="pod-gateway",
|
|
19
|
+
description="Routing method: 'pod-gateway' (VXLAN overlay)",
|
|
20
|
+
)
|
|
21
|
+
gateway_dns_name: str = Field(
|
|
22
|
+
description="Service FQDN for client DNS resolution",
|
|
23
|
+
)
|
|
24
|
+
vxlan_id: int = Field(
|
|
25
|
+
default=DEFAULT_VXLAN_ID,
|
|
26
|
+
ge=1,
|
|
27
|
+
le=16777215,
|
|
28
|
+
description="VXLAN tunnel ID (1-16777215)",
|
|
29
|
+
)
|
|
30
|
+
vxlan_ip_network: str = Field(
|
|
31
|
+
default=DEFAULT_VXLAN_IP_NETWORK,
|
|
32
|
+
description="First 3 octets of VXLAN subnet",
|
|
33
|
+
)
|
|
34
|
+
cluster_cidrs: str = Field(
|
|
35
|
+
description="Comma-separated CIDRs to NOT route through VPN",
|
|
36
|
+
)
|
|
37
|
+
vpn_connected: bool = Field(
|
|
38
|
+
default=False,
|
|
39
|
+
description="Whether VPN tunnel is established",
|
|
40
|
+
)
|
|
41
|
+
external_ip: str | None = Field(
|
|
42
|
+
default=None,
|
|
43
|
+
description="Current VPN exit IP for verification",
|
|
44
|
+
)
|
|
45
|
+
instance_name: str = Field(
|
|
46
|
+
description="Juju application name of the gateway",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VPNGatewayRequirerData(BaseModel):
|
|
51
|
+
"""Data published by VPN gateway requirers."""
|
|
52
|
+
|
|
53
|
+
instance_name: str = Field(
|
|
54
|
+
description="Juju application name of the requirer",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VPNGatewayChangedEvent(EventBase):
|
|
59
|
+
"""Event emitted when vpn-gateway relation state changes."""
|
|
60
|
+
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VPNGatewayProvider(Object):
|
|
65
|
+
"""Provider side of vpn-gateway interface (passive - no events)."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, charm: Any, relation_name: str = "vpn-gateway") -> None:
|
|
68
|
+
super().__init__(charm, relation_name)
|
|
69
|
+
self._charm = charm
|
|
70
|
+
self._relation_name = relation_name
|
|
71
|
+
|
|
72
|
+
def publish_data(self, data: VPNGatewayProviderData) -> None:
|
|
73
|
+
"""Publish provider data to all relations."""
|
|
74
|
+
if not self._charm.unit.is_leader():
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
for relation in self._charm.model.relations.get(self._relation_name, []):
|
|
78
|
+
relation.data[self._charm.app]["config"] = data.model_dump_json()
|
|
79
|
+
|
|
80
|
+
def get_connected_clients(self) -> list[str]:
|
|
81
|
+
"""Get list of connected requirer application names."""
|
|
82
|
+
clients = []
|
|
83
|
+
for relation in self._charm.model.relations.get(self._relation_name, []):
|
|
84
|
+
try:
|
|
85
|
+
app_data = relation.data[relation.app]
|
|
86
|
+
if app_data and "config" in app_data:
|
|
87
|
+
data = VPNGatewayRequirerData.model_validate_json(app_data["config"])
|
|
88
|
+
clients.append(data.instance_name)
|
|
89
|
+
except (ValidationError, KeyError):
|
|
90
|
+
continue
|
|
91
|
+
return clients
|
|
92
|
+
|
|
93
|
+
def is_ready(self) -> bool:
|
|
94
|
+
"""Check if provider has any relations."""
|
|
95
|
+
return len(self._charm.model.relations.get(self._relation_name, [])) > 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class VPNGatewayRequirerEvents(ObjectEvents):
|
|
99
|
+
"""Events emitted by VPNGatewayRequirer."""
|
|
100
|
+
|
|
101
|
+
changed = EventSource(VPNGatewayChangedEvent)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class VPNGatewayRequirer(Object):
|
|
105
|
+
"""Requirer side of vpn-gateway interface."""
|
|
106
|
+
|
|
107
|
+
on = VPNGatewayRequirerEvents() # type: ignore[assignment]
|
|
108
|
+
|
|
109
|
+
def __init__(self, charm: Any, relation_name: str = "vpn-gateway") -> None:
|
|
110
|
+
super().__init__(charm, relation_name)
|
|
111
|
+
self._charm = charm
|
|
112
|
+
self._relation_name = relation_name
|
|
113
|
+
events = charm.on[relation_name]
|
|
114
|
+
self.framework.observe(events.relation_changed, self._emit_changed)
|
|
115
|
+
self.framework.observe(events.relation_broken, self._emit_changed)
|
|
116
|
+
|
|
117
|
+
def _emit_changed(self, event: EventBase) -> None:
|
|
118
|
+
self.on.changed.emit()
|
|
119
|
+
|
|
120
|
+
def publish_data(self, data: VPNGatewayRequirerData) -> None:
|
|
121
|
+
"""Publish requirer data to the relation."""
|
|
122
|
+
if not self._charm.unit.is_leader():
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
relation = self._charm.model.get_relation(self._relation_name)
|
|
126
|
+
if relation:
|
|
127
|
+
relation.data[self._charm.app]["config"] = data.model_dump_json()
|
|
128
|
+
|
|
129
|
+
def get_gateway(self) -> VPNGatewayProviderData | None:
|
|
130
|
+
"""Get gateway provider data if available."""
|
|
131
|
+
relation = self._charm.model.get_relation(self._relation_name)
|
|
132
|
+
if not relation:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
provider_app = relation.app
|
|
137
|
+
if provider_app:
|
|
138
|
+
provider_data = relation.data[provider_app]
|
|
139
|
+
if provider_data and "config" in provider_data:
|
|
140
|
+
return VPNGatewayProviderData.model_validate_json(provider_data["config"])
|
|
141
|
+
except (ValidationError, KeyError):
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def is_ready(self) -> bool:
|
|
146
|
+
"""Check if gateway is available and VPN is connected."""
|
|
147
|
+
gateway = self.get_gateway()
|
|
148
|
+
return gateway is not None and gateway.vpn_connected
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: charmarr-lib-vpn
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: VPN gateway charm library for Kubernetes
|
|
5
|
+
Author-email: Adhitya Ravi <adhityashan95@gmail.com>
|
|
6
|
+
License: LGPL-3.0-or-later
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: charmarr-lib-krm>=0.1
|
|
14
|
+
Requires-Dist: lightkube>=0.15
|
|
15
|
+
Requires-Dist: ops>=2.0
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="../assets/charmarr-charmarr-lib.png" width="350" alt="Charmarr Lib">
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<h1 align="center">charmarr-lib-vpn</h1>
|
|
29
|
+
|
|
30
|
+
VPN gateway charm library for Kubernetes.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- VPN gateway Juju relation interface
|
|
35
|
+
- StatefulSet patching utilities for pod-gateway integration
|
|
36
|
+
- NetworkPolicy kill switch implementation
|
|
37
|
+
- Reusable beyond Charmarr ecosystem
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install charmarr-lib-vpn
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Interfaces
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from charmarr_lib.vpn.interfaces import (
|
|
51
|
+
VPNGatewayProvider,
|
|
52
|
+
VPNGatewayRequirer,
|
|
53
|
+
VPNGatewayProviderData,
|
|
54
|
+
VPNGatewayRequirerData,
|
|
55
|
+
VPNGatewayChangedEvent,
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Gateway Patching (VPN gateway side - gluetun)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from charmarr_lib.vpn import (
|
|
63
|
+
reconcile_gateway,
|
|
64
|
+
build_gateway_patch,
|
|
65
|
+
is_gateway_patched,
|
|
66
|
+
)
|
|
67
|
+
from charmarr_lib.krm import K8sResourceManager
|
|
68
|
+
|
|
69
|
+
manager = K8sResourceManager()
|
|
70
|
+
provider_data = VPNGatewayProviderData(
|
|
71
|
+
vxlan_id=42,
|
|
72
|
+
vxlan_ip_network="172.16.0.0/24",
|
|
73
|
+
cluster_cidrs="10.1.0.0/16,10.152.183.0/24",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Idempotent reconciliation
|
|
77
|
+
result = reconcile_gateway(
|
|
78
|
+
manager=manager,
|
|
79
|
+
statefulset_name="gluetun",
|
|
80
|
+
namespace="vpn-gateway",
|
|
81
|
+
data=provider_data,
|
|
82
|
+
pod_cidr="10.1.0.0/16",
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Client Patching (Download client side - qBittorrent)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from charmarr_lib.vpn import (
|
|
90
|
+
reconcile_gateway_client,
|
|
91
|
+
build_gateway_client_patch,
|
|
92
|
+
build_gateway_client_configmap_data,
|
|
93
|
+
is_gateway_client_patched,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Create client-side VPN routing
|
|
97
|
+
result = reconcile_gateway_client(
|
|
98
|
+
manager=manager,
|
|
99
|
+
statefulset_name="qbittorrent",
|
|
100
|
+
namespace="download-clients",
|
|
101
|
+
data=requirer_data,
|
|
102
|
+
configmap_name="qbittorrent-vpn-config",
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Kill Switch (NetworkPolicy)
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from charmarr_lib.vpn import (
|
|
110
|
+
KillSwitchConfig,
|
|
111
|
+
reconcile_kill_switch,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Create NetworkPolicy that blocks non-VPN egress
|
|
115
|
+
config = KillSwitchConfig(
|
|
116
|
+
app_name="qbittorrent",
|
|
117
|
+
namespace="download-clients",
|
|
118
|
+
cluster_cidrs=["10.1.0.0/16", "10.152.183.0/24"],
|
|
119
|
+
dns_namespace="kube-system",
|
|
120
|
+
)
|
|
121
|
+
reconcile_kill_switch(manager, "qbittorrent", "download-clients", config)
|
|
122
|
+
|
|
123
|
+
# Remove kill switch on relation-broken
|
|
124
|
+
reconcile_kill_switch(manager, "qbittorrent", "download-clients", None)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Constants
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from charmarr_lib.vpn import (
|
|
131
|
+
POD_GATEWAY_IMAGE,
|
|
132
|
+
DEFAULT_VXLAN_ID,
|
|
133
|
+
DEFAULT_VXLAN_IP_NETWORK,
|
|
134
|
+
GATEWAY_INIT_CONTAINER_NAME,
|
|
135
|
+
GATEWAY_SIDECAR_CONTAINER_NAME,
|
|
136
|
+
CLIENT_INIT_CONTAINER_NAME,
|
|
137
|
+
CLIENT_SIDECAR_CONTAINER_NAME,
|
|
138
|
+
)
|
|
139
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
charmarr_lib/vpn/__init__.py,sha256=nm5l2EeYTXTCth4ItmssQCmncf95WkFtRgKvuK6_nT4,1412
|
|
2
|
+
charmarr_lib/vpn/_version.py,sha256=aB8nHXsa9U2rYEzbBMgYI-KFGyuS8pe2xXtf2OFHYcs,91
|
|
3
|
+
charmarr_lib/vpn/constants.py,sha256=YE3VU_ZLFPBY_3xtqjR57XsQlVH7aSyb10Oro0S0thQ,683
|
|
4
|
+
charmarr_lib/vpn/_k8s/__init__.py,sha256=uVhI_LCNwgMcoV-EcBb5kxy0N1FdGEjFcHkOU61tWZw,1611
|
|
5
|
+
charmarr_lib/vpn/_k8s/_gateway.py,sha256=DCOsC_aLToAUjm1YXMPP6agYAGH25mndv2JHMFpGRWU,6368
|
|
6
|
+
charmarr_lib/vpn/_k8s/_gateway_client.py,sha256=kqsuy3t5oRCswGj8sVAbK2be2dj-IQnAmxbT32I_81U,6024
|
|
7
|
+
charmarr_lib/vpn/_k8s/_kill_switch.py,sha256=1tzvWAT3I_6OuTTscEcqkiHXa8Eqe2tGGrDnXb2eMa4,6847
|
|
8
|
+
charmarr_lib/vpn/interfaces/__init__.py,sha256=stnITzP8difNh9exlyqJkyAcTXyhfhYykxU3T99KRuE,491
|
|
9
|
+
charmarr_lib/vpn/interfaces/_vpn_gateway.py,sha256=ib2aMvvE4SZhlORZR5-YOCcPDoRrv7wEeHOGoUgZKIk,5122
|
|
10
|
+
charmarr_lib_vpn-0.2.2.dist-info/METADATA,sha256=KK2Ix6OkTA63Ux5U1zbTktz7KuLdbzYN3cJYs0BbTls,3390
|
|
11
|
+
charmarr_lib_vpn-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
charmarr_lib_vpn-0.2.2.dist-info/RECORD,,
|