Securing the connection between the Inway and your API
The Inway terminates the FSC connection from an Outway, verifies the Group certificate and the access token, and then proxies the request to the endpoint URL registered for the Service. That last hop — Inway to API — has no security options of its own. This guide covers the two measures that close the gap without modifying the Inway: an mTLS tunnel sidecar, and network policies that make the Inway the only possible caller.
Conventions used in the examples
| Item | Value |
|---|---|
| Inway namespace | fsc |
| Inway Helm release | inway (so pod labels are app.kubernetes.io/name: open-fsc-inway, app.kubernetes.io/instance: inway) |
| Backend API | example-api in namespace backend, listening on port 443 |
| Internal CA | the cert-manager Issuer named internal from the Helm installation guide |
1. mTLS tunnel sidecar
An Nginx sidecar runs in the Inway pod, listening on 127.0.0.1:8090. The
Service endpoint URL is registered as http://127.0.0.1:8090, so the Inway
proxies to loopback, and the sidecar opens the real connection to the API with
a client certificate. The cleartext hop never leaves the pod's network
namespace.
Nginx is used rather than a plain TCP tunnel such as ghostunnel because the
Inway rewrites the Host header to the endpoint URL's host. A TCP tunnel would
forward Host: 127.0.0.1:8090 to the API, which breaks any backend that routes on hostname or sits behind an Ingress.
1.1 Issue a client certificate
First we need a client certificate for the Inway. This guide expects an Internal CA to be present on the Kubernetes Cluster.
inway-api-client-certificate.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: inway-api-client-tls
namespace: fsc
spec:
commonName: inway-open-fsc-inway.api-client
issuerRef:
name: internal
duration: 2160h # 90 days
renewBefore: 720h # 30 days
secretName: inway-api-client-tls
usages:
- digital signature
- key encipherment
- client auth
privateKey:
size: 4096
rotationPolicy: Always
Apply inway-api-client-certificate.yaml
It mints inway-api-client-tls from the internal issuer with the client auth
usage and a common name the API can authorise on:
kubectl apply -f inway-api-client-certificate.yaml
kubectl get certificate -n fsc inway-api-client-tls
The resulting secret holds tls.crt, tls.key and ca.crt. The API must be
configured to require a client certificate, to trust that same ca.crt, and to
accept only the common name inway-open-fsc-inway.api-client. Trusting the
CA without pinning the subject is not enough — the internal CA issues
certificates to every component in the installation, so any of them would be
accepted.
1.2 Create the sidecar configuration
Create a ConfigMap that contains the Nginx configuration needed to set up an mTLS connection.
The config below adjusts the upstream hostname and applies mTLS. Removing any of these four breaks something silently:
proxy_set_header Host— undoes the Inway'sHostrewrite.client_max_body_size 0— Nginx otherwise rejects request bodies over 1 MB, a limit the Inway does not have. Set a real ceiling if you want one.proxy_ssl_verify onwithproxy_ssl_trusted_certificate— without this nginx does not verify the API's certificate, which would trade one unauthenticated hop for another.proxy_ssl_namemust stay as well: becauseproxy_passnow names an upstream, nginx would otherwise sendapi_backendas the SNI name and verify the certificate against it.keepalivein theupstreamblock together withproxy_set_header Connection ""— both are needed to reuse the connection to the API. With either one missing, every request repeats the full TLS handshake, including the 4096-bit client certificate.
nginx-sidecar-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: inway-api-tunnel
namespace: fsc
data:
api-tunnel.conf: |
# Keeps connections to the API open between requests. Without this every
# request repeats the TLS handshake, client certificate included.
upstream api_backend {
server example-api.backend.svc.cluster.local:443;
keepalive 32;
}
server {
listen 127.0.0.1:8090;
server_name _;
# The Inway passes requests and responses straight through. nginx
# buffers both by default and rejects request bodies over 1m, which
# would cap uploads and stall streaming responses such as SSE.
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
location / {
proxy_pass https://api_backend;
proxy_set_header Host example-api.backend.svc.cluster.local;
# Required for upstream keepalive: nginx sends "Connection: close"
# to the upstream unless this header is cleared.
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_ssl_name example-api.backend.svc.cluster.local;
proxy_ssl_certificate /tls/client/tls.crt;
proxy_ssl_certificate_key /tls/client/tls.key;
proxy_ssl_trusted_certificate /tls/client/ca.crt;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_http_version 1.1;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
Apply nginx-sidecar-configmap.yaml
kubectl apply -f nginx-sidecar-configmap.yaml
1.3 Add the sidecar to the Inway pod
inway-values-sidecar.yaml
extraContainers:
- name: api-tunnel
# Pin a digest in production; this tag is here for readability.
image: nginxinc/nginx-unprivileged:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- name: api-tunnel
containerPort: 8090
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 101
capabilities:
drop:
- "ALL"
volumeMounts:
- name: api-tunnel-config
mountPath: /etc/nginx/conf.d
readOnly: true
- name: api-client-tls
mountPath: /tls/client
readOnly: true
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
memory: 128Mi
extraVolumes:
- name: api-tunnel-config
configMap:
name: inway-api-tunnel
defaultMode: 0440
- name: api-client-tls
secret:
secretName: inway-api-client-tls
defaultMode: 0640
Merge inway-values-sidecar.yaml into your
inway-values.yaml and upgrade the Inway Helm deployment:
helm upgrade inway oci://registry-1.docker.io/federatedserviceconnectivity/open-fsc-inway \
-n fsc -f inway-values.yaml -f inway-values-sidecar.yaml
The chart passes extraContainers and extraVolumes through to the pod spec
unchanged. The secret is mounted with mode 0640; the chart's pod-level
fsGroup: 10001 sets the volume's group ownership and adds that supplementary
group to every container, so the sidecar's uid 101 can read the key.
1.4 Repoint the Service endpoint
In the Controller, change the Service's endpoint URL to:
http://127.0.0.1:8090
1.5 Verify
# The sidecar is listening and the Inway is reaching it.
kubectl logs -n fsc deploy/inway-open-fsc-inway -c api-tunnel
# End-to-end through a peer Outway, then confirm the API saw the right caller.
kubectl logs -n backend deploy/example-api | tail
Check three things in the API's logs:
- the client certificate subject is
inway-open-fsc-inway.api-client. - the
Hostheader is the API's own hostname rather than127.0.0.1:8090. - The
FSC-Request-Peer-IdHTTP header still arrives.
The Inway image is built FROM scratch and has no shell, so kubectl exec into
it will not work. Use an ephemeral container when you need to poke at the pod's
network namespace:
kubectl debug -n fsc -it deploy/inway-open-fsc-inway \
--image=curlimages/curl --target=open-fsc-inway -- sh
2. Network policies
The sidecar proves the Inway's identity to the API. Network policy makes that proof meaningful by ensuring no other workload can reach the API in the first place.
Your CNI must enforce NetworkPolicy. Calico, Cilium, Antrea and Weave do; plain Flannel does not, and there the objects apply cleanly and protect nothing. Confirm that your CNI enforces NetworkPolicies.
2.1 Restrict who may reach the API
networkpolicy-api-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: example-api-allow-inway-only
namespace: fsc
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: example-api
policyTypes:
- Ingress
ingress:
- from:
# namespaceSelector and podSelector in ONE list item are ANDed:
# "a pod labelled open-fsc-inway IN namespace fsc".
# Splitting them into two items would OR them and allow every pod
# in the fsc namespace plus every open-fsc-inway pod anywhere.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fsc
podSelector:
matchLabels:
app.kubernetes.io/name: open-fsc-inway
app.kubernetes.io/instance: inway
ports:
- protocol: TCP
port: 443
# Kubelet health probes come from the node itself, not from a pod, so no
# selector can match them — ipBlock is the only construct that can. Whether
# they are allowed without a rule depends on your CNI. Uncomment and fill
# in your node addresses if the API's probes start failing.
# - from:
# - ipBlock:
# cidr: 10.0.0.0/24 # kubectl get nodes -o wide -> INTERNAL-IP
# ports:
# - protocol: TCP
# port: 443
Apply networkpolicy-api-ingress.yaml
kubectl apply -f networkpolicy-api-ingress.yaml
The detail that matters most is the shape of the from block. A
namespaceSelector and a podSelector in the same list item are ANDed —
"a pod with these labels, in that namespace". Written as two separate list items they are ORed, which would allow every pod in the fsc namespace and every
pod labelled open-fsc-inway in any namespace. This is the most common way
these policies end up weaker than intended.
The kubernetes.io/metadata.name label is set automatically on every namespace
from Kubernetes 1.21 onwards.
2.2 Restrict where the Inway may connect
networkpolicy-inway-egress.yaml
# Restricts where the Inway pod may connect. Adding this policy makes the pod
# default-deny for egress, so every destination it needs must be listed here.
# Traffic from the tunnel sidecar counts as egress from this same pod.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: inway-egress
namespace: fsc
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: open-fsc-inway
app.kubernetes.io/instance: inway
policyTypes:
- Egress
egress:
# DNS. Adjust the selector for CoreDNS installations that label differently.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# OpenFSC control plane in the same namespace: Controller, Manager, TxLog API.
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: open-fsc-controller
- podSelector:
matchLabels:
app.kubernetes.io/name: open-fsc-manager
- podSelector:
matchLabels:
app.kubernetes.io/name: open-fsc-txlog-api
ports:
- protocol: TCP
port: 9443
- protocol: TCP
port: 9444
# The backend API, reached by the tunnel sidecar.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: backend
podSelector:
matchLabels:
app.kubernetes.io/name: example-api
ports:
- protocol: TCP
port: 443
# CRL distribution points. These are public endpoints, so this rule is
# broad by necessity; narrow it to the published CRL host addresses, or
# send it through an egress gateway, if your CNI supports that.
# Omit this rule only if both disableCrlChecks and
# disableCrlChecksInternal are true, which is not recommended.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 80
- protocol: TCP
port: 443
Apply networkpolicy-inway-egress.yaml
kubectl apply -f networkpolicy-inway-egress.yaml
Adding a policy with policyTypes: [Egress] flips the pod to default-deny for
egress, so everything it needs to run must be added to the policy. For the Inway that is:
- DNS.
- The Controller registration API, the Manager's internal unauthenticated address, and the TxLog API.
- The authorization or AuthZen service, if you have enabled either.
- The backend API. Traffic from the sidecar originates from the Inway pod, so the same policy covers it.
- CRL distribution points, which are public HTTP endpoints. This is easy to forget, and omitting it causes TLS handshakes to fail once the cached CRLs expire rather than immediately — a delayed, confusing outage.
Loopback traffic from the Inway container to the sidecar is not subject to NetworkPolicy at all; it never leaves the pod's network namespace.
2.3 Verify
Confirm a pod that should not reach the API cannot:
kubectl run probe -n default --rm -it --restart=Never \
--image=curlimages/curl -- \
curl -sS --max-time 5 https://example-api.backend.svc.cluster.local/
This should time out. A connection refused or a TLS error means the policy is not being enforced — the connection reached the API.
Then confirm the Inway still works, by making a real request through a peer Outway.