diff --git a/echo-mysql/.env b/echo-mysql/.env index 2b7731b0..0614e1aa 100644 --- a/echo-mysql/.env +++ b/echo-mysql/.env @@ -2,4 +2,6 @@ MYSQL_USER=root MYSQL_PASSWORD=password MYSQL_HOST=localhost MYSQL_PORT=3306 -MYSQL_DBNAME=uss \ No newline at end of file +MYSQL_DBNAME=uss +MYSQL_SSL_MODE=production +MYSQL_SSL_CA=./certs/ca.pem \ No newline at end of file diff --git a/echo-mysql/certs/ca-key.pem b/echo-mysql/certs/ca-key.pem new file mode 100644 index 00000000..4a95c0d1 --- /dev/null +++ b/echo-mysql/certs/ca-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAprQ1uus1FpFfRAKGXWOclp+Bns8WVH3l8sBRfcoMfXiAyYBH +347NEJEwN1Nb/IhS2fe/K4dIh21wLOBQGgNNnf8MCNWK02Sif8oJ60E4ZX8DzZZd +RKyrzODgDtFcfSwCkk7ZW6Aq2xRUG/K2SwgHzVJ/cAGxlCKBb/IjHPJCxv4es/kR +sb9RrdsDUqbIG0t0FXMdiinteC1lO+ao0UNrJWT6+LOfIFrV7XxJPV2lJvV1j3EL +Kbf6FvMB8bRLPP8SzI8N1AQBOlDI0tVA5w/2Q22ddEcfm2TVaFEg0+75dunSqMhf +nbsUcIjOe/hA0lmuobLfZmT/ShpWutiLkdbEIQIDAQABAoIBAAbW7zrqblDNGVpk +G6DuhrTP2cy7HKSOD06OyTEm5m0J8gfCa4B+vodi25ZzhTtG2RZgEa/+opFK4k63 +C9ZLYyZ7ucHNiB78+qVRU+ea8w5IAC8Rz6UTDA/xfIPjH18z3G2mDkkx+LwpGPvW +ERhiPZa1Hy0oZqGoKkUea0YjYu3A24+ZXHz8Lf4JBFlGLgL5RaMcc3cJVSmAhS8c +SvrTmRmbeP/BAzRHO50Nb+LlWIovTXdqEqkU4FhmR13xA3Xf8mAvFI8jguUpkjHw +ZT19s0Ahp9hxan3vVLQ6iYghZc/ohSdMruyt1M90Pzfhj0M4sqzSLOqdX7V0Hmxw +lK29bWkCgYEA57gYwdwKAiBY14kOeTxzDPsFWqLuJILcJDd3Za2v6/WipgdL77LB +VQIc+wQxHqFyCUKHMS6czoNsIBJezZmUs5YroXCDHE6xPnTNsBtWyVsLA2xiuPxD +BN/J0JJV0gPVxXEV5Kx2qp72jS3ccVPHoCIkdq6CAYiBdAkU8DJ9X1kCgYEAuCwS +AGxDJc7LvdHBmcu3pyEVuSVN1OYRIE0dk+9DrDMxCR/pn4ZP1eNt+SjoOHLkPwVN +VfYm4DGPEYzZLvXWhvLwNijHHj4KQebehrPnR7+WAPncJdjtgkR00Ysd4+ICtMaC +8ExeNB5TmrRMhOKBjEz94sgpy2Vt6UUe1vouegkCgYBgx3cvoKNdd/0jKE8vO5wh +08XMsTgdb7paNgBcK0rKlnE0Pt/sYRB2XMeV345UaMGkNHEajYlYh3NlgcauwHJm +/1WBu+hGrmdA2q/92a1JtAjJiT9CW7nyEzXLMxM8//UM3cpzE8UMRhBbrsffXUqH +CzuHhiMuWMEYoaJpH+1VIQKBgCUetM4jA/Gl2Yi7szKlTbHAyFkVvLcxW7hP8qsz +aUdW1gZJyVOexY6NlUfHx+5AseJF1k2CHFnJg1V9NvTxFbkDVAkGdQOSa4zW1Hj/ +35ilc71knst+CnjcBVOKn46jqfn3nMKEEeSdTCp9NoL+CDBYAD/qKgpVui5vAQVB +TYbJAoGBALykwHGsUgFxE3Oep+xgPoyv16rCnrxw6gbC9deH9aNR7KF1c1TiwRru +lotpayjCozrTGhMbDcoITrVquAQ9rS91CjLZ0xTQPWeyt97GxF1Fm0oFsx6LL9Gi +GXJM/jrOXhUeWbmJ1T2JLpbW24lLXhdMeNAgeSdJXbQrG0mwSnbN +-----END RSA PRIVATE KEY----- diff --git a/echo-mysql/certs/ca.pem b/echo-mysql/certs/ca.pem new file mode 100644 index 00000000..8a660ba2 --- /dev/null +++ b/echo-mysql/certs/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIUfeXxHr/bnolkh3+QK87031n++U8wDQYJKoZIhvcNAQEL +BQAwEzERMA8GA1UEAwwIbXlzcWwtY2EwHhcNMjUxMDE1MDMzNjAzWhcNMzUxMDEz +MDMzNjAzWjATMREwDwYDVQQDDAhteXNxbC1jYTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAKa0NbrrNRaRX0QChl1jnJafgZ7PFlR95fLAUX3KDH14gMmA +R9+OzRCRMDdTW/yIUtn3vyuHSIdtcCzgUBoDTZ3/DAjVitNkon/KCetBOGV/A82W +XUSsq8zg4A7RXH0sApJO2VugKtsUVBvytksIB81Sf3ABsZQigW/yIxzyQsb+HrP5 +EbG/Ua3bA1KmyBtLdBVzHYop7XgtZTvmqNFDayVk+viznyBa1e18ST1dpSb1dY9x +Cym3+hbzAfG0Szz/EsyPDdQEATpQyNLVQOcP9kNtnXRHH5tk1WhRINPu+Xbp0qjI +X527FHCIznv4QNJZrqGy32Zk/0oaVrrYi5HWxCECAwEAAaNTMFEwHQYDVR0OBBYE +FGDWq4435H96w5xIaBLhjEaF4HGbMB8GA1UdIwQYMBaAFGDWq4435H96w5xIaBLh +jEaF4HGbMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIRys1R8 +CQwTFOiJu9yyNVzZQQjlIbaY4gAXbu7t2KqRE2VrJvjxMTJH9Dp96srxYkNw1lpY +qCXYaMllohn0oVwKnfSkHG6WoKEC9PNn3dXyCR7cqu1nGJNtefZWd8HDCK88iJZH +zEtVXWb7T+5QriBE0TbzYFERB26r7lH3X1vh5YU+NGlXKguGx6dXVl3HsYxoNF7+ +FyWDWQ7LaDKbgiWQ44jnpZ21hvULuqeKHCsSDzwXLcG7yurbTI7oLL3XwQ/es/ui +ditMEvAWlaRwUxTJPDsSc18G6lijS7b7jELErWmYVAFt5h+DKfxKSN3Jcb5OBDDV +O8pdCMAAYRXv008= +-----END CERTIFICATE----- diff --git a/echo-mysql/certs/openssl.cnf b/echo-mysql/certs/openssl.cnf new file mode 100644 index 00000000..c8471f9b --- /dev/null +++ b/echo-mysql/certs/openssl.cnf @@ -0,0 +1,19 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = mysql-server + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +DNS.2 = mysql +DNS.3 = mysql-container-ssl +IP.1 = 127.0.0.1 +IP.2 = ::1 diff --git a/echo-mysql/certs/server-cert.pem b/echo-mysql/certs/server-cert.pem new file mode 100644 index 00000000..b9291e8a --- /dev/null +++ b/echo-mysql/certs/server-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVTCCAj2gAwIBAgIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhteXNx +bC1jYTAeFw0yNTEwMTUwMzM2MDNaFw0zNTEwMTMwMzM2MDNaMBcxFTATBgNVBAMM +DG15c3FsLXNlcnZlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO/q +54qhFgGTb8R8ntL0Y5F6SdYDKVbJIxOJzanCWJfYWdBnyGK9MW+WvvL59fzJcM3t +Dua+3ka/2HDhWKfb6dbsePzZBpjyVJ38oQfv98s/oS4gN1ZpXW9JnQSmt5nlms1d +LAXFTt8KiUxUXWRWQI4CxVuTmW5Vw1PV3nrF1/Do7Vz3sh9LrzRbUPFMKW1rr7HA +kap+yw76u10WgTiOiUnZKYgiHNyMpb5PQhAjTPh8/aYm2kbXBYPxzzH5lFGNjWzw +2iy5B3xXpM/39qAlKU7efN2X2SZhtYnv6Qog+Jmu7mIx/bX9QCwM+/VD/IKgxiBi +2MuMtoWsIZU7T7rc7ycCAwEAAaOBrzCBrDALBgNVHQ8EBAMCBDAwEwYDVR0lBAww +CgYIKwYBBQUHAwEwSAYDVR0RBEEwP4IJbG9jYWxob3N0ggVteXNxbIITbXlzcWwt +Y29udGFpbmVyLXNzbIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATAdBgNVHQ4EFgQU +mQHlaZULtFVWBoaS2sInicXX3uowHwYDVR0jBBgwFoAUYNarjjfkf3rDnEhoEuGM +RoXgcZswDQYJKoZIhvcNAQELBQADggEBAFY+mmfTkx56CNWbxzs1+zVCtzStQEy6 +1TATeHG2jTtjd6uQ96jIsw+A4U0OozGbua5+pxTTWKox7ZJ+06OAJnJ/EyuWZPAD +rXwx7Up7co44Dw4+0KgGLXTGWjHW3foQXM1Xx2Y99O8AwfAAWy6ZY/GqWG8dhQ4t +d7eOY3i053aJ6e/yW2GZmIcdTn6LR6SP7JgtALE9PA/tFFxJGY+jrD3ZcZNYCcoD +1nmYkc5gE0HB5xq60hYTRM29Bxx95/W6/2HeAVxZtxF0ntcePK0ya0jZN7jowgPt +FDESBQPf8SS6cRVh3Kkhu7wjEFRE/Uj6MGtPFdda7lc3upVCcLqb/h0= +-----END CERTIFICATE----- diff --git a/echo-mysql/certs/server-key.pem b/echo-mysql/certs/server-key.pem new file mode 100644 index 00000000..9a38b672 --- /dev/null +++ b/echo-mysql/certs/server-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA7+rniqEWAZNvxHye0vRjkXpJ1gMpVskjE4nNqcJYl9hZ0GfI +Yr0xb5a+8vn1/Mlwze0O5r7eRr/YcOFYp9vp1ux4/NkGmPJUnfyhB+/3yz+hLiA3 +Vmldb0mdBKa3meWazV0sBcVO3wqJTFRdZFZAjgLFW5OZblXDU9XeesXX8OjtXPey +H0uvNFtQ8UwpbWuvscCRqn7LDvq7XRaBOI6JSdkpiCIc3Iylvk9CECNM+Hz9piba +RtcFg/HPMfmUUY2NbPDaLLkHfFekz/f2oCUpTt583ZfZJmG1ie/pCiD4ma7uYjH9 +tf1ALAz79UP8gqDGIGLYy4y2hawhlTtPutzvJwIDAQABAoIBAFFUB/lucc9G83Rf +6lcIkVgXZEAwAitxM3rEE/uf7fhLVubWx47xI3j4WPJ02XY/swWbfpmpyh+hmPVq +7mq4maRJtRnBWAMw4o5LvSq7pfw4LaM9OIUKYqn/AkM5YLPCqZ9EHlA2em4RXEmL +r7z7oBaDyfMpLbHBUN1yemCUAIehRg1KR/uDHh+PXWFIt2BVq75ehrVBezczkOsp +6B59FhfZtUTO8MbOPadlrUHIPmybhifP+Vr8oxD++Hn2PxiKN4P4sJLhy+97f6H5 +Rlt7SCRNb3BWCT5zUQKd7dBIFOsSJate7GSNpuMpdQKud2S7kihjxspv249MV9a+ +0eYCMcECgYEA+nUUo1CLXcShlulzshoN4nQuicDeSXcuzRq5//OSswnUR/NqbJIN +3H/52RwxWLFomA7fcKUEol+LM3wzdD9S4Jv32CzJixkefufKS1E1MUtIlKu+LCIP +yh+nKA4nFDRYSPBLIaZGTGzyLsSitdY9+qpDVQBLc2lMZQbxlWGdyJ0CgYEA9Toc +48hDM/+QdnNyxw3kcC2wNZfbIYcIZzi90LqlISPnmWqh3lFUDXz7SnIViRoSD3Nw +sssk6n+MkmN3eoT8Q77PSZZDY3uRGoFJ38yo8ahxFO43CrfYbMaTDPdMhd57PEod +5bNpwMIkkjxMKiaurS3H8p80p+ExGbhXFmL4oZMCgYAKVG/geH73BBgiEEjcTKTL +9TzCI7lHUGoWvYZ0Xwhq5/ngadK23aNCt+iHItmKLe8BboOassOpKsWj/vhkUARM +DULAoMBDQ2r1kvvN9XB7Mv6wWxEB4vnBvWJ4jXThKXOGtppyrdfyaP/oG+YWF9sA +jqsuQ0/ZV7t14z5tidQnJQKBgHK8MN4mScMfdLDnDTGy/0m5JrO8jCtgqX7aHn11 +hmM+EFNIf9mrxZ7V9iD7xbWy+/Y8teMBhxEsglHPtgweAoWT1hqA8qCuJNL44N6U +PAttGxOG7TvXjqw+MHklj6km0hQAPYLGcdldPI0rJxulo56lR+LtuE4/36BADocL +4XZ/AoGAVknMVdXlvureOnCxLnJPk0L9Hhk2xv/lnDOeyygZ8szaA69C4IEil6QT +FoVvSHle927lBpS/cPWD5zdaGqbiiCNkccb9LwkrIxeO1fKPyBspmzi5GBqeJyL5 +Jrn0lOM1nNQOmc2inHtT7KeBrxx8PzpAyNFg/yFoTeaXj0kLJGE= +-----END RSA PRIVATE KEY----- diff --git a/echo-mysql/custom.cnf b/echo-mysql/custom.cnf new file mode 100644 index 00000000..7c58930e --- /dev/null +++ b/echo-mysql/custom.cnf @@ -0,0 +1,7 @@ +[mysqld] +ssl_ca=/etc/mysql/certs/ca.pem +ssl_cert=/etc/mysql/certs/server-cert.pem +ssl_key=/etc/mysql/certs/server-key.pem +require_secure_transport=ON +ssl_cipher=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 +tls_ciphersuites=TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 diff --git a/echo-mysql/docker-compose.yml b/echo-mysql/docker-compose.yml new file mode 100644 index 00000000..2c66e34e --- /dev/null +++ b/echo-mysql/docker-compose.yml @@ -0,0 +1,14 @@ +services: + db-ssl: + image: mysql:latest + container_name: mysql-container + restart: always + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: uss + ports: + - "3306:3306" + volumes: + - ./certs:/etc/mysql/certs:ro + - ./custom.cnf:/etc/mysql/conf.d/custom.cnf:ro + user: "999:999" diff --git a/echo-mysql/go.mod b/echo-mysql/go.mod index 5f379d04..db44c1e9 100644 --- a/echo-mysql/go.mod +++ b/echo-mysql/go.mod @@ -3,16 +3,17 @@ module github.com/hermione/echo-mysql go 1.22.4 require ( + github.com/go-sql-driver/mysql v1.9.3 github.com/itchyny/base58-go v0.2.2 github.com/joho/godotenv v1.5.1 github.com/labstack/echo v3.3.10+incompatible - gorm.io/driver/mysql v1.5.7 - gorm.io/gorm v1.25.11 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.0 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect - github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -23,5 +24,5 @@ require ( golang.org/x/crypto v0.24.0 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/text v0.20.0 // indirect ) diff --git a/echo-mysql/go.sum b/echo-mysql/go.sum index 9b3f2901..33e70dbe 100644 --- a/echo-mysql/go.sum +++ b/echo-mysql/go.sum @@ -1,9 +1,11 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= -github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/itchyny/base58-go v0.2.2 h1:pswMT6rW2nRoELk5Mi8+xGLQPmDnlNnCwbfRCl2p7Mo= github.com/itchyny/base58-go v0.2.2/go.mod h1:e7aEDHyQXm42jniwyoi+MaUeUdeWp58C5H20rTe52co= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -37,12 +39,11 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= -gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= -gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= -gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/echo-mysql/uss/store.go b/echo-mysql/uss/store.go index bf71ed7d..93b075c2 100644 --- a/echo-mysql/uss/store.go +++ b/echo-mysql/uss/store.go @@ -1,11 +1,14 @@ package uss import ( + "crypto/tls" + "crypto/x509" "fmt" "log" "os" "time" + sql "github.com/go-sql-driver/mysql" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -29,28 +32,82 @@ type Store struct { db *gorm.DB } -// Connect establishes a connection to the MySQL database and runs auto-migrations. +func registerTLSConfig(config map[string]string) error { + if sslMode, exists := config["MYSQL_SSL_MODE"]; exists && sslMode == "production" { + if caPath, exists := config["MYSQL_SSL_CA"]; exists && caPath != "" { + rootCertPool := x509.NewCertPool() + pem, err := os.ReadFile(caPath) + if err != nil { + return fmt.Errorf("failed to read CA file: %w", err) + } + if ok := rootCertPool.AppendCertsFromPEM(pem); !ok { + return fmt.Errorf("failed to append CA certs") + } + + tlsConfig := &tls.Config{ + RootCAs: rootCertPool, + InsecureSkipVerify: true, + } + + if err := sql.RegisterTLSConfig(sslMode, tlsConfig); err != nil { + return fmt.Errorf("failed to register TLS config '%s': %w", sslMode, err) + } + return nil + } + } + return nil +} + func (s *Store) Connect(config map[string]string) error { - // Open up our database connection. + if err := registerTLSConfig(config); err != nil { + return fmt.Errorf("failed to register TLS config: %w", err) + } + var err error + sslMode := config["MYSQL_SSL_MODE"] + if sslMode == "" { + sslMode = "false" + } mysqlDSN := fmt.Sprintf( - "%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local&tls=False", + "%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local&tls=%s", config["MYSQL_USER"], config["MYSQL_PASSWORD"], config["MYSQL_HOST"], config["MYSQL_PORT"], config["MYSQL_DBNAME"], + sslMode, ) s.db, err = gorm.Open(mysql.New(mysql.Config{ DSN: mysqlDSN, DefaultStringSize: 256, }), &gorm.Config{}) if err != nil { - return err + return fmt.Errorf("failed to connect to database: %w", err) + } + + // Only enforce SSL verification if the mode is set to 'production' + if config["MYSQL_SSL_MODE"] == "production" { + var sslStatus string + var variableName string + err := s.db.Raw("SHOW STATUS LIKE 'Ssl_cipher'").Row().Scan(&variableName, &sslStatus) + if err != nil { + s.Close() + return fmt.Errorf("failed to verify SSL connection: %w", err) + } + if sslStatus == "" { + s.Close() + // The error is now correctly tied to the configuration requirement + return fmt.Errorf("CRITICAL: SSL connection required (MYSQL_SSL_MODE=production) but connection is UNENCRYPTED") + } + log.Printf("✅ SSL connection established with cipher: %s", sslStatus) + } else { + // For any other mode (like 'false'), just log a warning and continue + log.Printf("⚠️ SSL not required by config. Proceeding with a potentially unencrypted database connection.") } sqlDB, err := s.db.DB() if err != nil { + s.Close() return err } 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