ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
13 changed files with 145336 additions and 0 deletions

View file

@ -0,0 +1,53 @@
module github.com/quorate/ms2k-internal/services/oci-l4-relay-wan-controller
go 1.26.4
require (
github.com/oracle/oci-go-sdk/v65 v65.121.0
k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gofrs/flock v0.10.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.36.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

View file

@ -0,0 +1,127 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw=
github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View file

@ -0,0 +1,109 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: oci-l4-relay-wan-controller
namespace: kube-system
spec:
replicas: 2
strategy:
type: RollingUpdate
selector:
matchLabels:
app.kubernetes.io/name: oci-l4-relay-wan-controller
template:
metadata:
labels:
app.kubernetes.io/name: oci-l4-relay-wan-controller
spec:
serviceAccountName: oci-l4-relay-wan-controller
automountServiceAccountToken: true
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: oci-l4-relay-wan-controller
topologyKey: kubernetes.io/hostname
containers:
- name: controller
image: registry.quorate.internal/quorate/oci-l4-relay-wan-controller@sha256:395e68f424e6fddcfb72cee8f83887ed219b47e16cd87562c4073325b3598803
imagePullPolicy: IfNotPresent
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OPNSENSE_URL
value: https://10.212.111.120:8443
- name: OPNSENSE_INTERFACE
value: WAN
- name: POLL_INTERVAL
value: 10s
- name: MINIMUM_OBSERVATIONS
value: "2"
- name: WIREGUARD_PORT
value: "51820"
- name: OPNSENSE_KEY_PATH
value: /run/secrets/opnsense/key
- name: OPNSENSE_SECRET_PATH
value: /run/secrets/opnsense/secret
- name: OPNSENSE_CA_PATH
value: /run/secrets/opnsense-ca/ca.crt
- name: OCI_TENANCY_OCID_PATH
value: /run/secrets/oci/tenancy_ocid
- name: OCI_USER_OCID_PATH
value: /run/secrets/oci/user_ocid
- name: OCI_FINGERPRINT_PATH
value: /run/secrets/oci/fingerprint
- name: OCI_PRIVATE_KEY_PATH
value: /run/secrets/oci/private_key
- name: OCI_REGION_PATH
value: /run/secrets/oci/region
- name: OCI_NSG_OCID_PATH
value: /run/secrets/oci/network_security_group_id
- name: OCI_SECURITY_RULE_ID_PATH
value: /run/secrets/oci/security_rule_id
- name: WG_STATUS_URL
value: http://oci-l4-relay-metrics.kube-system.svc.cluster.local:19100/metrics
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 4m
memory: 20Mi
limits:
memory: 128Mi
volumeMounts:
- name: opnsense
mountPath: /run/secrets/opnsense
readOnly: true
- name: opnsense-ca
mountPath: /run/secrets/opnsense-ca
readOnly: true
- name: oci
mountPath: /run/secrets/oci
readOnly: true
volumes:
- name: opnsense
secret:
secretName: opnsense-interface-watcher
defaultMode: 0440
- name: opnsense-ca
secret:
secretName: ca-opnsense
defaultMode: 0440
- name: oci
secret:
secretName: oci-l4-relay-wan-controller-api
defaultMode: 0440

View file

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kube-system
resources:
- service-account.yaml
- vault-secret.yaml
- deployment.yaml
- network-policy.yaml

View file

@ -0,0 +1,59 @@
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: oci-l4-relay-wan-controller
namespace: kube-system
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: oci-l4-relay-wan-controller
ingress: []
egress:
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toServices:
- k8sService:
namespace: kube-system
serviceName: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
- toCIDR:
- 10.212.111.120/32
toPorts:
- ports:
- port: "8443"
protocol: TCP
- toEntities:
- host
- remote-node
toPorts:
- ports:
- port: "19100"
protocol: TCP
- toFQDNs:
- matchPattern: iaas.*.oraclecloud.com
toPorts:
- ports:
- port: "443"
protocol: TCP

View file

@ -0,0 +1,44 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: oci-l4-relay-wan-controller
namespace: kube-system
automountServiceAccountToken: true
imagePullSecrets:
- name: quay-cluster-pull
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: oci-l4-relay-wan-controller
namespace: kube-system
rules:
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- create
- apiGroups:
- coordination.k8s.io
resourceNames:
- wan-controller
resources:
- leases
verbs:
- get
- update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: oci-l4-relay-wan-controller
namespace: kube-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: oci-l4-relay-wan-controller
subjects:
- kind: ServiceAccount
name: oci-l4-relay-wan-controller
namespace: kube-system

View file

@ -0,0 +1,109 @@
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: oci-l4-relay-wan-controller
namespace: kube-system
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: oci-l4-relay-wan-controller
serviceAccount: oci-l4-relay-wan-controller
vaultConnectionRef: vault/vault
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: opnsense-interface-watcher
namespace: kube-system
spec:
type: kv-v2
mount: secret
path: opnsense/interface-watcher
destination:
name: opnsense-interface-watcher
create: true
overwrite: true
transformation:
excludeRaw: true
excludes:
- .*
templates:
key:
text: '{{ get .Secrets "key" }}'
secret:
text: '{{ get .Secrets "secret" }}'
rolloutRestartTargets:
- kind: Deployment
name: oci-l4-relay-wan-controller
vaultAuthRef: oci-l4-relay-wan-controller
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: opnsense-root-ca
namespace: kube-system
spec:
type: kv-v2
mount: secret
path: ca/opnsense
hmacSecretData: true
refreshAfter: 1h
destination:
name: ca-opnsense
create: true
overwrite: true
transformation:
excludeRaw: true
excludes:
- .*
templates:
ca.crt:
text: >-
{{- $ca := get .Secrets "certificate" -}}
{{- if not (hasPrefix "-----BEGIN CERTIFICATE-----" $ca) -}}
{{- fail "CA publication is pending" -}}
{{- end -}}
{{- $ca -}}
rolloutRestartTargets:
- kind: Deployment
name: oci-l4-relay-wan-controller
vaultAuthRef: oci-l4-relay-wan-controller
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: oci-l4-relay-wan-controller-api
namespace: kube-system
spec:
type: kv-v2
mount: secret
path: oci/relay/wan-controller-api
destination:
name: oci-l4-relay-wan-controller-api
create: true
overwrite: true
transformation:
excludeRaw: true
excludes:
- .*
templates:
tenancy_ocid:
text: '{{ get .Secrets "tenancy_ocid" }}'
user_ocid:
text: '{{ get .Secrets "user_ocid" }}'
fingerprint:
text: '{{ get .Secrets "fingerprint" }}'
private_key:
text: |
{{ get .Secrets "private_key" }}
region:
text: '{{ get .Secrets "region" }}'
network_security_group_id:
text: '{{ get .Secrets "network_security_group_id" }}'
security_rule_id:
text: '{{ get .Secrets "security_rule_id" }}'
rolloutRestartTargets:
- kind: Deployment
name: oci-l4-relay-wan-controller
vaultAuthRef: oci-l4-relay-wan-controller

View file

@ -0,0 +1,419 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
coordinationv1 "k8s.io/client-go/kubernetes/typed/coordination/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/leaderelection"
"k8s.io/client-go/tools/leaderelection/resourcelock"
)
const leaderLockName = "wan-controller"
type opnsenseClient struct {
baseURL string
key string
secret string
http *http.Client
}
type interfaceOverview struct {
Rows []interfaceRow `json:"rows"`
}
type interfaceRow struct {
Identifier string `json:"identifier"`
Description string `json:"description"`
IPv4 []struct {
Address string `json:"ipaddr"`
} `json:"ipv4"`
}
func (c *opnsenseClient) wanIPv4(ctx context.Context, selector string) (net.IP, error) {
endpoint := strings.TrimRight(c.baseURL, "/") + "/api/interfaces/overview/interfaces_info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.key, c.secret)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OPNsense returned HTTP %d", resp.StatusCode)
}
var overview interfaceOverview
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&overview); err != nil {
return nil, err
}
var selected *interfaceRow
for index := range overview.Rows {
row := &overview.Rows[index]
if row.Identifier != selector && row.Description != selector {
continue
}
if selected != nil {
return nil, fmt.Errorf("OPNsense interface selector %q is ambiguous", selector)
}
selected = row
}
if selected == nil {
return nil, fmt.Errorf("OPNsense interface selector %q did not match", selector)
}
for _, address := range selected.IPv4 {
if ip := parseInterfaceIPv4(address.Address); validPublicIPv4(ip) {
return ip.To4(), nil
}
}
return nil, fmt.Errorf("OPNsense interface %q contains no public IPv4", selector)
}
func parseInterfaceIPv4(address string) net.IP {
if ip := net.ParseIP(address); ip != nil {
return ip
}
ip, _, err := net.ParseCIDR(address)
if err != nil {
return nil
}
return ip
}
func validPublicIPv4(ip net.IP) bool {
return ip != nil && ip.To4() != nil && !ip.IsPrivate() && !ip.IsLoopback() &&
!ip.IsUnspecified() && !ip.IsMulticast() && !ip.IsLinkLocalUnicast()
}
type sourceReconciler interface {
Reconcile(ctx context.Context, source net.IP) error
}
func poll(ctx context.Context, client *opnsenseClient, selector string, interval time.Duration, minimumObservations int, reconcile sourceReconciler) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var applied, candidate string
observations := 0
for {
ip, err := client.wanIPv4(ctx, selector)
if err != nil {
slog.Error("poll OPNsense WAN", "error", err)
candidate = ""
observations = 0
} else {
current := ip.String()
if current == applied {
candidate = ""
observations = 0
} else {
if current != candidate {
candidate = current
observations = 1
slog.Info("observed new OPNsense WAN candidate", "source", current, "observations", observations)
} else {
observations++
}
if observations >= minimumObservations {
if err := reconcile.Reconcile(ctx, ip); err != nil {
slog.Error("reconcile OCI WireGuard source", "source", current, "error", err)
} else {
applied = current
candidate = ""
observations = 0
slog.Info("reconciled OCI WireGuard source", "source", current)
}
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("controller stopped", "error", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
cfg, err := loadConfig()
if err != nil {
return err
}
restConfig, err := rest.InClusterConfig()
if err != nil {
return fmt.Errorf("load Kubernetes client config: %w", err)
}
kubeClient, err := coordinationv1.NewForConfig(restConfig)
if err != nil {
return fmt.Errorf("create Kubernetes client: %w", err)
}
identity, err := os.Hostname()
if err != nil {
return fmt.Errorf("read pod hostname: %w", err)
}
namespace := os.Getenv("POD_NAMESPACE")
if namespace == "" {
return errors.New("POD_NAMESPACE is required")
}
electionCtx, cancel := context.WithCancel(ctx)
defer cancel()
controllerErr := make(chan error, 1)
elector, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
Lock: &resourcelock.LeaseLock{
LeaseMeta: metav1.ObjectMeta{Name: leaderLockName, Namespace: namespace},
Client: kubeClient,
LockConfig: resourcelock.ResourceLockConfig{
Identity: identity,
},
},
LeaseDuration: 30 * time.Second,
RenewDeadline: 20 * time.Second,
RetryPeriod: 5 * time.Second,
ReleaseOnCancel: true,
Name: leaderLockName,
Callbacks: leaderelection.LeaderCallbacks{
OnStartedLeading: func(leaderCtx context.Context) {
controllerErr <- runLeader(leaderCtx, cfg)
cancel()
},
OnStoppedLeading: func() {
if ctx.Err() == nil {
select {
case controllerErr <- errors.New("leadership lost"):
default:
}
}
},
},
})
if err != nil {
return fmt.Errorf("configure leader election: %w", err)
}
elector.Run(electionCtx)
select {
case err := <-controllerErr:
return err
default:
return electionCtx.Err()
}
}
func runLeader(ctx context.Context, cfg config) error {
reconciler, err := newOCIReconciler(cfg)
if err != nil {
return err
}
httpClient, err := newOPNsenseHTTPClient(cfg)
if err != nil {
return err
}
client := &opnsenseClient{
baseURL: cfg.opnsenseURL,
key: cfg.opnsenseKey,
secret: cfg.opnsenseSecret,
http: httpClient,
}
return poll(ctx, client, cfg.opnsenseInterface, cfg.pollInterval, cfg.minimumObservations, reconciler)
}
type config struct {
opnsenseURL string
opnsenseInterface string
opnsenseKey string
opnsenseSecret string
ociTenancy string
ociUser string
ociFingerprint string
ociPrivateKey string
ociRegion string
networkSecurityGroup string
securityRule string
wireguardPort int
pollInterval time.Duration
minimumObservations int
requestTimeout time.Duration
opnsenseCACert string
wireguardURL string
recoveryTimeout time.Duration
}
func loadConfig() (config, error) {
read := func(name string) (string, error) {
path := os.Getenv(name)
if path == "" {
return "", fmt.Errorf("%s is required", name)
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
return strings.TrimSpace(string(value)), nil
}
opnsenseKey, err := read("OPNSENSE_KEY_PATH")
if err != nil {
return config{}, err
}
opnsenseSecret, err := read("OPNSENSE_SECRET_PATH")
if err != nil {
return config{}, err
}
opnsenseCACert, err := read("OPNSENSE_CA_PATH")
if err != nil {
return config{}, err
}
ociTenancy, err := read("OCI_TENANCY_OCID_PATH")
if err != nil {
return config{}, err
}
ociUser, err := read("OCI_USER_OCID_PATH")
if err != nil {
return config{}, err
}
ociFingerprint, err := read("OCI_FINGERPRINT_PATH")
if err != nil {
return config{}, err
}
ociPrivateKey, err := read("OCI_PRIVATE_KEY_PATH")
if err != nil {
return config{}, err
}
ociRegion, err := read("OCI_REGION_PATH")
if err != nil {
return config{}, err
}
nsg, err := read("OCI_NSG_OCID_PATH")
if err != nil {
return config{}, err
}
rule, err := read("OCI_SECURITY_RULE_ID_PATH")
if err != nil {
return config{}, err
}
port, err := strconv.Atoi(envOrDefault("WIREGUARD_PORT", "51820"))
if err != nil || port < 1 || port > 65535 {
return config{}, errors.New("WIREGUARD_PORT must be a valid port")
}
pollInterval, err := time.ParseDuration(envOrDefault("POLL_INTERVAL", "30s"))
if err != nil || pollInterval < 5*time.Second {
return config{}, errors.New("POLL_INTERVAL must be at least 5s")
}
minimumObservations, err := strconv.Atoi(envOrDefault("MINIMUM_OBSERVATIONS", "2"))
if err != nil || minimumObservations < 1 || minimumObservations > 10 {
return config{}, errors.New("MINIMUM_OBSERVATIONS must be between 1 and 10")
}
opnsenseURL, err := requiredHTTPSOrigin("OPNSENSE_URL")
if err != nil {
return config{}, err
}
wireguardURL, err := requiredMetricsURL("WG_STATUS_URL")
if err != nil {
return config{}, err
}
return config{
opnsenseURL: opnsenseURL,
opnsenseInterface: envOrDefault("OPNSENSE_INTERFACE", "WAN"),
opnsenseKey: opnsenseKey,
opnsenseSecret: opnsenseSecret,
opnsenseCACert: opnsenseCACert,
ociTenancy: ociTenancy,
ociUser: ociUser,
ociFingerprint: ociFingerprint,
ociPrivateKey: ociPrivateKey,
ociRegion: ociRegion,
networkSecurityGroup: nsg,
securityRule: rule,
wireguardPort: port,
pollInterval: pollInterval,
minimumObservations: minimumObservations,
requestTimeout: 15 * time.Second,
wireguardURL: wireguardURL,
recoveryTimeout: 90 * time.Second,
}, nil
}
func newOPNsenseHTTPClient(cfg config) (*http.Client, error) {
block, _ := pem.Decode([]byte(cfg.opnsenseCACert))
if block == nil || block.Type != "CERTIFICATE" {
return nil, errors.New("OPNSENSE_CA_PATH contains no PEM certificate")
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse OPNsense CA certificate: %w", err)
}
remaining := time.Until(certificate.NotAfter)
if remaining <= 0 {
return nil, errors.New("OPNsense CA certificate is expired")
}
if remaining < 90*24*time.Hour {
slog.Warn("OPNsense CA certificate expires soon", "not_after", certificate.NotAfter.UTC())
}
if !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
return nil, errors.New("OPNSENSE_CA_PATH certificate is not a certificate authority")
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM([]byte(cfg.opnsenseCACert)) {
return nil, errors.New("OPNSENSE_CA_PATH contains no PEM certificates")
}
transport := &http.Transport{TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: roots,
}}
return &http.Client{Timeout: cfg.requestTimeout, Transport: transport}, nil
}
func requiredHTTPSOrigin(name string) (string, error) {
raw := os.Getenv(name)
if raw == "" {
return "", fmt.Errorf("%s is required", name)
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return "", fmt.Errorf("%s must be an HTTPS origin", name)
}
return strings.TrimRight(raw, "/"), nil
}
func envOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func requiredMetricsURL(name string) (string, error) {
value := os.Getenv(name)
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "/metrics" {
return "", fmt.Errorf("%s must be an HTTP metrics endpoint", name)
}
return value, nil
}

View file

@ -0,0 +1,72 @@
package main
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestWANIPv4RejectsPrivateAddress(t *testing.T) {
server := interfaceServer(t, `{"rows":[{"identifier":"wan","description":"WAN","ipv4":[{"ipaddr":"10.180.186.43"}]}]}`)
defer server.Close()
client := &opnsenseClient{baseURL: server.URL, key: "key", secret: "secret", http: server.Client()}
if _, err := client.wanIPv4(context.Background(), "WAN"); err == nil {
t.Fatal("expected private address rejection")
}
}
func TestWANIPv4ReturnsPublicAddressByDescription(t *testing.T) {
server := interfaceServer(t, `{"rows":[{"identifier":"wan","description":"WAN","ipv4":[{"ipaddr":"203.0.113.7/24"}]}]}`)
defer server.Close()
client := &opnsenseClient{baseURL: server.URL, http: server.Client()}
ip, err := client.wanIPv4(context.Background(), "WAN")
if err != nil || !ip.Equal(net.ParseIP("203.0.113.7")) {
t.Fatalf("WAN address = %v, error = %v", ip, err)
}
}
func TestWANIPv4RejectsAmbiguousSelector(t *testing.T) {
server := interfaceServer(t, `{"rows":[{"identifier":"wan","description":"WAN","ipv4":[{"ipaddr":"203.0.113.7"}]},{"identifier":"WAN","description":"other","ipv4":[{"ipaddr":"203.0.113.8"}]}]}`)
defer server.Close()
client := &opnsenseClient{baseURL: server.URL, http: server.Client()}
if _, err := client.wanIPv4(context.Background(), "WAN"); err == nil {
t.Fatal("expected ambiguous selector rejection")
}
}
func interfaceServer(t *testing.T, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/interfaces/overview/interfaces_info" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(body))
}))
}
type recordingReconciler struct {
cancel context.CancelFunc
calls int
}
func (r *recordingReconciler) Reconcile(_ context.Context, _ net.IP) error {
r.calls++
r.cancel()
return nil
}
func TestPollDebouncesWANChange(t *testing.T) {
server := interfaceServer(t, `{"rows":[{"identifier":"wan","description":"WAN","ipv4":[{"ipaddr":"203.0.113.7"}]}]}`)
defer server.Close()
client := &opnsenseClient{baseURL: server.URL, http: server.Client()}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
reconciler := &recordingReconciler{cancel: cancel}
_ = poll(ctx, client, "WAN", time.Millisecond, 2, reconciler)
if reconciler.calls != 1 {
t.Fatalf("reconcile calls = %d", reconciler.calls)
}
}

