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.
@@ -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,3 @@
1
+ """Version for charmarr-vpn package."""
2
+
3
+ __version__ = "0.2.2" # x-release-please-version
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any