diff --git a/mtls-app/Dockerfile b/mtls-app/Dockerfile new file mode 100644 index 00000000..6aafad0b --- /dev/null +++ b/mtls-app/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +COPY cmd ./cmd + +RUN go build -o /out/mtls-server ./cmd/server && \ + go build -o /out/mtls-client ./cmd/client + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +COPY --from=builder /out/mtls-server /usr/local/bin/mtls-server +COPY --from=builder /out/mtls-client /usr/local/bin/mtls-client + +ENV APP_BIN=mtls-server + +ENTRYPOINT ["/bin/sh", "-c", "exec /usr/local/bin/${APP_BIN}"] diff --git a/mtls-app/Dockerfile.certs b/mtls-app/Dockerfile.certs new file mode 100644 index 00000000..59af2ac3 --- /dev/null +++ b/mtls-app/Dockerfile.certs @@ -0,0 +1,11 @@ +FROM alpine:3.20 + +RUN apk add --no-cache openssl + +WORKDIR /work + +COPY certs/generate-certs.sh /usr/local/bin/generate-certs.sh + +RUN chmod +x /usr/local/bin/generate-certs.sh + +ENTRYPOINT ["/usr/local/bin/generate-certs.sh"] diff --git a/mtls-app/README.md b/mtls-app/README.md new file mode 100644 index 00000000..e304ecad --- /dev/null +++ b/mtls-app/README.md @@ -0,0 +1,117 @@ +# mTLS Go Sample + +This sample demonstrates mutual TLS between a Go HTTPS server and a Go client. The server only accepts requests from clients that present a certificate signed by the shared demo CA, and the client verifies the server certificate before sending the request. + +## Run with Docker Compose + +```bash +docker compose up --build +``` + +What happens: + +1. `cert-generator` creates a demo CA plus server and client certificates in a shared Docker volume. +2. `mtls-server` starts on `https://localhost:8443` and requires a valid client certificate. +3. `mtls-client` starts an API on `http://localhost:8080`. +4. Hitting `GET /hello` on the client API makes an mTLS request to `mtls-server` and returns the upstream response. + +Try it: + +```bash +curl http://localhost:8080/hello +``` + +## Run locally without Compose + +Generate demo certificates: + +```bash +docker build -t mtls-certs -f Dockerfile.certs . +docker run --rm \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + -v "$(pwd)/certs-local:/certs" \ + mtls-certs +``` + +Start the server: + +```bash +SERVER_CERT_FILE="$(pwd)/certs-local/server.crt" \ +SERVER_KEY_FILE="$(pwd)/certs-local/server.key" \ +CA_CERT_FILE="$(pwd)/certs-local/ca.crt" \ +go run ./cmd/server +``` + +In another terminal, run the client in API mode: + +```bash +CLIENT_CERT_FILE="$(pwd)/certs-local/client.crt" \ +CLIENT_KEY_FILE="$(pwd)/certs-local/client.key" \ +CA_CERT_FILE="$(pwd)/certs-local/ca.crt" \ +CLIENT_API_ADDR=":8080" \ +SERVER_URL="https://localhost:8443/hello" \ +go run ./cmd/client +``` + +Call the client API: + +```bash +curl http://localhost:8080/hello +``` + +Optional: one-shot client mode (no API server) is still available by omitting `CLIENT_API_ADDR`. + +## Big Payload Mode (50KB to 3MB) + +Run the client in big payload mode: + +```bash +CLIENT_CERT_FILE="$(pwd)/certs-local/client.crt" \ +CLIENT_KEY_FILE="$(pwd)/certs-local/client.key" \ +CA_CERT_FILE="$(pwd)/certs-local/ca.crt" \ +CLIENT_API_ADDR=":8080" \ +CLIENT_MODE="bigpayload" \ +BIGPAYLOAD_SERVER_URL="https://localhost:8443/payload" \ +go run ./cmd/client +``` + +The client exposes: + +- `POST /bigpayload` for large payload testing +- request and response sizes must be between `51200` bytes (50KB) and `3145728` bytes (3MB) +- response size defaults to request size, but can be overridden with header `X-Response-Size-Bytes` or query `response_size_bytes` + +Examples: + +```bash +# 50KB request, 50KB response +head -c 51200 /dev/zero | curl -sS \ + -X POST http://localhost:8080/bigpayload \ + -H "Content-Type: application/octet-stream" \ + --data-binary @- \ + -o /tmp/resp-50kb.bin +wc -c /tmp/resp-50kb.bin +``` + +```bash +# 1MB request, 2MB response +head -c 1048576 /dev/zero | curl -sS \ + -X POST "http://localhost:8080/bigpayload?response_size_bytes=2097152" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @- \ + -o /tmp/resp-2mb.bin +wc -c /tmp/resp-2mb.bin +``` + +```bash +# 3MB request, 3MB response +head -c 3145728 /dev/zero | curl -sS \ + -X POST http://localhost:8080/bigpayload \ + -H "Content-Type: application/octet-stream" \ + --data-binary @- \ + -o /tmp/resp-3mb.bin +wc -c /tmp/resp-3mb.bin +``` + +The client logs upstream payload sizes after every request, and the server logs the handled request/response sizes too. diff --git a/mtls-app/certs-local/ca.crt b/mtls-app/certs-local/ca.crt new file mode 100644 index 00000000..99753c86 --- /dev/null +++ b/mtls-app/certs-local/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDzCCAfegAwIBAgIUc39uWKH5kmEpI+Cx7lktYRB2LP0wDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMbXRscy1kZW1vLWNhMB4XDTI2MDMxODEwNDgxM1oXDTM2 +MDMxNTEwNDgxM1owFzEVMBMGA1UEAwwMbXRscy1kZW1vLWNhMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlbIK4npEc4acCjLfZvMe3rNddEeSxOFF9YFT +D5ZxD2VJK2jSnPGlaHQc4pZwPzkW4wYON0CIDHw8nRKzlZ8OgrH30QJkChK8BVz2 +6Zy1ZfkSRmBNdpCW4mi5rGfZPkTSxEQEugghJwwrlIMouJNZFuaQmg9QX30aJVpl +msXjCZOIJmAat4M1xM7hn2v3ZN/Cfz65nQdtXep3ml/IUFASZwt6z4tV/hWYJCv2 +WBqCfUhNeDOaOLe49QaARQtuJks6IzCBnU6FMd6JQiLQ57Eksp4T+fkpFC0OMl2/ +SRV92lRmO6Mr23mLdVUv14yIfgbhYDeXQJxkXLH7bYAbDgj5twIDAQABo1MwUTAd +BgNVHQ4EFgQUrnCiQeBzuCmF8FKUH0+UGOQwqtwwHwYDVR0jBBgwFoAUrnCiQeBz +uCmF8FKUH0+UGOQwqtwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEARb9GI1us9AsTJgebct6mg6/vfdae4ucpM038Wex9sHfppgrA6+DK3y9mn53a +rfreGbTXG4Daz55iapS8uoyaKTHFHUK5hUVqtqvnk4BB2H1Kmr5/yYc/Emzw07UI +uaGWz++4iY/hKQS7ha1+AJq1wEaUk0ZB7I5JM/gpgSYjjnkW4uce24EJO29CMnP1 +v49NfFRw/CJzJW1RLfASOrWugYXfXprbAxESFNXfeR1JA/BfuOVvTzBfcrcgjeRe +5QhpNCv46BzPV+ANKk5ImOPJZsOjn7wj8xGi4/0SCn0hS5QM0K6a1IkmlWwYoWuX +cIFhaiW8nD3+QPfXcpF6D1c2Iw== +-----END CERTIFICATE----- diff --git a/mtls-app/certs-local/ca.key b/mtls-app/certs-local/ca.key new file mode 100644 index 00000000..3b093865 --- /dev/null +++ b/mtls-app/certs-local/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCVsgriekRzhpwK +Mt9m8x7es110R5LE4UX1gVMPlnEPZUkraNKc8aVodBzilnA/ORbjBg43QIgMfDyd +ErOVnw6CsffRAmQKErwFXPbpnLVl+RJGYE12kJbiaLmsZ9k+RNLERAS6CCEnDCuU +gyi4k1kW5pCaD1BffRolWmWaxeMJk4gmYBq3gzXEzuGfa/dk38J/PrmdB21d6nea +X8hQUBJnC3rPi1X+FZgkK/ZYGoJ9SE14M5o4t7j1BoBFC24mSzojMIGdToUx3olC +ItDnsSSynhP5+SkULQ4yXb9JFX3aVGY7oyvbeYt1VS/XjIh+BuFgN5dAnGRcsftt +gBsOCPm3AgMBAAECggEACF/yGPKTMs1knHN1KSrP3tC1GUTJ0sbxpYcLMROPFrfp +bIrMQaiJQvtABHM7K2ZTv/a+Q9wR4HTw5S6/Kk9APhKb1S8njqK2rywgyjgQs/hH +y/UmUExNjLQkMx+KOWAbVIyjoQ7EYA1fwMrHs+/Wa6ARlfTmX7k9hbp1db+9cHMh +Hb+lruu3HoAvX3A3Xr84y77Oacgze2mwnsthFUlvbuPE54QcPkUV9Txe2yaPH7bQ +FrM5o36O7ZCGu+5SwC5JHBxSho+kRIpv2dspmOeGx8CcxSf+Own6066M8KnrDCAS +1YDw4q/ukGRoRzOg5eJRUCtEy7wk4aW3PmbidmQ+sQKBgQDMUooPGRnQJW5ySN1Y +/wLVHfo0zh/MD5qWXPcyLFyuF6h5A8jKp5fO2cndLZneEvwRg/T9LOvw3oKHtkiH +kahLY55jMMjRZFzFOkeW9qSu41GFcz+nv01750BowV/ef8ZUdSjEOLf4h2aLAaSm +XLf0MrifaQ6QVmOXaNMvCCv4UQKBgQC7joUgj3ody9Tn4EYElYJ4gFS9vcpkNEqk +E9R4Ehwv4vCnbVxF1szO44NufHqMcoqHU0jRkkIZa9ckaJCmzGvSwdg+sIQ/gKd6 +EcUhkbvfmsQW3l3xQMAFhbQJ9L7S+8yHCO3K18xYAzHetbmD1eLdOXbw7e+a2wKb +cr1oxj3XhwKBgQCZ3CHYgrdcdYN5DgOYy9ePMpbCguGQ4cMwLWt8Xcmg03HrRv1C +FfgMLRaEtp0ijLtCWVL3/4bgiD5VAeAWLopD0w1ndkoS2/e8EUntlWenxsgRrRqn +MDih8B8hg1S1ERUBboQ3Vtq6jQOb863QFQv1GOjMKelsqZEvaCF3TjkGMQKBgQCk +j9Hi1cCRsCxoHvGQSBYn4IF50bJo5TCwce20RD+TDI2WeW/Cn0soI5tIL9Peswk0 +3zA/IRL59xLXkR+KGkZor0grCPmgNiO8CSdr4tByyvpODmFisitJLRzgt2tO9ztn +J8Bsf5d9iaASBmR1dg8Nh8QCdOIMfyj0d2IVMgtEtQKBgQCVyEthJe3TFOqx+Odt +vrZkSucRTNiFY1DfuQsPlTHx7xhrvyNQqjfY8Bf3iMXyg1Ja0t/XeF1x7J3GHEAC +mP4e2otQDTos/xyEVIOeL6AMfb+zYahE93ileO1+L25aY8XtndpwxRH6i8saPQ2/ +5wASOQS0jdFswkQ1MRkkdJq05A== +-----END PRIVATE KEY----- diff --git a/mtls-app/certs-local/client.crt b/mtls-app/certs-local/client.crt new file mode 100644 index 00000000..27ab0025 --- /dev/null +++ b/mtls-app/certs-local/client.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIUKYCVBJIMLyrpaoxa+swIZSW2LNYwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMbXRscy1kZW1vLWNhMB4XDTI2MDMxODEwNDgxNFoXDTM2 +MDMxNTEwNDgxNFowFjEUMBIGA1UEAwwLbXRscy1jbGllbnQwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCnPd1wuhc8S92VzqqyJnRs4kWyiblsqdcroUKx +rJd/+4WFA0nB65OwySf7+LAU1zHEI7NH05WWxENb278huay5m4KkeA/kSADKtN3m +gk1wGRQ+ATMw5xqz3R94kXuP6pXGOj/RrtvtmwQutssOvvl/ZtnMYJ3mHafvGecS +vt44ASfSepT9drqZ8jkhKDqoKtk4qC32WtK6ijvgTLaKyPxHNGEet4H0/j4cGI5X +ri28Ngqg3nSL2F3vZGVOnHrJQAcjkVAWHKnyu5iMqtNN8Pm51mX1wleFP7gwaoXk +my4xMk7bW3ppxM27LBRqvhlR6+WOEb/cvHY5afT09acqtL8zAgMBAAGjVzBVMBMG +A1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBSDzYTdSVbnT3PXieRdXSOaLEM3 +UjAfBgNVHSMEGDAWgBSucKJB4HO4KYXwUpQfT5QY5DCq3DANBgkqhkiG9w0BAQsF +AAOCAQEAgbQFAtnBeCE4JMktbXp0yE4W1A3NK9XCP74/LNnzya9Ft9gkQhsd2O1P +q26OCAsgsxLf8HQRUDOP6v8Pnd45tAuGa/cet60zbsBwRXy9iLGziysSBG4yT35/ +4aa92zyuLGlxRFe3u8TM0QPDMWkn7RzltBMKFYZSXkn0/MiT9oO5BRnyMOyanq3w +wRE+8YaNRKQ+zM2M0bMW0N9leo3mnVaJ1FeFvwHiSxtsZHJlUSAULPGDm/zcWJYa +VZRZ/GOSiY3puSAYvS3xNkwYS+2fIAXX7DxqvxKZmCd8+7pVPeO/yE2kzhWeWJ0Z +t+AgI3x3nFeaTtenUGmyD24f+ZQ+Yg== +-----END CERTIFICATE----- diff --git a/mtls-app/certs-local/client.key b/mtls-app/certs-local/client.key new file mode 100644 index 00000000..6b2c0c79 --- /dev/null +++ b/mtls-app/certs-local/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnPd1wuhc8S92V +zqqyJnRs4kWyiblsqdcroUKxrJd/+4WFA0nB65OwySf7+LAU1zHEI7NH05WWxENb +278huay5m4KkeA/kSADKtN3mgk1wGRQ+ATMw5xqz3R94kXuP6pXGOj/RrtvtmwQu +tssOvvl/ZtnMYJ3mHafvGecSvt44ASfSepT9drqZ8jkhKDqoKtk4qC32WtK6ijvg +TLaKyPxHNGEet4H0/j4cGI5Xri28Ngqg3nSL2F3vZGVOnHrJQAcjkVAWHKnyu5iM +qtNN8Pm51mX1wleFP7gwaoXkmy4xMk7bW3ppxM27LBRqvhlR6+WOEb/cvHY5afT0 +9acqtL8zAgMBAAECggEATa9ojeT7RxhsiRpzYwaG3U8sFfdwqP+pwgwJ6XNk+l+x +EWzKFaiitaNzDdHipQOjC9uTe0FXAq4PJfvI6FcR2zPX9yMIKr+hkod6bglIBFK5 ++uVezJAFcNg9tqlJjrvmr6o+G94QLepsgnCJmUNvrNTvRcb5gbtz1xaepjAAFF4A +uUr/8ZTRaf1IIXux3Jf4uyTQAPhr3fEvj4HD3G1DTOIL6QKPsx+G5EUn0WCE7YP+ +t5CvmeucIr3zzoErKmUdSkq6lAe9lQcUtV1eGHy0aWTsOvf1NGhXxNCFlyTNjUpQ +IlVZBZikw5+6skQIROEHrwl7Cnf0/E8X+LdwlrlsgQKBgQDVbmgPbS8Fir9LCGDX +yDuHTXT1y1Aes5zqgNhDoNY5NRHerCV2shMgFw1ZDrsh+bYeTypHTMT3l5yHehjy +wMS8NHnYTjU7PvotLL7H7VbcxSZL90+EXUQ9/4pWMAgZs1NRsKmT+pdaSaqvHgQ/ +WrgrY/J34/kTxDuFJfUIwMc+gQKBgQDImQ/IUwZLq+Z7/jchWNqAA2oOIK+3TKb6 +cK23oPm/irrHRvOi5XJ7SOKoZCEuVICnHDI/XQLXZhJTg68Qm6RWQO9bTPUa4Vhd +Q6yxfu13hv1x6xJqPR1PhVUBQEMiq0HVGolfbGr7okZ02k2Pd+qbm9oyt7i2rcZ5 +ds1g3nSLswKBgAzXSq167TRRJ7c09taktmgqkdnj9JsURWGahOh0uc7RUZTrGInu +ptXsbSIpj7q4kmt6adnGVadr2MAR6YRZcry8D4SjF/LLlDO5mHTg47P+rJIve/pD +vkJYqJMM6r/ZGS82CM3datPE0N8eWDUTmTcLGWB7N9YnnUkign6XUqWBAoGBAMgk +TybkD2f4vyH/ZioTaQ5IWcx2uFr+U6uUOP750bVWST0CgZuJqktvURYJsUF0dlhF +Pa0Ss/8NjENfI5BCehjE+QvzIKoNJAkJuIfvyCZ1vPGoRNtS1qe8tC9nWpSAolJp +A579oVAnfHyiQrheQOm4+l+YBufdQiV2bzuzOD0ZAoGASyTqy96n1k/MA705yZbG +1b7GhCU4ifR9W6FYFa2vcIfzVXtARCin6EeKngOuWl1vLOy9OoQYbupaR0jc7l46 +5kM788vWVyj2rccNDKfFuqlrRk3BWjBvXeY6f7F6mCelQFXd6Q9mCFovr23eAqdu +o0ezp5dWuCbfkINBjafZVHY= +-----END PRIVATE KEY----- diff --git a/mtls-app/certs-local/client.pem b/mtls-app/certs-local/client.pem new file mode 100644 index 00000000..1dcd476d --- /dev/null +++ b/mtls-app/certs-local/client.pem @@ -0,0 +1,47 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnPd1wuhc8S92V +zqqyJnRs4kWyiblsqdcroUKxrJd/+4WFA0nB65OwySf7+LAU1zHEI7NH05WWxENb +278huay5m4KkeA/kSADKtN3mgk1wGRQ+ATMw5xqz3R94kXuP6pXGOj/RrtvtmwQu +tssOvvl/ZtnMYJ3mHafvGecSvt44ASfSepT9drqZ8jkhKDqoKtk4qC32WtK6ijvg +TLaKyPxHNGEet4H0/j4cGI5Xri28Ngqg3nSL2F3vZGVOnHrJQAcjkVAWHKnyu5iM +qtNN8Pm51mX1wleFP7gwaoXkmy4xMk7bW3ppxM27LBRqvhlR6+WOEb/cvHY5afT0 +9acqtL8zAgMBAAECggEATa9ojeT7RxhsiRpzYwaG3U8sFfdwqP+pwgwJ6XNk+l+x +EWzKFaiitaNzDdHipQOjC9uTe0FXAq4PJfvI6FcR2zPX9yMIKr+hkod6bglIBFK5 ++uVezJAFcNg9tqlJjrvmr6o+G94QLepsgnCJmUNvrNTvRcb5gbtz1xaepjAAFF4A +uUr/8ZTRaf1IIXux3Jf4uyTQAPhr3fEvj4HD3G1DTOIL6QKPsx+G5EUn0WCE7YP+ +t5CvmeucIr3zzoErKmUdSkq6lAe9lQcUtV1eGHy0aWTsOvf1NGhXxNCFlyTNjUpQ +IlVZBZikw5+6skQIROEHrwl7Cnf0/E8X+LdwlrlsgQKBgQDVbmgPbS8Fir9LCGDX +yDuHTXT1y1Aes5zqgNhDoNY5NRHerCV2shMgFw1ZDrsh+bYeTypHTMT3l5yHehjy +wMS8NHnYTjU7PvotLL7H7VbcxSZL90+EXUQ9/4pWMAgZs1NRsKmT+pdaSaqvHgQ/ +WrgrY/J34/kTxDuFJfUIwMc+gQKBgQDImQ/IUwZLq+Z7/jchWNqAA2oOIK+3TKb6 +cK23oPm/irrHRvOi5XJ7SOKoZCEuVICnHDI/XQLXZhJTg68Qm6RWQO9bTPUa4Vhd +Q6yxfu13hv1x6xJqPR1PhVUBQEMiq0HVGolfbGr7okZ02k2Pd+qbm9oyt7i2rcZ5 +ds1g3nSLswKBgAzXSq167TRRJ7c09taktmgqkdnj9JsURWGahOh0uc7RUZTrGInu +ptXsbSIpj7q4kmt6adnGVadr2MAR6YRZcry8D4SjF/LLlDO5mHTg47P+rJIve/pD +vkJYqJMM6r/ZGS82CM3datPE0N8eWDUTmTcLGWB7N9YnnUkign6XUqWBAoGBAMgk +TybkD2f4vyH/ZioTaQ5IWcx2uFr+U6uUOP750bVWST0CgZuJqktvURYJsUF0dlhF +Pa0Ss/8NjENfI5BCehjE+QvzIKoNJAkJuIfvyCZ1vPGoRNtS1qe8tC9nWpSAolJp +A579oVAnfHyiQrheQOm4+l+YBufdQiV2bzuzOD0ZAoGASyTqy96n1k/MA705yZbG +1b7GhCU4ifR9W6FYFa2vcIfzVXtARCin6EeKngOuWl1vLOy9OoQYbupaR0jc7l46 +5kM788vWVyj2rccNDKfFuqlrRk3BWjBvXeY6f7F6mCelQFXd6Q9mCFovr23eAqdu +o0ezp5dWuCbfkINBjafZVHY= +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIUKYCVBJIMLyrpaoxa+swIZSW2LNYwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMbXRscy1kZW1vLWNhMB4XDTI2MDMxODEwNDgxNFoXDTM2 +MDMxNTEwNDgxNFowFjEUMBIGA1UEAwwLbXRscy1jbGllbnQwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCnPd1wuhc8S92VzqqyJnRs4kWyiblsqdcroUKx +rJd/+4WFA0nB65OwySf7+LAU1zHEI7NH05WWxENb278huay5m4KkeA/kSADKtN3m +gk1wGRQ+ATMw5xqz3R94kXuP6pXGOj/RrtvtmwQutssOvvl/ZtnMYJ3mHafvGecS +vt44ASfSepT9drqZ8jkhKDqoKtk4qC32WtK6ijvgTLaKyPxHNGEet4H0/j4cGI5X +ri28Ngqg3nSL2F3vZGVOnHrJQAcjkVAWHKnyu5iMqtNN8Pm51mX1wleFP7gwaoXk +my4xMk7bW3ppxM27LBRqvhlR6+WOEb/cvHY5afT09acqtL8zAgMBAAGjVzBVMBMG +A1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBSDzYTdSVbnT3PXieRdXSOaLEM3 +UjAfBgNVHSMEGDAWgBSucKJB4HO4KYXwUpQfT5QY5DCq3DANBgkqhkiG9w0BAQsF +AAOCAQEAgbQFAtnBeCE4JMktbXp0yE4W1A3NK9XCP74/LNnzya9Ft9gkQhsd2O1P +q26OCAsgsxLf8HQRUDOP6v8Pnd45tAuGa/cet60zbsBwRXy9iLGziysSBG4yT35/ +4aa92zyuLGlxRFe3u8TM0QPDMWkn7RzltBMKFYZSXkn0/MiT9oO5BRnyMOyanq3w +wRE+8YaNRKQ+zM2M0bMW0N9leo3mnVaJ1FeFvwHiSxtsZHJlUSAULPGDm/zcWJYa +VZRZ/GOSiY3puSAYvS3xNkwYS+2fIAXX7DxqvxKZmCd8+7pVPeO/yE2kzhWeWJ0Z +t+AgI3x3nFeaTtenUGmyD24f+ZQ+Yg== +-----END CERTIFICATE----- diff --git a/mtls-app/certs-local/server.crt b/mtls-app/certs-local/server.crt new file mode 100644 index 00000000..e7814889 --- /dev/null +++ b/mtls-app/certs-local/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNTCCAh2gAwIBAgIUKYCVBJIMLyrpaoxa+swIZSW2LNUwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMbXRscy1kZW1vLWNhMB4XDTI2MDMxODEwNDgxNFoXDTM2 +MDMxNTEwNDgxNFowFjEUMBIGA1UEAwwLbXRscy1zZXJ2ZXIwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCGsc5jWJjzga+4KIOzv2Skyv3i5PZE/vCmZKkU +rD8KdkkA/meeFWrxPq68+0ZCIHaLG7tUea4lHl9Lm1dsXSKxDKfk/DbA8BaGFsgU +nYFbNwBLNnzCGu8mHx7Gw45ojSF2MBftQs2IiMQAfJCQJ73AMRj+DGm228YD9jv2 +nvU6TmxvsSrdUIVaE9bbEh1cnwwOp4xmYTRrN+4NS8eDprdOqLfhfs+Mcr+nfMbb +lK9geHu1aYsmaE0HCU41HSOGbqiqKO/SqokTKpc2Ov5RLBsAzmtgkOzUQKixlaFl +9rqvaez9kMk6A0Na2xT+BiSOBlcV3mBLXnvK3n+BDNOX87i3AgMBAAGjejB4MCEG +A1UdEQQaMBiCC210bHMtc2VydmVygglsb2NhbGhvc3QwEwYDVR0lBAwwCgYIKwYB +BQUHAwEwHQYDVR0OBBYEFCEjdJWrQcLK++YxjY6oZc1GAlaEMB8GA1UdIwQYMBaA +FK5wokHgc7gphfBSlB9PlBjkMKrcMA0GCSqGSIb3DQEBCwUAA4IBAQBJW1WA54iw +2CPKg4mAuTpOOvoymxwVkftZ82FkoiesXqaGoORhVU2tn1wzjACVd82BkOiT9MTs +yFdy+qVujluGGqTItYqDMNJwDuyOYVMpHTRcAU3WvIHFXi901qevSdApd8qs+f7T +UAvpEVWFJxbZeDxgazD63dFast478zBbFR2CLGQtz03woAbeetoQj8qabu2v6gtd +MTQeud7iqEWt8ny9cKLm4A5dxKdKC7Sm2AT4cZdMK+cHkNsmjyKJGLN+32s9Vk1X +lVB1BSEcH7zaMwDd/cF5F/Agknu/cxuJT4V5sib0nMyu8+/NT009TmHRJWzPQRI2 +3D3FfOSpOe+k +-----END CERTIFICATE----- diff --git a/mtls-app/certs-local/server.key b/mtls-app/certs-local/server.key new file mode 100644 index 00000000..74e365b4 --- /dev/null +++ b/mtls-app/certs-local/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCGsc5jWJjzga+4 +KIOzv2Skyv3i5PZE/vCmZKkUrD8KdkkA/meeFWrxPq68+0ZCIHaLG7tUea4lHl9L +m1dsXSKxDKfk/DbA8BaGFsgUnYFbNwBLNnzCGu8mHx7Gw45ojSF2MBftQs2IiMQA +fJCQJ73AMRj+DGm228YD9jv2nvU6TmxvsSrdUIVaE9bbEh1cnwwOp4xmYTRrN+4N +S8eDprdOqLfhfs+Mcr+nfMbblK9geHu1aYsmaE0HCU41HSOGbqiqKO/SqokTKpc2 +Ov5RLBsAzmtgkOzUQKixlaFl9rqvaez9kMk6A0Na2xT+BiSOBlcV3mBLXnvK3n+B +DNOX87i3AgMBAAECggEAICrrqt84XANPV3BZj754R0DxZFQhGnY2O87TcIv4XEPG +iJW5YlAkI6xAKALskxNUrEE5umF6/QNlZ9WYCdmuVNE8cZvoaaiNAIYFT6MUBxg6 +GjxPjD3JenW5MGf4pTB7WtH+jNvE4UQkZydYkQzkrLctDFMjlhejkUOnq2zoDP3g +Bg5LdCS6AfHjSS/IqAZEO0uev1trY/jlsV3+R1u6biqPuOsMgGQR8gcu3b/tlzUM +1OkuyZhvgVoLPZtHhAkEHPLMWyWe9Zqlvvq9IoAKBwAPb9APpC0ROA8U/OQD/r4Y +3G4M6JhRMG8qNWndwzEz6NJQuLtMzsj6u1tHTGoZYQKBgQC7KGXxsFE60250VCz4 +iMGlQXehpoSKg72tS9Pbu0Iv2qcioGbSZkezzqHzvIvsuaJQ6fmYtOTJ6lpYisa/ +z1cImFw0Ok2I7A2FbVbzDdrIEddAZKDnrgttj6wl6FgIypiFIP1AT62rMy8VPnpE +c5vlOQdODsE8yKfE2us5ZJRClwKBgQC4PTzarbGKBr3mLWBK7sF8RKGViKHgD8Ig +udGf4k0HU/9DI1Q9C7oiLhGcmUat+aViTN9QRlRdFDwQRkkwIW6WzeXuzKFNdxPa +7Ht1z9kKIY/pcEFR1On2EOvP+KKhZPF4cDFoeGjIpay5ev7+/bP00ZM9ujBnGsjM +Y9bTwVSe4QKBgBYTM8MIGuynV5Xc/9jouH53dFbavzNfSpYQJZL7SVk/nwsUhEw4 +yChLLQsEqDRpyN1mW4xJedrfC3z6EWs6V3eqEOYQImkN/qJIPUM51R5YDF2KAPiS +rMJledaWyxtuWgMJ2xUk0MUqqlkFH4LHaBHnYhcw4lX7DN7JO4lvdZVNAoGADNpY +0Hillhd6UACCYzfcz6qKC0CI6nSu+lF8SkcjUIuPl0NzsP6Mca39FIus3p4352+t +dJAzenra5dfBa1YpvOOIUux7pEfWXsN4qXNilM5al9J4/Bh6aewsR0n1LoU4Q0qw +Z7VeugC02Au4lllkoIOuXfQLRGYd9ARTDFrEaIECgYEAjeUs9maFqWo2LMVH+0Hd +BywcXi6+ZZNFmWrVie3MYud02npWHzNfc3eG2Z2zoREnHquH2c21uzrh+Fp6G5y6 +XdD2Crgl+vJw5pgB16QPrT45RU3I1iEh5B/jUXfgqxfsg9LmQnHEcvoSPBNk0bhi +KOcu03kbwWjH5ytS/Ig6XYw= +-----END PRIVATE KEY----- diff --git a/mtls-app/certs/generate-certs.sh b/mtls-app/certs/generate-certs.sh new file mode 100755 index 00000000..21e55341 --- /dev/null +++ b/mtls-app/certs/generate-certs.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +CERT_DIR="${CERT_DIR:-/certs}" +HOST_UID="${HOST_UID:-}" +HOST_GID="${HOST_GID:-}" +mkdir -p "${CERT_DIR}" + +cat > "${CERT_DIR}/server-openssl.cnf" <<'CNF' +[req] +distinguished_name = dn +req_extensions = req_ext +prompt = no + +[dn] +CN = mtls-server + +[req_ext] +subjectAltName = @alt_names +extendedKeyUsage = serverAuth + +[alt_names] +DNS.1 = mtls-server +DNS.2 = localhost +CNF + +cat > "${CERT_DIR}/client-openssl.cnf" <<'CNF' +[req] +distinguished_name = dn +req_extensions = req_ext +prompt = no + +[dn] +CN = mtls-client + +[req_ext] +extendedKeyUsage = clientAuth +CNF + +openssl genrsa -out "${CERT_DIR}/ca.key" 2048 +openssl req -x509 -new -nodes -key "${CERT_DIR}/ca.key" -sha256 -days 3650 \ + -out "${CERT_DIR}/ca.crt" -subj "/CN=mtls-demo-ca" + +openssl genrsa -out "${CERT_DIR}/server.key" 2048 +openssl req -new -key "${CERT_DIR}/server.key" -out "${CERT_DIR}/server.csr" \ + -config "${CERT_DIR}/server-openssl.cnf" +openssl x509 -req -in "${CERT_DIR}/server.csr" -CA "${CERT_DIR}/ca.crt" \ + -CAkey "${CERT_DIR}/ca.key" -CAcreateserial -out "${CERT_DIR}/server.crt" \ + -days 3650 -sha256 -extensions req_ext -extfile "${CERT_DIR}/server-openssl.cnf" + +openssl genrsa -out "${CERT_DIR}/client.key" 2048 +openssl req -new -key "${CERT_DIR}/client.key" -out "${CERT_DIR}/client.csr" \ + -config "${CERT_DIR}/client-openssl.cnf" +openssl x509 -req -in "${CERT_DIR}/client.csr" -CA "${CERT_DIR}/ca.crt" \ + -CAkey "${CERT_DIR}/ca.key" -CAcreateserial -out "${CERT_DIR}/client.crt" \ + -days 3650 -sha256 -extensions req_ext -extfile "${CERT_DIR}/client-openssl.cnf" + +rm -f \ + "${CERT_DIR}/server.csr" \ + "${CERT_DIR}/client.csr" \ + "${CERT_DIR}/ca.srl" \ + "${CERT_DIR}/server-openssl.cnf" \ + "${CERT_DIR}/client-openssl.cnf" + +chmod 600 \ + "${CERT_DIR}/ca.key" \ + "${CERT_DIR}/server.key" \ + "${CERT_DIR}/client.key" + +chmod 644 \ + "${CERT_DIR}/ca.crt" \ + "${CERT_DIR}/server.crt" \ + "${CERT_DIR}/client.crt" + +if [ -n "${HOST_UID}" ] && [ -n "${HOST_GID}" ]; then + chown -R "${HOST_UID}:${HOST_GID}" "${CERT_DIR}" +fi + +echo "certificates generated in ${CERT_DIR}" diff --git a/mtls-app/cmd/client/main.go b/mtls-app/cmd/client/main.go new file mode 100644 index 00000000..3e98f0f1 --- /dev/null +++ b/mtls-app/cmd/client/main.go @@ -0,0 +1,355 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +const ( + defaultServerURL = "https://mtls-server:8443/hello" + defaultBigPayloadURL = "https://mtls-server:8443/payload" + defaultCACert = "/certs/ca.crt" + defaultCertFile = "/certs/client.crt" + defaultKeyFile = "/certs/client.key" + minPayloadSizeBytes = 50 * 1024 + maxPayloadSizeBytes = 3 * 1024 * 1024 + responseSizeHeader = "X-Response-Size-Bytes" + modeDefault = "default" + modeBigPayload = "bigpayload" + contentTypeOctetStream = "application/octet-stream" + contentTypeJSON = "application/json" + defaultSingleRunAttempts = 12 + defaultAPIRetryAttempts = 3 +) + +type upstreamResponse struct { + status string + statusCode int + contentType string + body []byte +} + +type upstreamRequest struct { + method string + url string + body []byte + contentType string + headers map[string]string +} + +var errBodyTooLarge = errors.New("request body exceeds maximum size") + +func main() { + serverURL := getenv("SERVER_URL", defaultServerURL) + bigPayloadURL := getenv("BIGPAYLOAD_SERVER_URL", deriveBigPayloadURL(serverURL)) + caCertPath := getenv("CA_CERT_FILE", defaultCACert) + certFile := getenv("CLIENT_CERT_FILE", defaultCertFile) + keyFile := getenv("CLIENT_KEY_FILE", defaultKeyFile) + apiAddr := os.Getenv("CLIENT_API_ADDR") + clientMode := getenv("CLIENT_MODE", modeDefault) + if clientMode != modeDefault && clientMode != modeBigPayload { + log.Printf("unknown CLIENT_MODE=%q, falling back to %q", clientMode, modeDefault) + clientMode = modeDefault + } + + rootCAs, err := loadCertPool(caCertPath) + if err != nil { + log.Fatalf("load CA cert: %v", err) + } + + clientCert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("load client certificate: %v", err) + } + + httpClient := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + Certificates: []tls.Certificate{clientCert}, + ServerName: "mtls-server", + }, + }, + } + + if apiAddr != "" { + startAPI(apiAddr, serverURL, bigPayloadURL, clientMode, httpClient) + return + } + + req := upstreamRequest{ + method: http.MethodGet, + url: serverURL, + } + if clientMode == modeBigPayload { + body := bytes.Repeat([]byte("b"), minPayloadSizeBytes) + req = upstreamRequest{ + method: http.MethodPost, + url: bigPayloadURL, + body: body, + contentType: contentTypeOctetStream, + headers: map[string]string{ + responseSizeHeader: strconv.Itoa(minPayloadSizeBytes), + }, + } + } + + resp, err := requestWithRetries(context.Background(), httpClient, req, defaultSingleRunAttempts) + if err != nil { + log.Fatalf("request failed after retries: %v", err) + } + + fmt.Printf("response status: %s\n", resp.status) + if clientMode == modeBigPayload { + fmt.Printf("response body size: %d bytes\n", len(resp.body)) + return + } + fmt.Printf("response body: %s\n", string(resp.body)) +} + +func startAPI(addr, serverURL, bigPayloadURL, clientMode string, httpClient *http.Client) { + mux := http.NewServeMux() + + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { + resp, err := requestWithRetries(r.Context(), httpClient, upstreamRequest{ + method: http.MethodGet, + url: serverURL, + }, defaultAPIRetryAttempts) + if err != nil { + http.Error(w, fmt.Sprintf("upstream request failed: %v", err), http.StatusBadGateway) + return + } + + contentType := resp.contentType + if contentType == "" { + contentType = contentTypeJSON + } + + w.Header().Set("Content-Type", contentType) + w.WriteHeader(resp.statusCode) + _, _ = w.Write(resp.body) + }) + + if clientMode == modeBigPayload { + mux.HandleFunc("/bigpayload", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed; use POST", http.StatusMethodNotAllowed) + return + } + + reqBody, err := readBoundedBody(r.Body, maxPayloadSizeBytes) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, errBodyTooLarge) { + status = http.StatusRequestEntityTooLarge + } + http.Error(w, err.Error(), status) + return + } + + if err := validatePayloadSize(len(reqBody), "request"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + respSize, err := resolveResponseSize(r, len(reqBody)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + contentType := r.Header.Get("Content-Type") + if contentType == "" { + contentType = contentTypeOctetStream + } + + resp, err := requestWithRetries(r.Context(), httpClient, upstreamRequest{ + method: http.MethodPost, + url: bigPayloadURL, + body: reqBody, + contentType: contentType, + headers: map[string]string{ + responseSizeHeader: strconv.Itoa(respSize), + }, + }, defaultAPIRetryAttempts) + if err != nil { + http.Error(w, fmt.Sprintf("upstream payload request failed: %v", err), http.StatusBadGateway) + return + } + + upstreamContentType := resp.contentType + if upstreamContentType == "" { + upstreamContentType = contentTypeOctetStream + } + + w.Header().Set("Content-Type", upstreamContentType) + w.WriteHeader(resp.statusCode) + _, _ = w.Write(resp.body) + log.Printf("client /bigpayload request complete req_size=%dB resp_size=%dB", len(reqBody), len(resp.body)) + }) + } + + server := &http.Server{ + Addr: addr, + Handler: mux, + } + + log.Printf("mTLS client API listening on %s", addr) + log.Printf("GET /hello -> calls %s over mTLS", serverURL) + if clientMode == modeBigPayload { + log.Printf("POST /bigpayload -> calls %s over mTLS with payloads [%dB, %dB]", bigPayloadURL, minPayloadSizeBytes, maxPayloadSizeBytes) + } + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("client API stopped: %v", err) + } +} + +func requestWithRetries(ctx context.Context, httpClient *http.Client, reqCfg upstreamRequest, maxAttempts int) (*upstreamResponse, error) { + if maxAttempts < 1 { + maxAttempts = 1 + } + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + resp, err := requestOnce(ctx, httpClient, reqCfg) + if err == nil { + return resp, nil + } + + lastErr = err + log.Printf("request attempt %d failed: %v", attempt, err) + + if attempt < maxAttempts { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(1 * time.Second): + } + } + } + + return nil, lastErr +} + +func requestOnce(ctx context.Context, httpClient *http.Client, reqCfg upstreamRequest) (*upstreamResponse, error) { + reqBody := bytes.NewReader(reqCfg.body) + req, err := http.NewRequestWithContext(ctx, reqCfg.method, reqCfg.url, reqBody) + if err != nil { + return nil, err + } + if reqCfg.contentType != "" { + req.Header.Set("Content-Type", reqCfg.contentType) + } + for key, value := range reqCfg.headers { + req.Header.Set(key, value) + } + + start := time.Now() + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + log.Printf("upstream request complete method=%s url=%s status=%d req_size=%dB resp_size=%dB duration_ms=%d", + reqCfg.method, reqCfg.url, resp.StatusCode, len(reqCfg.body), len(body), time.Since(start).Milliseconds()) + + return &upstreamResponse{ + status: resp.Status, + statusCode: resp.StatusCode, + contentType: resp.Header.Get("Content-Type"), + body: body, + }, nil +} + +func loadCertPool(path string) (*x509.CertPool, error) { + certPEM, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(certPEM) { + return nil, errors.New("invalid PEM data for CA certificate") + } + + return pool, nil +} + +func readBoundedBody(r io.Reader, maxBytes int) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r, int64(maxBytes)+1)) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + if len(body) > maxBytes { + return nil, errBodyTooLarge + } + return body, nil +} + +func validatePayloadSize(size int, kind string) error { + if size < minPayloadSizeBytes || size > maxPayloadSizeBytes { + return fmt.Errorf("%s payload size must be between %d and %d bytes, got %d", kind, minPayloadSizeBytes, maxPayloadSizeBytes, size) + } + return nil +} + +func resolveResponseSize(r *http.Request, fallback int) (int, error) { + sizeRaw := r.URL.Query().Get("response_size_bytes") + if sizeRaw == "" { + sizeRaw = r.Header.Get(responseSizeHeader) + } + if sizeRaw == "" { + return fallback, nil + } + + size, err := strconv.Atoi(sizeRaw) + if err != nil { + return 0, fmt.Errorf("invalid response size %q", sizeRaw) + } + if err := validatePayloadSize(size, "response"); err != nil { + return 0, err + } + return size, nil +} + +func deriveBigPayloadURL(serverURL string) string { + parsed, err := url.Parse(serverURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return defaultBigPayloadURL + } + parsed.Path = "/payload" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/mtls-app/cmd/server/main.go b/mtls-app/cmd/server/main.go new file mode 100644 index 00000000..58a0ea3f --- /dev/null +++ b/mtls-app/cmd/server/main.go @@ -0,0 +1,191 @@ +package main + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" +) + +const ( + defaultAddr = ":8443" + defaultCACert = "/certs/ca.crt" + defaultCertFile = "/certs/server.crt" + defaultKeyFile = "/certs/server.key" + minPayloadSizeBytes = 50 * 1024 + maxPayloadSizeBytes = 3 * 1024 * 1024 + responseSizeHeader = "X-Response-Size-Bytes" + contentTypeJSON = "application/json" + contentTypeOctetStream = "application/octet-stream" +) + +type helloResponse struct { + Message string `json:"message"` + ClientCommon string `json:"client_common_name"` +} + +var errBodyTooLarge = errors.New("request body exceeds maximum size") + +func main() { + addr := getenv("SERVER_ADDR", defaultAddr) + caCertPath := getenv("CA_CERT_FILE", defaultCACert) + certFile := getenv("SERVER_CERT_FILE", defaultCertFile) + keyFile := getenv("SERVER_KEY_FILE", defaultKeyFile) + + clientCAPool, err := loadCertPool(caCertPath) + if err != nil { + log.Fatalf("load client CA: %v", err) + } + + serverCert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("load server certificate: %v", err) + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAPool, + Certificates: []tls.Certificate{serverCert}, + } + + mux := http.NewServeMux() + mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + http.Error(w, "client certificate required", http.StatusUnauthorized) + return + } + + clientCert := r.TLS.PeerCertificates[0] + resp := helloResponse{ + Message: "mTLS handshake complete", + ClientCommon: clientCert.Subject.CommonName, + } + + w.Header().Set("Content-Type", contentTypeJSON) + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("served /hello for client CN=%q", clientCert.Subject.CommonName) + }) + + mux.HandleFunc("/payload", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed; use POST", http.StatusMethodNotAllowed) + return + } + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + http.Error(w, "client certificate required", http.StatusUnauthorized) + return + } + + reqBody, err := readBoundedBody(r.Body, maxPayloadSizeBytes) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, errBodyTooLarge) { + status = http.StatusRequestEntityTooLarge + } + http.Error(w, err.Error(), status) + return + } + if err := validatePayloadSize(len(reqBody), "request"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + respSize, err := resolveResponseSize(r, len(reqBody)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + respBody := bytes.Repeat([]byte("r"), respSize) + w.Header().Set("Content-Type", contentTypeOctetStream) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(respBody); err != nil { + log.Printf("write /payload response failed: %v", err) + return + } + + clientCN := r.TLS.PeerCertificates[0].Subject.CommonName + log.Printf("served /payload for client CN=%q req_size=%dB resp_size=%dB", clientCN, len(reqBody), len(respBody)) + }) + + server := &http.Server{ + Addr: addr, + Handler: mux, + TLSConfig: tlsConfig, + } + + log.Printf("mTLS server listening on %s", addr) + if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + log.Fatalf("server stopped: %v", err) + } +} + +func loadCertPool(path string) (*x509.CertPool, error) { + certPEM, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(certPEM) { + return nil, errors.New("invalid PEM data for CA certificate") + } + + return pool, nil +} + +func readBoundedBody(r io.Reader, maxBytes int) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r, int64(maxBytes)+1)) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + if len(body) > maxBytes { + return nil, errBodyTooLarge + } + return body, nil +} + +func validatePayloadSize(size int, kind string) error { + if size < minPayloadSizeBytes || size > maxPayloadSizeBytes { + return fmt.Errorf("%s payload size must be between %d and %d bytes, got %d", kind, minPayloadSizeBytes, maxPayloadSizeBytes, size) + } + return nil +} + +func resolveResponseSize(r *http.Request, fallback int) (int, error) { + sizeRaw := r.URL.Query().Get("response_size_bytes") + if sizeRaw == "" { + sizeRaw = r.Header.Get(responseSizeHeader) + } + if sizeRaw == "" { + return fallback, nil + } + + size, err := strconv.Atoi(sizeRaw) + if err != nil { + return 0, fmt.Errorf("invalid response size %q", sizeRaw) + } + if err := validatePayloadSize(size, "response"); err != nil { + return 0, err + } + return size, nil +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/mtls-app/docker-compose.yml b/mtls-app/docker-compose.yml new file mode 100644 index 00000000..06e4baa8 --- /dev/null +++ b/mtls-app/docker-compose.yml @@ -0,0 +1,45 @@ +services: + cert-generator: + build: + context: . + dockerfile: Dockerfile.certs + container_name: mtls-cert-generator + volumes: + - certs:/certs + + mtls-server: + build: + context: . + dockerfile: Dockerfile + container_name: mtls-server + environment: + APP_BIN: mtls-server + depends_on: + cert-generator: + condition: service_completed_successfully + ports: + - "8443:8443" + volumes: + - certs:/certs:ro + + mtls-client: + build: + context: . + dockerfile: Dockerfile + container_name: mtls-client + environment: + APP_BIN: mtls-client + CLIENT_API_ADDR: ":8080" + SERVER_URL: "https://mtls-server:8443/hello" + depends_on: + cert-generator: + condition: service_completed_successfully + mtls-server: + condition: service_started + ports: + - "8080:8080" + volumes: + - certs:/certs:ro + +volumes: + certs: diff --git a/mtls-app/go.mod b/mtls-app/go.mod new file mode 100644 index 00000000..c1a8660c --- /dev/null +++ b/mtls-app/go.mod @@ -0,0 +1,3 @@ +module github.com/keploy/samples-go/mtls-app + +go 1.22