View file

@ -0,0 +1,118 @@
package main
import (
"context"
"fmt"
"net"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
type networkSecurityGroupClient interface {
UpdateNetworkSecurityGroupSecurityRules(context.Context, core.UpdateNetworkSecurityGroupSecurityRulesRequest) (core.UpdateNetworkSecurityGroupSecurityRulesResponse, error)
}
type ociReconciler struct {
client networkSecurityGroupClient
networkSecurityGroup string
securityRule string
wireguardDestinationPort int
wireguardURL string
recoveryTimeout time.Duration
probeWireGuard func(context.Context, string, time.Duration) error
}
func newOCIReconciler(cfg config) (*ociReconciler, error) {
provider := common.NewRawConfigurationProvider(
cfg.ociTenancy,
cfg.ociUser,
cfg.ociRegion,
cfg.ociFingerprint,
cfg.ociPrivateKey,
nil,
)
client, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider)
if err != nil {
return nil, fmt.Errorf("create OCI virtual network client: %w", err)
}
return &ociReconciler{
client: &client,
networkSecurityGroup: cfg.networkSecurityGroup,
securityRule: cfg.securityRule,
wireguardDestinationPort: cfg.wireguardPort,
wireguardURL: cfg.wireguardURL,
recoveryTimeout: cfg.recoveryTimeout,
probeWireGuard: waitForWireGuard,
}, nil
}
func (r *ociReconciler) Reconcile(ctx context.Context, source net.IP) error {
if !validPublicIPv4(source) {
return fmt.Errorf("refuse invalid WAN source %q", source)
}
port := r.wireguardDestinationPort
protocol := "17"
description := "Home WireGuard peer"
sourceCIDR := source.String() + "/32"
response, err := r.client.UpdateNetworkSecurityGroupSecurityRules(ctx, core.UpdateNetworkSecurityGroupSecurityRulesRequest{
NetworkSecurityGroupId: common.String(r.networkSecurityGroup),
UpdateNetworkSecurityGroupSecurityRulesDetails: core.UpdateNetworkSecurityGroupSecurityRulesDetails{
SecurityRules: []core.UpdateSecurityRuleDetails{{
Direction: core.UpdateSecurityRuleDetailsDirectionIngress,
Id: common.String(r.securityRule),
Protocol: common.String(protocol),
Description: common.String(description),
Source: common.String(sourceCIDR),
SourceType: core.UpdateSecurityRuleDetailsSourceTypeCidrBlock,
IsStateless: common.Bool(false),
UdpOptions: &core.UdpOptions{DestinationPortRange: &core.PortRange{Min: common.Int(port), Max: common.Int(port)}},
}},
},
})
if err != nil {
return fmt.Errorf("update WireGuard NSG source: %w", err)
}
if err := verifyUpdatedRule(response.SecurityRules, r.securityRule, sourceCIDR, port); err != nil {
return err
}
if err := r.probeWireGuard(ctx, r.wireguardURL, r.recoveryTimeout); err != nil {
return fmt.Errorf("verify WireGuard connectivity: %w", err)
}
return nil
}
func verifyUpdatedRule(rules []core.SecurityRule, id, source string, port int) error {
if len(rules) != 1 {
return fmt.Errorf("OCI returned %d updated rules, want 1", len(rules))
}
rule := rules[0]
if stringValue(rule.Id) != id || stringValue(rule.Source) != source ||
stringValue(rule.Protocol) != "17" || rule.Direction != core.SecurityRuleDirectionIngress ||
rule.SourceType != core.SecurityRuleSourceTypeCidrBlock || boolValue(rule.IsStateless) ||
rule.UdpOptions == nil || rule.UdpOptions.DestinationPortRange == nil ||
intValue(rule.UdpOptions.DestinationPortRange.Min) != port ||
intValue(rule.UdpOptions.DestinationPortRange.Max) != port {
return fmt.Errorf("OCI returned an unexpected WireGuard rule")
}
return nil
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func boolValue(value *bool) bool {
return value != nil && *value
}
func intValue(value *int) int {
if value == nil {
return 0
}
return *value
}

View file

@ -0,0 +1,85 @@
package main
import (
"context"
"net"
"testing"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/core"
)
type recordingNSGClient struct {
request core.UpdateNetworkSecurityGroupSecurityRulesRequest
calls int
rules []core.SecurityRule
}
func (c *recordingNSGClient) UpdateNetworkSecurityGroupSecurityRules(_ context.Context, request core.UpdateNetworkSecurityGroupSecurityRulesRequest) (core.UpdateNetworkSecurityGroupSecurityRulesResponse, error) {
c.request = request
c.calls++
return core.UpdateNetworkSecurityGroupSecurityRulesResponse{
UpdatedNetworkSecurityGroupSecurityRules: core.UpdatedNetworkSecurityGroupSecurityRules{SecurityRules: c.rules},
}, nil
}
func returnedRule(id, source string, port int) core.SecurityRule {
return core.SecurityRule{
Direction: core.SecurityRuleDirectionIngress,
Id: common.String(id),
Protocol: common.String("17"),
Source: common.String(source),
SourceType: core.SecurityRuleSourceTypeCidrBlock,
IsStateless: common.Bool(false),
UdpOptions: &core.UdpOptions{DestinationPortRange: &core.PortRange{Min: common.Int(port), Max: common.Int(port)}},
}
}
func TestOCIReconcilerUpdatesOnlyConfiguredWireGuardRule(t *testing.T) {
client := &recordingNSGClient{rules: []core.SecurityRule{returnedRule("rule", "203.0.113.7/32", 51820)}}
probed := false
reconciler := &ociReconciler{
client: client, networkSecurityGroup: "nsg", securityRule: "rule", wireguardDestinationPort: 51820,
wireguardURL: "http://relay.test:19100/metrics", recoveryTimeout: time.Minute,
probeWireGuard: func(_ context.Context, address string, timeout time.Duration) error {
probed = address == "http://relay.test:19100/metrics" && timeout == time.Minute
return nil
},
}
if err := reconciler.Reconcile(context.Background(), net.ParseIP("203.0.113.7")); err != nil {
t.Fatal(err)
}
rules := client.request.SecurityRules
if len(rules) != 1 {
t.Fatalf("rule count = %d", len(rules))
}
rule := rules[0]
if *rule.Id != "rule" || *rule.Source != "203.0.113.7/32" || *rule.Protocol != "17" {
t.Fatalf("unexpected rule: %#v", rule)
}
if rule.UdpOptions == nil || *rule.UdpOptions.DestinationPortRange.Min != 51820 || *rule.UdpOptions.DestinationPortRange.Max != 51820 {
t.Fatalf("unexpected UDP options: %#v", rule.UdpOptions)
}
if !probed {
t.Fatal("WireGuard connectivity was not probed")
}
}
func TestOCIReconcilerRejectsUnexpectedUpdatedRule(t *testing.T) {
client := &recordingNSGClient{rules: []core.SecurityRule{returnedRule("other", "203.0.113.7/32", 51820)}}
reconciler := &ociReconciler{
client: client, networkSecurityGroup: "nsg", securityRule: "rule", wireguardDestinationPort: 51820,
probeWireGuard: func(context.Context, string, time.Duration) error { return nil },
}
if err := reconciler.Reconcile(context.Background(), net.ParseIP("203.0.113.7")); err == nil {
t.Fatal("expected returned rule rejection")
}
}
func TestOCIReconcilerRejectsPrivateSource(t *testing.T) {
reconciler := &ociReconciler{client: &recordingNSGClient{}, networkSecurityGroup: "nsg", securityRule: "rule", wireguardDestinationPort: 51820}
if err := reconciler.Reconcile(context.Background(), net.ParseIP("10.180.186.43")); err == nil {
t.Fatal("expected private source rejection")
}
}

View file

@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func waitForWireGuard(ctx context.Context, endpoint string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
if err := probeWireGuard(ctx, client, endpoint); err == nil {
return nil
} else {
select {
case <-ctx.Done():
return fmt.Errorf("%w: %v", ctx.Err(), err)
case <-ticker.C:
}
}
}
}
func probeWireGuard(ctx context.Context, client *http.Client, endpoint string) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
response, err := client.Do(request)
if err != nil {
return err
}
defer func() { _ = response.Body.Close() }()
// The relay proxy fetches this response live from the OCI WireGuard address.
if response.StatusCode != http.StatusOK {
return fmt.Errorf("WireGuard endpoint returned HTTP %d", response.StatusCode)
}
return nil
}

