Skip to main content

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​

ItemValue
Inway namespacefsc
Inway Helm releaseinway (so pod labels are app.kubernetes.io/name: open-fsc-inway, app.kubernetes.io/instance: inway)
Backend APIexample-api in namespace backend, listening on port 443
Internal CAthe 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's Host rewrite.
  • 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 on with proxy_ssl_trusted_certificate — without this nginx does not verify the API's certificate, which would trade one unauthenticated hop for another. proxy_ssl_name must stay as well: because proxy_pass now names an upstream, nginx would otherwise send api_backend as the SNI name and verify the certificate against it.
  • keepalive in the upstream block together with proxy_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:

  1. the client certificate subject is inway-open-fsc-inway.api-client.
  2. the Host header is the API's own hostname rather than 127.0.0.1:8090.
  3. The FSC-Request-Peer-Id HTTP 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.