View file

@ -0,0 +1,115 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/oracle/oci-go-sdk/v65/core"
)
func TestWireGuardIgnoresWebHealth(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/metrics" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
_, _ = w.Write([]byte("oci_l4_relay_component_healthy{component=\"web_allow\"} 0\n"))
}))
defer server.Close()
if err := waitForWireGuard(context.Background(), server.URL+"/metrics", time.Second); err != nil {
t.Fatal(err)
}
}
func TestWireGuardRejectsUnavailableTunnel(t *testing.T) {
for _, status := range []int{http.StatusBadGateway, http.StatusServiceUnavailable} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
defer server.Close()
err := waitForWireGuard(context.Background(), server.URL+"/metrics", 20*time.Millisecond)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected recovery deadline, got %v", err)
}
})
}
}
func TestWireGuardDoesNotFollowRedirects(t *testing.T) {
var calls atomic.Int32
destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer destination.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, destination.URL, http.StatusFound)
}))
defer server.Close()
err := waitForWireGuard(context.Background(), server.URL+"/metrics", 20*time.Millisecond)
if !errors.Is(err, context.DeadlineExceeded) || calls.Load() != 0 {
t.Fatalf("error = %v, redirected calls = %d", err, calls.Load())
}
}
func TestWireGuardCancelsBlockedRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if err := waitForWireGuard(ctx, server.URL+"/metrics", time.Minute); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected cancellation, got %v", err)
}
}
func TestPollStopsUpdatingAfterWireGuardRecovery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
observations := 0
opnsense := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
observations++
if observations == 5 {
cancel()
}
_, _ = w.Write([]byte(`{"rows":[{"identifier":"wan","description":"WAN","ipv4":[{"ipaddr":"203.0.113.7"}]}]}`))
}))
defer opnsense.Close()
var probes atomic.Int32
tunnel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
probes.Add(1)
_, _ = w.Write([]byte("oci_l4_relay_component_healthy{component=\"web_allow\"} 0\n"))
}))
defer tunnel.Close()
nsg := &recordingNSGClient{rules: []core.SecurityRule{returnedRule("rule", "203.0.113.7/32", 51820)}}
reconciler := &ociReconciler{
client: nsg, networkSecurityGroup: "nsg", securityRule: "rule", wireguardDestinationPort: 51820,
wireguardURL: tunnel.URL + "/metrics", recoveryTimeout: time.Second, probeWireGuard: waitForWireGuard,
}
client := &opnsenseClient{baseURL: opnsense.URL, http: opnsense.Client()}
_ = poll(ctx, client, "WAN", time.Millisecond, 2, reconciler)
if observations != 5 || nsg.calls != 1 || probes.Load() != 1 {
t.Fatalf("observations = %d, NSG updates = %d, tunnel probes = %d", observations, nsg.calls, probes.Load())
}
}
func TestMetricsURL(t *testing.T) {
for _, value := range []string{"", "https://relay.test/metrics", "http://relay.test/", "http://relay.test/metrics?query=1", "http://key:[email protected]/metrics"} {
t.Run(value, func(t *testing.T) {
t.Setenv("WG_STATUS_URL", value)
if _, err := requiredMetricsURL("WG_STATUS_URL"); err == nil {
t.Fatal("expected invalid endpoint rejection")
}
})
}
t.Setenv("WG_STATUS_URL", "http://relay.test:19100/metrics")
if _, err := requiredMetricsURL("WG_STATUS_URL"); err != nil {
t.Fatal(err)
}
}