diff --git a/go.mod b/go.mod index 1916284..81afa7a 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,43 @@ module github.com/flatrun/agent -go 1.21.0 +go 1.24.0 toolchain go1.24.3 require ( + github.com/aws/aws-sdk-go-v2/config v1.32.6 + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 + github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 + github.com/cloudflare/cloudflare-go v0.116.0 github.com/creack/pty v1.1.24 + github.com/digitalocean/godo v1.171.0 github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 github.com/robfig/cron/v3 v3.0.1 + golang.org/x/oauth2 v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect @@ -26,20 +45,25 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/protobuf v1.30.0 // indirect ) diff --git a/go.sum b/go.sum index 316f30f..d6ec36f 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,53 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/cloudflare/cloudflare-go v0.116.0 h1:iRPMnTtnswRpELO65NTwMX4+RTdxZl+Xf/zi+HPE95s= +github.com/cloudflare/cloudflare-go v0.116.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/digitalocean/godo v1.171.0 h1:QwpkwWKr3v7yxc8D4NQG973NoR9APCEWjYnLOQeXVpQ= +github.com/digitalocean/godo v1.171.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -29,27 +66,45 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -63,6 +118,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= 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= @@ -72,8 +129,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= @@ -81,23 +139,27 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +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.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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/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= diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go new file mode 100644 index 0000000..4453844 --- /dev/null +++ b/internal/api/audit_handlers.go @@ -0,0 +1,183 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "github.com/flatrun/agent/internal/audit" + "github.com/gin-gonic/gin" +) + +func (s *Server) listAuditEvents(c *gin.Context) { + filter := &audit.AuditFilter{} + + if actorID := c.Query("actor_id"); actorID != "" { + filter.ActorID = actorID + } + if actorType := c.Query("actor_type"); actorType != "" { + filter.ActorType = audit.ActorType(actorType) + } + if action := c.Query("action"); action != "" { + filter.Action = action + } + if resourceType := c.Query("resource_type"); resourceType != "" { + filter.ResourceType = resourceType + } + if resourceID := c.Query("resource_id"); resourceID != "" { + filter.ResourceID = resourceID + } + if successStr := c.Query("success"); successStr != "" { + success := successStr == "true" + filter.Success = &success + } + if clientIP := c.Query("client_ip"); clientIP != "" { + filter.ClientIP = clientIP + } + if startTime := c.Query("start_time"); startTime != "" { + if t, err := time.Parse(time.RFC3339, startTime); err == nil { + filter.StartTime = t + } + } + if endTime := c.Query("end_time"); endTime != "" { + if t, err := time.Parse(time.RFC3339, endTime); err == nil { + filter.EndTime = t + } + } + + limit := 50 + if limitStr := c.Query("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { + limit = l + } + } + filter.Limit = limit + + if offsetStr := c.Query("offset"); offsetStr != "" { + if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { + filter.Offset = o + } + } + + events, total, err := s.auditManager.GetEvents(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "events": events, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +func (s *Server) getAuditEvent(c *gin.Context) { + idStr := c.Param("id") + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + event, err := s.auditManager.GetEventByEventID(idStr) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"}) + return + } + c.JSON(http.StatusOK, event) + return + } + + event, err := s.auditManager.GetEventByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"}) + return + } + + c.JSON(http.StatusOK, event) +} + +func (s *Server) getAuditStats(c *gin.Context) { + stats, err := s.auditManager.GetStats() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +func (s *Server) exportAuditEvents(c *gin.Context) { + var req struct { + Format string `json:"format"` + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Limit int `json:"limit"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + req.Format = c.DefaultQuery("format", "json") + } + + if req.Format == "" { + req.Format = "json" + } + + filter := &audit.AuditFilter{ + ActorID: req.ActorID, + Action: req.Action, + ResourceType: req.ResourceType, + Limit: req.Limit, + } + + if req.ActorType != "" { + filter.ActorType = audit.ActorType(req.ActorType) + } + if req.StartTime != "" { + if t, err := time.Parse(time.RFC3339, req.StartTime); err == nil { + filter.StartTime = t + } + } + if req.EndTime != "" { + if t, err := time.Parse(time.RFC3339, req.EndTime); err == nil { + filter.EndTime = t + } + } + if filter.Limit == 0 { + filter.Limit = 10000 + } + + data, err := s.auditManager.ExportEvents(filter, req.Format) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename := "audit_export_" + time.Now().Format("20060102_150405") + contentType := "application/json" + if req.Format == "csv" { + filename += ".csv" + contentType = "text/csv" + } else { + filename += ".json" + } + + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, contentType, data) +} + +func (s *Server) cleanupAuditEvents(c *gin.Context) { + deleted, err := s.auditManager.Cleanup() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Cleanup completed", + "deleted": deleted, + }) +} diff --git a/internal/api/dns_handlers.go b/internal/api/dns_handlers.go new file mode 100644 index 0000000..e6cd2ee --- /dev/null +++ b/internal/api/dns_handlers.go @@ -0,0 +1,39 @@ +package api + +import ( + "net/http" + + dnsPlugins "github.com/flatrun/agent/pkg/plugins/dns" + "github.com/gin-gonic/gin" +) + +func (s *Server) listDNSProviders(c *gin.Context) { + providers := []gin.H{ + { + "name": "dns-cloudflare", + "display_name": "Cloudflare DNS", + "provider": "cloudflare", + "credentials": dnsPlugins.NewCloudflarePlugin().RequiredCredentials(), + }, + { + "name": "dns-route53", + "display_name": "AWS Route 53", + "provider": "route53", + "credentials": dnsPlugins.NewRoute53Plugin().RequiredCredentials(), + }, + { + "name": "dns-digitalocean", + "display_name": "DigitalOcean DNS", + "provider": "digitalocean", + "credentials": dnsPlugins.NewDigitalOceanPlugin().RequiredCredentials(), + }, + { + "name": "dns-hetzner", + "display_name": "Hetzner DNS", + "provider": "hetzner", + "credentials": dnsPlugins.NewHetznerPlugin().RequiredCredentials(), + }, + } + + c.JSON(http.StatusOK, gin.H{"providers": providers}) +} diff --git a/internal/api/powerdns_handlers.go b/internal/api/powerdns_handlers.go new file mode 100644 index 0000000..07e0d4a --- /dev/null +++ b/internal/api/powerdns_handlers.go @@ -0,0 +1,134 @@ +package api + +import ( + "net/http" + + "github.com/flatrun/agent/internal/dns" + "github.com/gin-gonic/gin" +) + +type PowerDNSHandlers struct { + manager *dns.PowerDNSManager +} + +func NewPowerDNSHandlers(manager *dns.PowerDNSManager) *PowerDNSHandlers { + return &PowerDNSHandlers{manager: manager} +} + +func (h *PowerDNSHandlers) RegisterRoutes(rg *gin.RouterGroup) { + pdns := rg.Group("/dns/powerdns") + { + pdns.GET("/status", h.GetStatus) + pdns.POST("/enable", h.EnableService) + pdns.POST("/disable", h.DisableService) + pdns.POST("/restart", h.RestartService) + + pdns.GET("/zones", h.ListZones) + pdns.POST("/zones", h.CreateZone) + pdns.GET("/zones/:zoneId", h.GetZone) + pdns.DELETE("/zones/:zoneId", h.DeleteZone) + pdns.PATCH("/zones/:zoneId", h.UpdateRecords) + } +} + +func (h *PowerDNSHandlers) GetStatus(c *gin.Context) { + status, err := h.manager.GetStatus() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, status) +} + +func (h *PowerDNSHandlers) EnableService(c *gin.Context) { + if err := h.manager.EnableService(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "PowerDNS service enabled"}) +} + +func (h *PowerDNSHandlers) DisableService(c *gin.Context) { + if err := h.manager.DisableService(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "PowerDNS service disabled"}) +} + +func (h *PowerDNSHandlers) RestartService(c *gin.Context) { + if err := h.manager.RestartService(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "PowerDNS service restarted"}) +} + +func (h *PowerDNSHandlers) ListZones(c *gin.Context) { + zones, err := h.manager.ListZones() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"zones": zones}) +} + +func (h *PowerDNSHandlers) GetZone(c *gin.Context) { + zoneID := c.Param("zoneId") + + zone, err := h.manager.GetZone(zoneID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, zone) +} + +func (h *PowerDNSHandlers) CreateZone(c *gin.Context) { + var req dns.ZoneCreate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + zone, err := h.manager.CreateZone(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, zone) +} + +func (h *PowerDNSHandlers) DeleteZone(c *gin.Context) { + zoneID := c.Param("zoneId") + + if err := h.manager.DeleteZone(zoneID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Zone deleted"}) +} + +func (h *PowerDNSHandlers) UpdateRecords(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req struct { + RRSets []dns.RRSet `json:"rrsets"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.manager.UpdateRecords(zoneID, req.RRSets); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + zone, err := h.manager.GetZone(zoneID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, zone) +} diff --git a/internal/api/server.go b/internal/api/server.go index 705e5c9..8af037a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -15,8 +15,11 @@ import ( "strings" "time" + "github.com/flatrun/agent/internal/audit" "github.com/flatrun/agent/internal/auth" "github.com/flatrun/agent/internal/backup" + "github.com/flatrun/agent/internal/dns" + dnsPlugins "github.com/flatrun/agent/pkg/plugins/dns" "github.com/flatrun/agent/internal/certs" "github.com/flatrun/agent/internal/credentials" "github.com/flatrun/agent/internal/database" @@ -59,6 +62,9 @@ type Server struct { trafficManager *traffic.Manager backupManager *backup.Manager schedulerManager *scheduler.Manager + auditManager *audit.Manager + auditMiddleware *audit.Middleware + powerDNSManager *dns.PowerDNSManager } func New(cfg *config.Config, configPath string) *Server { @@ -131,6 +137,27 @@ func New(cfg *config.Config, configPath string) *Server { log.Printf("Warning: Failed to initialize backup manager: %v", err) } + var auditManager *audit.Manager + var auditMiddleware *audit.Middleware + if cfg.Audit.Enabled { + auditConfig := &audit.Config{ + Enabled: cfg.Audit.Enabled, + RetentionDays: cfg.Audit.RetentionDays, + CaptureRequestBody: cfg.Audit.CaptureRequestBody, + ExcludedPaths: cfg.Audit.ExcludedPaths, + SensitiveFields: cfg.Audit.SensitiveFields, + CleanupInterval: cfg.Audit.CleanupInterval, + } + auditManager, err = audit.NewManager(cfg.DeploymentsPath, auditConfig) + if err != nil { + log.Printf("Warning: Failed to initialize audit manager: %v", err) + } else { + auditMiddleware = audit.NewMiddleware(auditManager) + } + } + + powerDNSManager := dns.NewPowerDNSManager(cfg) + s := &Server{ config: cfg, configPath: configPath, @@ -149,6 +176,9 @@ func New(cfg *config.Config, configPath string) *Server { securityManager: securityManager, trafficManager: trafficManager, backupManager: backupManager, + auditManager: auditManager, + auditMiddleware: auditMiddleware, + powerDNSManager: powerDNSManager, } if backupManager != nil { @@ -180,6 +210,9 @@ func (s *Server) setupRoutes() { protected := api.Group("") protected.Use(s.authMiddleware.RequireAuth()) + if s.auditMiddleware != nil { + protected.Use(s.auditMiddleware.Capture()) + } { protected.GET("/deployments", s.listDeployments) protected.GET("/deployments/:name", s.getDeployment) @@ -352,6 +385,28 @@ func (s *Server) setupRoutes() { protected.POST("/scheduler/tasks/:id/run", s.runTaskNow) protected.GET("/scheduler/tasks/:id/executions", s.getTaskExecutions) protected.GET("/scheduler/executions", s.getRecentExecutions) + + // Audit endpoints + protected.GET("/audit/events", s.listAuditEvents) + protected.GET("/audit/events/:id", s.getAuditEvent) + protected.GET("/audit/stats", s.getAuditStats) + protected.POST("/audit/export", s.exportAuditEvents) + protected.DELETE("/audit/cleanup", s.cleanupAuditEvents) + + // DNS plugin routes + dnsGroup := protected.Group("/dns") + { + dnsGroup.GET("/providers", s.listDNSProviders) + + // Register DNS plugin routes + dnsPlugins.NewCloudflarePlugin().RegisterRoutes(dnsGroup) + dnsPlugins.NewRoute53Plugin().RegisterRoutes(dnsGroup) + dnsPlugins.NewDigitalOceanPlugin().RegisterRoutes(dnsGroup) + dnsPlugins.NewHetznerPlugin().RegisterRoutes(dnsGroup) + + // PowerDNS routes + NewPowerDNSHandlers(s.powerDNSManager).RegisterRoutes(protected) + } } // Ingest endpoints (no auth - called by nginx Lua) diff --git a/internal/audit/db.go b/internal/audit/db.go new file mode 100644 index 0000000..c3753f7 --- /dev/null +++ b/internal/audit/db.go @@ -0,0 +1,415 @@ +package audit + +import ( + "database/sql" + "os" + "path/filepath" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + conn *sql.DB + path string + mu sync.RWMutex +} + +func NewDB(deploymentsPath string) (*DB, error) { + dbDir := filepath.Join(deploymentsPath, ".flatrun") + if err := os.MkdirAll(dbDir, 0755); err != nil { + return nil, err + } + + dbPath := filepath.Join(dbDir, "audit.db") + conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, err + } + + conn.SetMaxOpenConns(1) + conn.SetMaxIdleConns(1) + conn.SetConnMaxLifetime(time.Hour) + + db := &DB{conn: conn, path: dbPath} + if err := db.migrate(); err != nil { + conn.Close() + return nil, err + } + + return db, nil +} + +func (db *DB) Close() error { + db.mu.Lock() + defer db.mu.Unlock() + return db.conn.Close() +} + +func (db *DB) migrate() error { + schema := ` + CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT UNIQUE NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + actor_type TEXT NOT NULL, + actor_id TEXT, + actor_name TEXT, + api_key_prefix TEXT, + action TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + client_ip TEXT NOT NULL, + user_agent TEXT, + request_id TEXT, + request_body TEXT, + response_status INTEGER, + response_time_ms INTEGER, + success BOOLEAN NOT NULL, + error_message TEXT, + metadata TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_events(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor_id); + CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action); + CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_events(resource_type, resource_id); + CREATE INDEX IF NOT EXISTS idx_audit_success ON audit_events(success); + CREATE INDEX IF NOT EXISTS idx_audit_client_ip ON audit_events(client_ip); + CREATE INDEX IF NOT EXISTS idx_audit_api_key ON audit_events(api_key_prefix); + ` + + _, err := db.conn.Exec(schema) + return err +} + +func (db *DB) InsertEvent(event *AuditEvent) (int64, error) { + db.mu.Lock() + defer db.mu.Unlock() + + result, err := db.conn.Exec(` + INSERT INTO audit_events + (event_id, timestamp, actor_type, actor_id, actor_name, api_key_prefix, + action, method, path, resource_type, resource_id, client_ip, user_agent, + request_id, request_body, response_status, response_time_ms, success, + error_message, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + event.EventID, event.Timestamp, event.ActorType, event.ActorID, + event.ActorName, event.APIKeyPrefix, event.Action, event.Method, + event.Path, event.ResourceType, event.ResourceID, event.ClientIP, + event.UserAgent, event.RequestID, event.RequestBody, event.ResponseStatus, + event.ResponseTimeMs, event.Success, event.ErrorMessage, event.Metadata, + ) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +func (db *DB) GetEvents(filter *AuditFilter) ([]AuditEvent, int, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + query := `SELECT id, event_id, timestamp, actor_type, actor_id, actor_name, + api_key_prefix, action, method, path, resource_type, resource_id, + client_ip, user_agent, request_id, request_body, response_status, + response_time_ms, success, error_message, metadata + FROM audit_events WHERE 1=1` + countQuery := "SELECT COUNT(*) FROM audit_events WHERE 1=1" + args := []interface{}{} + + if filter.ActorID != "" { + query += " AND actor_id = ?" + countQuery += " AND actor_id = ?" + args = append(args, filter.ActorID) + } + if filter.ActorType != "" { + query += " AND actor_type = ?" + countQuery += " AND actor_type = ?" + args = append(args, filter.ActorType) + } + if filter.Action != "" { + query += " AND action = ?" + countQuery += " AND action = ?" + args = append(args, filter.Action) + } + if filter.ResourceType != "" { + query += " AND resource_type = ?" + countQuery += " AND resource_type = ?" + args = append(args, filter.ResourceType) + } + if filter.ResourceID != "" { + query += " AND resource_id = ?" + countQuery += " AND resource_id = ?" + args = append(args, filter.ResourceID) + } + if filter.Success != nil { + query += " AND success = ?" + countQuery += " AND success = ?" + args = append(args, *filter.Success) + } + if filter.ClientIP != "" { + query += " AND client_ip = ?" + countQuery += " AND client_ip = ?" + args = append(args, filter.ClientIP) + } + if !filter.StartTime.IsZero() { + query += " AND timestamp >= ?" + countQuery += " AND timestamp >= ?" + args = append(args, filter.StartTime) + } + if !filter.EndTime.IsZero() { + query += " AND timestamp <= ?" + countQuery += " AND timestamp <= ?" + args = append(args, filter.EndTime) + } + + var total int + if err := db.conn.QueryRow(countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + query += " ORDER BY timestamp DESC" + if filter.Limit > 0 { + query += " LIMIT ?" + args = append(args, filter.Limit) + } + if filter.Offset > 0 { + query += " OFFSET ?" + args = append(args, filter.Offset) + } + + rows, err := db.conn.Query(query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var events []AuditEvent + for rows.Next() { + var e AuditEvent + var actorID, actorName, apiKeyPrefix, resourceType, resourceID sql.NullString + var userAgent, requestID, requestBody, errorMsg, metadata sql.NullString + var responseStatus, responseTimeMs sql.NullInt64 + + if err := rows.Scan( + &e.ID, &e.EventID, &e.Timestamp, &e.ActorType, &actorID, &actorName, + &apiKeyPrefix, &e.Action, &e.Method, &e.Path, &resourceType, &resourceID, + &e.ClientIP, &userAgent, &requestID, &requestBody, &responseStatus, + &responseTimeMs, &e.Success, &errorMsg, &metadata, + ); err != nil { + return nil, 0, err + } + + e.ActorID = actorID.String + e.ActorName = actorName.String + e.APIKeyPrefix = apiKeyPrefix.String + e.ResourceType = resourceType.String + e.ResourceID = resourceID.String + e.UserAgent = userAgent.String + e.RequestID = requestID.String + e.RequestBody = requestBody.String + e.ErrorMessage = errorMsg.String + e.Metadata = metadata.String + if responseStatus.Valid { + e.ResponseStatus = int(responseStatus.Int64) + } + if responseTimeMs.Valid { + e.ResponseTimeMs = responseTimeMs.Int64 + } + + events = append(events, e) + } + + return events, total, nil +} + +func (db *DB) GetEventByID(id int64) (*AuditEvent, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + var e AuditEvent + var actorID, actorName, apiKeyPrefix, resourceType, resourceID sql.NullString + var userAgent, requestID, requestBody, errorMsg, metadata sql.NullString + var responseStatus, responseTimeMs sql.NullInt64 + + err := db.conn.QueryRow(` + SELECT id, event_id, timestamp, actor_type, actor_id, actor_name, + api_key_prefix, action, method, path, resource_type, resource_id, + client_ip, user_agent, request_id, request_body, response_status, + response_time_ms, success, error_message, metadata + FROM audit_events WHERE id = ?`, id).Scan( + &e.ID, &e.EventID, &e.Timestamp, &e.ActorType, &actorID, &actorName, + &apiKeyPrefix, &e.Action, &e.Method, &e.Path, &resourceType, &resourceID, + &e.ClientIP, &userAgent, &requestID, &requestBody, &responseStatus, + &responseTimeMs, &e.Success, &errorMsg, &metadata, + ) + if err != nil { + return nil, err + } + + e.ActorID = actorID.String + e.ActorName = actorName.String + e.APIKeyPrefix = apiKeyPrefix.String + e.ResourceType = resourceType.String + e.ResourceID = resourceID.String + e.UserAgent = userAgent.String + e.RequestID = requestID.String + e.RequestBody = requestBody.String + e.ErrorMessage = errorMsg.String + e.Metadata = metadata.String + if responseStatus.Valid { + e.ResponseStatus = int(responseStatus.Int64) + } + if responseTimeMs.Valid { + e.ResponseTimeMs = responseTimeMs.Int64 + } + + return &e, nil +} + +func (db *DB) GetEventByEventID(eventID string) (*AuditEvent, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + var e AuditEvent + var actorID, actorName, apiKeyPrefix, resourceType, resourceID sql.NullString + var userAgent, requestID, requestBody, errorMsg, metadata sql.NullString + var responseStatus, responseTimeMs sql.NullInt64 + + err := db.conn.QueryRow(` + SELECT id, event_id, timestamp, actor_type, actor_id, actor_name, + api_key_prefix, action, method, path, resource_type, resource_id, + client_ip, user_agent, request_id, request_body, response_status, + response_time_ms, success, error_message, metadata + FROM audit_events WHERE event_id = ?`, eventID).Scan( + &e.ID, &e.EventID, &e.Timestamp, &e.ActorType, &actorID, &actorName, + &apiKeyPrefix, &e.Action, &e.Method, &e.Path, &resourceType, &resourceID, + &e.ClientIP, &userAgent, &requestID, &requestBody, &responseStatus, + &responseTimeMs, &e.Success, &errorMsg, &metadata, + ) + if err != nil { + return nil, err + } + + e.ActorID = actorID.String + e.ActorName = actorName.String + e.APIKeyPrefix = apiKeyPrefix.String + e.ResourceType = resourceType.String + e.ResourceID = resourceID.String + e.UserAgent = userAgent.String + e.RequestID = requestID.String + e.RequestBody = requestBody.String + e.ErrorMessage = errorMsg.String + e.Metadata = metadata.String + if responseStatus.Valid { + e.ResponseStatus = int(responseStatus.Int64) + } + if responseTimeMs.Valid { + e.ResponseTimeMs = responseTimeMs.Int64 + } + + return &e, nil +} + +func (db *DB) GetStats() (*AuditStats, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + stats := &AuditStats{ + ByAction: make(map[string]int), + ByActorType: make(map[string]int), + ByResourceType: make(map[string]int), + } + + _ = db.conn.QueryRow("SELECT COUNT(*) FROM audit_events").Scan(&stats.TotalEvents) + _ = db.conn.QueryRow("SELECT COUNT(*) FROM audit_events WHERE timestamp >= datetime('now', '-24 hours')").Scan(&stats.Last24Hours) + _ = db.conn.QueryRow("SELECT COUNT(*) FROM audit_events WHERE timestamp >= datetime('now', '-7 days')").Scan(&stats.Last7Days) + _ = db.conn.QueryRow("SELECT COUNT(*) FROM audit_events WHERE success = 0").Scan(&stats.FailureCount) + + rows, err := db.conn.Query("SELECT action, COUNT(*) FROM audit_events GROUP BY action") + if err == nil { + defer rows.Close() + for rows.Next() { + var action string + var count int + if err := rows.Scan(&action, &count); err == nil { + stats.ByAction[action] = count + } + } + } + + rows, err = db.conn.Query("SELECT actor_type, COUNT(*) FROM audit_events GROUP BY actor_type") + if err == nil { + defer rows.Close() + for rows.Next() { + var actorType string + var count int + if err := rows.Scan(&actorType, &count); err == nil { + stats.ByActorType[actorType] = count + } + } + } + + rows, err = db.conn.Query("SELECT resource_type, COUNT(*) FROM audit_events WHERE resource_type IS NOT NULL AND resource_type != '' GROUP BY resource_type") + if err == nil { + defer rows.Close() + for rows.Next() { + var resourceType string + var count int + if err := rows.Scan(&resourceType, &count); err == nil { + stats.ByResourceType[resourceType] = count + } + } + } + + rows, err = db.conn.Query(` + SELECT actor_id, actor_type, COUNT(*) as cnt, MAX(timestamp) as last_seen + FROM audit_events + WHERE actor_id IS NOT NULL AND actor_id != '' + GROUP BY actor_id, actor_type + ORDER BY cnt DESC + LIMIT 10`) + if err == nil { + defer rows.Close() + for rows.Next() { + var a ActorStats + if err := rows.Scan(&a.ActorID, &a.ActorType, &a.EventCount, &a.LastSeen); err == nil { + stats.TopActors = append(stats.TopActors, a) + } + } + } + + rows, err = db.conn.Query(` + SELECT date(timestamp) as dt, COUNT(*) as cnt + FROM audit_events + WHERE timestamp >= datetime('now', '-7 days') + GROUP BY dt + ORDER BY dt ASC`) + if err == nil { + defer rows.Close() + for rows.Next() { + var t TrendPoint + if err := rows.Scan(&t.Date, &t.Count); err == nil { + stats.EventsTrend = append(stats.EventsTrend, t) + } + } + } + + return stats, nil +} + +func (db *DB) CleanupOldEvents(olderThan time.Duration) (int64, error) { + db.mu.Lock() + defer db.mu.Unlock() + + cutoff := time.Now().Add(-olderThan) + result, err := db.conn.Exec("DELETE FROM audit_events WHERE timestamp < ?", cutoff) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/audit/manager.go b/internal/audit/manager.go new file mode 100644 index 0000000..0b94f0d --- /dev/null +++ b/internal/audit/manager.go @@ -0,0 +1,245 @@ +package audit + +import ( + "encoding/json" + "log" + "strings" + "time" +) + +type Config struct { + Enabled bool + RetentionDays int + CaptureRequestBody bool + ExcludedPaths []string + SensitiveFields []string + CleanupInterval time.Duration +} + +type Manager struct { + db *DB + config *Config + stopCh chan struct{} +} + +func NewManager(deploymentsPath string, config *Config) (*Manager, error) { + db, err := NewDB(deploymentsPath) + if err != nil { + return nil, err + } + + if config == nil { + config = &Config{ + Enabled: true, + RetentionDays: 30, + CaptureRequestBody: true, + ExcludedPaths: []string{"/api/health"}, + SensitiveFields: []string{"password", "token", "secret", "api_key", "authorization"}, + CleanupInterval: 24 * time.Hour, + } + } + + m := &Manager{ + db: db, + config: config, + stopCh: make(chan struct{}), + } + + if config.Enabled && config.RetentionDays > 0 { + go m.cleanupLoop() + } + + return m, nil +} + +func (m *Manager) Close() error { + close(m.stopCh) + return m.db.Close() +} + +func (m *Manager) IsEnabled() bool { + return m.config.Enabled +} + +func (m *Manager) ShouldCapturePath(path string) bool { + for _, excluded := range m.config.ExcludedPaths { + if strings.HasPrefix(path, excluded) { + return false + } + } + return true +} + +func (m *Manager) ShouldCaptureBody() bool { + return m.config.CaptureRequestBody +} + +func (m *Manager) LogEvent(event *AuditEvent) error { + if !m.config.Enabled { + return nil + } + + if event.RequestBody != "" && len(m.config.SensitiveFields) > 0 { + event.RequestBody = m.sanitizeBody(event.RequestBody) + } + + _, err := m.db.InsertEvent(event) + return err +} + +func (m *Manager) GetEvents(filter *AuditFilter) ([]AuditEvent, int, error) { + return m.db.GetEvents(filter) +} + +func (m *Manager) GetEventByID(id int64) (*AuditEvent, error) { + return m.db.GetEventByID(id) +} + +func (m *Manager) GetEventByEventID(eventID string) (*AuditEvent, error) { + return m.db.GetEventByEventID(eventID) +} + +func (m *Manager) GetStats() (*AuditStats, error) { + return m.db.GetStats() +} + +func (m *Manager) Cleanup() (int64, error) { + retention := time.Duration(m.config.RetentionDays) * 24 * time.Hour + return m.db.CleanupOldEvents(retention) +} + +func (m *Manager) cleanupLoop() { + ticker := time.NewTicker(m.config.CleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + deleted, err := m.Cleanup() + if err != nil { + log.Printf("Audit cleanup error: %v", err) + } else if deleted > 0 { + log.Printf("Audit cleanup: deleted %d old events", deleted) + } + case <-m.stopCh: + return + } + } +} + +func (m *Manager) sanitizeBody(body string) string { + var data map[string]interface{} + if err := json.Unmarshal([]byte(body), &data); err != nil { + return body + } + + m.redactSensitiveFields(data) + + sanitized, err := json.Marshal(data) + if err != nil { + return body + } + return string(sanitized) +} + +func (m *Manager) redactSensitiveFields(data map[string]interface{}) { + for key, value := range data { + keyLower := strings.ToLower(key) + for _, sensitive := range m.config.SensitiveFields { + if strings.Contains(keyLower, sensitive) { + data[key] = "[REDACTED]" + break + } + } + + if nested, ok := value.(map[string]interface{}); ok { + m.redactSensitiveFields(nested) + } + } +} + +func (m *Manager) ExportEvents(filter *AuditFilter, format string) ([]byte, error) { + events, _, err := m.db.GetEvents(filter) + if err != nil { + return nil, err + } + + switch format { + case "csv": + return m.exportCSV(events) + default: + return json.MarshalIndent(events, "", " ") + } +} + +func (m *Manager) exportCSV(events []AuditEvent) ([]byte, error) { + var sb strings.Builder + sb.WriteString("id,event_id,timestamp,actor_type,actor_id,action,method,path,resource_type,resource_id,client_ip,response_status,response_time_ms,success,error_message\n") + + for _, e := range events { + sb.WriteString(formatCSVRow( + e.ID, e.EventID, e.Timestamp.Format(time.RFC3339), + string(e.ActorType), e.ActorID, e.Action, e.Method, e.Path, + e.ResourceType, e.ResourceID, e.ClientIP, + e.ResponseStatus, e.ResponseTimeMs, e.Success, e.ErrorMessage, + )) + sb.WriteString("\n") + } + + return []byte(sb.String()), nil +} + +func formatCSVRow(values ...interface{}) string { + var parts []string + for _, v := range values { + str := "" + switch val := v.(type) { + case string: + str = escapeCSV(val) + case int: + str = formatInt(val) + case int64: + str = formatInt64(val) + case bool: + if val { + str = "true" + } else { + str = "false" + } + default: + str = "" + } + parts = append(parts, str) + } + return strings.Join(parts, ",") +} + +func escapeCSV(s string) string { + if strings.ContainsAny(s, ",\"\n") { + return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\"" + } + return s +} + +func formatInt(i int) string { + return strings.TrimPrefix(strings.TrimPrefix(formatInt64(int64(i)), "-"), "+") +} + +func formatInt64(i int64) string { + if i == 0 { + return "0" + } + negative := i < 0 + if negative { + i = -i + } + var digits []byte + for i > 0 { + digits = append([]byte{byte('0' + i%10)}, digits...) + i /= 10 + } + if negative { + return "-" + string(digits) + } + return string(digits) +} diff --git a/internal/audit/middleware.go b/internal/audit/middleware.go new file mode 100644 index 0000000..e1e5e59 --- /dev/null +++ b/internal/audit/middleware.go @@ -0,0 +1,195 @@ +package audit + +import ( + "bytes" + "io" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const ( + ContextKeyActorType = "audit_actor_type" + ContextKeyActorID = "audit_actor_id" + ContextKeyActorName = "audit_actor_name" + ContextKeyAPIKeyPrefix = "audit_api_key_prefix" + ContextKeyRequestID = "audit_request_id" +) + +type Middleware struct { + manager *Manager +} + +func NewMiddleware(manager *Manager) *Middleware { + return &Middleware{manager: manager} +} + +func (m *Middleware) Capture() gin.HandlerFunc { + return func(c *gin.Context) { + if !m.manager.IsEnabled() { + c.Next() + return + } + + if !m.manager.ShouldCapturePath(c.Request.URL.Path) { + c.Next() + return + } + + startTime := time.Now() + requestID := uuid.New().String() + c.Set(ContextKeyRequestID, requestID) + + var requestBody string + if m.manager.ShouldCaptureBody() && c.Request.Body != nil { + bodyBytes, _ := io.ReadAll(c.Request.Body) + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + requestBody = string(bodyBytes) + if len(requestBody) > 10000 { + requestBody = requestBody[:10000] + "...[truncated]" + } + } + + c.Next() + + event := &AuditEvent{ + EventID: requestID, + Timestamp: startTime, + ActorType: m.getActorType(c), + ActorID: m.getStringContext(c, ContextKeyActorID), + ActorName: m.getStringContext(c, ContextKeyActorName), + APIKeyPrefix: m.getStringContext(c, ContextKeyAPIKeyPrefix), + Action: m.determineAction(c), + Method: c.Request.Method, + Path: c.Request.URL.Path, + ResourceType: m.extractResourceType(c), + ResourceID: m.extractResourceID(c), + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + RequestID: requestID, + RequestBody: requestBody, + ResponseStatus: c.Writer.Status(), + ResponseTimeMs: time.Since(startTime).Milliseconds(), + Success: c.Writer.Status() < 400, + ErrorMessage: c.Errors.String(), + } + + go m.manager.LogEvent(event) + } +} + +func (m *Middleware) getActorType(c *gin.Context) ActorType { + if val, exists := c.Get(ContextKeyActorType); exists { + if actorType, ok := val.(ActorType); ok { + return actorType + } + if str, ok := val.(string); ok { + return ActorType(str) + } + } + return ActorTypeAnonymous +} + +func (m *Middleware) getStringContext(c *gin.Context, key string) string { + if val, exists := c.Get(key); exists { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +func (m *Middleware) determineAction(c *gin.Context) string { + path := c.Request.URL.Path + method := c.Request.Method + + path = strings.TrimPrefix(path, "/api/") + path = strings.TrimPrefix(path, "/api/v1/") + + parts := strings.Split(path, "/") + if len(parts) == 0 { + return method + "_unknown" + } + + resource := parts[0] + if len(parts) > 1 && !isID(parts[1]) { + resource = parts[0] + "_" + parts[1] + } + + var action string + switch method { + case "GET": + if len(parts) > 1 && isID(parts[len(parts)-1]) { + action = "read" + } else { + action = "list" + } + case "POST": + action = "create" + case "PUT", "PATCH": + action = "update" + case "DELETE": + action = "delete" + default: + action = strings.ToLower(method) + } + + return resource + "." + action +} + +func (m *Middleware) extractResourceType(c *gin.Context) string { + path := c.Request.URL.Path + path = strings.TrimPrefix(path, "/api/") + path = strings.TrimPrefix(path, "/api/v1/") + + parts := strings.Split(path, "/") + if len(parts) == 0 { + return "" + } + + return parts[0] +} + +func (m *Middleware) extractResourceID(c *gin.Context) string { + if id := c.Param("name"); id != "" { + return id + } + if id := c.Param("id"); id != "" { + return id + } + + path := c.Request.URL.Path + parts := strings.Split(path, "/") + for i := len(parts) - 1; i >= 0; i-- { + if isID(parts[i]) { + return parts[i] + } + } + + return "" +} + +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +var numericRegex = regexp.MustCompile(`^\d+$`) + +func isID(s string) bool { + if s == "" { + return false + } + if uuidRegex.MatchString(s) { + return true + } + if numericRegex.MatchString(s) { + return true + } + if len(s) >= 8 && len(s) <= 64 && !strings.Contains(s, " ") { + alphaNum := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + if alphaNum.MatchString(s) && strings.ContainsAny(s, "0123456789") { + return true + } + } + return false +} diff --git a/internal/audit/models.go b/internal/audit/models.go new file mode 100644 index 0000000..36ae283 --- /dev/null +++ b/internal/audit/models.go @@ -0,0 +1,74 @@ +package audit + +import "time" + +type ActorType string + +const ( + ActorTypeAPIKey ActorType = "api_key" + ActorTypeJWT ActorType = "jwt" + ActorTypeSystem ActorType = "system" + ActorTypeAnonymous ActorType = "anonymous" +) + +type AuditEvent struct { + ID int64 `json:"id"` + EventID string `json:"event_id"` + Timestamp time.Time `json:"timestamp"` + ActorType ActorType `json:"actor_type"` + ActorID string `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + APIKeyPrefix string `json:"api_key_prefix,omitempty"` + Action string `json:"action"` + Method string `json:"method"` + Path string `json:"path"` + ResourceType string `json:"resource_type,omitempty"` + ResourceID string `json:"resource_id,omitempty"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent,omitempty"` + RequestID string `json:"request_id,omitempty"` + RequestBody string `json:"request_body,omitempty"` + ResponseStatus int `json:"response_status"` + ResponseTimeMs int64 `json:"response_time_ms"` + Success bool `json:"success"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata string `json:"metadata,omitempty"` +} + +type AuditFilter struct { + ActorID string + ActorType ActorType + Action string + ResourceType string + ResourceID string + Success *bool + ClientIP string + StartTime time.Time + EndTime time.Time + Limit int + Offset int +} + +type AuditStats struct { + TotalEvents int `json:"total_events"` + Last24Hours int `json:"last_24_hours"` + Last7Days int `json:"last_7_days"` + ByAction map[string]int `json:"by_action"` + ByActorType map[string]int `json:"by_actor_type"` + ByResourceType map[string]int `json:"by_resource_type"` + FailureCount int `json:"failure_count"` + TopActors []ActorStats `json:"top_actors"` + EventsTrend []TrendPoint `json:"events_trend"` +} + +type ActorStats struct { + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + EventCount int `json:"event_count"` + LastSeen string `json:"last_seen"` +} + +type TrendPoint struct { + Date string `json:"date"` + Count int `json:"count"` +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 9ec2d0f..969cdea 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -2,6 +2,7 @@ package auth import ( "crypto/subtle" + "fmt" "net/http" "strings" "time" @@ -11,6 +12,13 @@ import ( "github.com/golang-jwt/jwt/v5" ) +const ( + ContextKeyActorType = "audit_actor_type" + ContextKeyActorID = "audit_actor_id" + ContextKeyActorName = "audit_actor_name" + ContextKeyAPIKeyPrefix = "audit_api_key_prefix" +) + type Claims struct { Username string `json:"username"` jwt.RegisteredClaims @@ -27,6 +35,7 @@ func NewMiddleware(cfg *config.AuthConfig) *Middleware { func (m *Middleware) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { if !m.config.Enabled { + c.Set(ContextKeyActorType, "anonymous") c.Next() return } @@ -54,12 +63,21 @@ func (m *Middleware) RequireAuth() gin.HandlerFunc { switch scheme { case "bearer": - if m.validateJWT(token) || m.validateAPIKey(token) { + if claims := m.validateJWTWithClaims(token); claims != nil { + c.Set(ContextKeyActorType, "jwt") + c.Set(ContextKeyActorID, claims.Username) + c.Set(ContextKeyActorName, claims.Username) + c.Next() + return + } + if keyIndex := m.validateAPIKeyWithIndex(token); keyIndex >= 0 { + m.setAPIKeyContext(c, token, keyIndex) c.Next() return } case "apikey": - if m.validateAPIKey(token) { + if keyIndex := m.validateAPIKeyWithIndex(token); keyIndex >= 0 { + m.setAPIKeyContext(c, token, keyIndex) c.Next() return } @@ -78,6 +96,35 @@ func (m *Middleware) RequireAuth() gin.HandlerFunc { } } +func (m *Middleware) setAPIKeyContext(c *gin.Context, token string, keyIndex int) { + c.Set(ContextKeyActorType, "api_key") + c.Set(ContextKeyActorID, fmt.Sprintf("key_%d", keyIndex)) + if len(token) >= 8 { + c.Set(ContextKeyAPIKeyPrefix, token[:8]+"...") + } else { + c.Set(ContextKeyAPIKeyPrefix, token+"...") + } +} + +func (m *Middleware) validateJWTWithClaims(tokenString string) *Claims { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(m.config.JWTSecret), nil + }) + + if err != nil { + return nil + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + if claims.ExpiresAt != nil && claims.ExpiresAt.Time.Before(time.Now()) { + return nil + } + return claims + } + + return nil +} + func (m *Middleware) validateAPIKey(key string) bool { for _, validKey := range m.config.APIKeys { if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 { @@ -87,6 +134,15 @@ func (m *Middleware) validateAPIKey(key string) bool { return false } +func (m *Middleware) validateAPIKeyWithIndex(key string) int { + for i, validKey := range m.config.APIKeys { + if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 { + return i + } + } + return -1 +} + func (m *Middleware) validateJWT(tokenString string) bool { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(m.config.JWTSecret), nil diff --git a/internal/dns/powerdns.go b/internal/dns/powerdns.go new file mode 100644 index 0000000..07ae374 --- /dev/null +++ b/internal/dns/powerdns.go @@ -0,0 +1,551 @@ +package dns + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" +) + +type PowerDNSManager struct { + config *config.Config + mu sync.RWMutex + client *http.Client +} + +func NewPowerDNSManager(cfg *config.Config) *PowerDNSManager { + return &PowerDNSManager{ + config: cfg, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (m *PowerDNSManager) UpdateConfig(cfg *config.Config) { + m.mu.Lock() + defer m.mu.Unlock() + m.config = cfg +} + +type PowerDNSStatus struct { + Running bool `json:"running"` + Version string `json:"version,omitempty"` +} + +func (m *PowerDNSManager) GetStatus() (*PowerDNSStatus, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.config.Infrastructure.PowerDNS.Enabled { + return &PowerDNSStatus{Running: false}, nil + } + + containerName := m.config.Infrastructure.PowerDNS.Container + cmd := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", containerName) + output, err := cmd.Output() + if err != nil { + return &PowerDNSStatus{Running: false}, nil + } + + running := strings.TrimSpace(string(output)) == "true" + status := &PowerDNSStatus{Running: running} + + if running { + if version, err := m.getAPIVersion(); err == nil { + status.Version = version + } + } + + return status, nil +} + +func (m *PowerDNSManager) getAPIVersion() (string, error) { + resp, err := m.apiRequest("GET", "/api/v1/servers/localhost", nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var server struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&server); err != nil { + return "", err + } + return server.Version, nil +} + +func (m *PowerDNSManager) EnableService() error { + m.mu.Lock() + defer m.mu.Unlock() + + pdnsDir := m.getPowerDNSDir() + if err := os.MkdirAll(pdnsDir, 0755); err != nil { + return fmt.Errorf("failed to create PowerDNS directory: %w", err) + } + + dataDir := filepath.Join(pdnsDir, "data") + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + if err := m.initializeDatabase(dataDir); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + if err := m.writeComposeFile(); err != nil { + return fmt.Errorf("failed to write docker-compose: %w", err) + } + + cmd := exec.Command("docker", "compose", "-f", filepath.Join(pdnsDir, "docker-compose.yml"), "up", "-d") + cmd.Dir = pdnsDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to start PowerDNS: %s - %w", string(output), err) + } + + m.config.Infrastructure.PowerDNS.Enabled = true + return nil +} + +func (m *PowerDNSManager) initializeDatabase(dataDir string) error { + dbPath := filepath.Join(dataDir, "pdns.sqlite3") + + if _, err := os.Stat(dbPath); err == nil { + return nil + } + + schema := ` +PRAGMA foreign_keys = 1; +CREATE TABLE domains ( + id INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL COLLATE NOCASE, + master VARCHAR(128) DEFAULT NULL, + last_check INTEGER DEFAULT NULL, + type VARCHAR(8) NOT NULL, + notified_serial INTEGER DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + options VARCHAR(65535) DEFAULT NULL, + catalog VARCHAR(255) DEFAULT NULL +); +CREATE UNIQUE INDEX name_index ON domains(name); +CREATE INDEX catalog_idx ON domains(catalog); +CREATE TABLE records ( + id INTEGER PRIMARY KEY, + domain_id INTEGER DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(65535) DEFAULT NULL, + ttl INTEGER DEFAULT NULL, + prio INTEGER DEFAULT NULL, + disabled BOOLEAN DEFAULT 0, + ordername VARCHAR(255), + auth BOOL DEFAULT 1, + FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX records_lookup_idx ON records(name, type); +CREATE INDEX records_lookup_id_idx ON records(domain_id, name, type); +CREATE INDEX records_order_idx ON records(domain_id, ordername); +CREATE TABLE supermasters ( + ip VARCHAR(64) NOT NULL, + nameserver VARCHAR(255) NOT NULL COLLATE NOCASE, + account VARCHAR(40) NOT NULL +); +CREATE UNIQUE INDEX ip_nameserver_pk ON supermasters(ip, nameserver); +CREATE TABLE comments ( + id INTEGER PRIMARY KEY, + domain_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) DEFAULT NULL, + comment VARCHAR(65535) NOT NULL, + FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX comments_idx ON comments(domain_id, name, type); +CREATE INDEX comments_order_idx ON comments(domain_id, modified_at); +CREATE TABLE domainmetadata ( + id INTEGER PRIMARY KEY, + domain_id INT NOT NULL, + kind VARCHAR(32) COLLATE NOCASE, + content TEXT, + FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX domainmetaidindex ON domainmetadata(domain_id); +CREATE TABLE cryptokeys ( + id INTEGER PRIMARY KEY, + domain_id INT NOT NULL, + flags INT NOT NULL, + active BOOL, + published BOOL DEFAULT 1, + content TEXT, + FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX domainidindex ON cryptokeys(domain_id); +CREATE TABLE tsigkeys ( + id INTEGER PRIMARY KEY, + name VARCHAR(255) COLLATE NOCASE, + algorithm VARCHAR(50) COLLATE NOCASE, + secret VARCHAR(255) +); +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); +` + + absDataDir, err := filepath.Abs(dataDir) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + uid := fmt.Sprintf("%d", os.Getuid()) + gid := fmt.Sprintf("%d", os.Getgid()) + + cmd := exec.Command("docker", "run", "--rm", + "-v", absDataDir+":/data", + "alpine:latest", + "sh", "-c", "apk add --no-cache sqlite > /dev/null 2>&1 && sqlite3 /data/pdns.sqlite3 \""+schema+"\" && chown "+uid+":"+gid+" /data/pdns.sqlite3") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("database init failed: %s - %w", string(output), err) + } + + return nil +} + +func (m *PowerDNSManager) DisableService() error { + m.mu.Lock() + defer m.mu.Unlock() + + pdnsDir := m.getPowerDNSDir() + composePath := filepath.Join(pdnsDir, "docker-compose.yml") + + if _, err := os.Stat(composePath); err == nil { + cmd := exec.Command("docker", "compose", "-f", composePath, "down") + cmd.Dir = pdnsDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to stop PowerDNS: %s - %w", string(output), err) + } + } + + m.config.Infrastructure.PowerDNS.Enabled = false + return nil +} + +func (m *PowerDNSManager) RestartService() error { + m.mu.RLock() + containerName := m.config.Infrastructure.PowerDNS.Container + m.mu.RUnlock() + + cmd := exec.Command("docker", "restart", containerName) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to restart PowerDNS: %s - %w", string(output), err) + } + return nil +} + +func (m *PowerDNSManager) GetInfraService() models.InfraService { + m.mu.RLock() + defer m.mu.RUnlock() + + svc := models.InfraService{ + Name: models.InfraTypePowerDNS, + Type: models.InfraTypePowerDNS, + Image: m.config.Infrastructure.PowerDNS.Image, + Managed: m.config.Infrastructure.PowerDNS.Enabled, + Config: map[string]any{ + "container": m.config.Infrastructure.PowerDNS.Container, + "api_port": m.config.Infrastructure.PowerDNS.APIPort, + "dns_port": m.config.Infrastructure.PowerDNS.DNSPort, + }, + } + + if !m.config.Infrastructure.PowerDNS.Enabled { + svc.Status = models.InfraStatusStopped + return svc + } + + containerName := m.config.Infrastructure.PowerDNS.Container + svc.Status, svc.ContainerID, svc.Health, svc.CreatedAt = m.getContainerStatus(containerName) + + return svc +} + +func (m *PowerDNSManager) getContainerStatus(containerName string) (status, containerID, health string, createdAt time.Time) { + if containerName == "" { + return models.InfraStatusUnknown, "", "", time.Time{} + } + + cmd := exec.Command("docker", "inspect", "--format", "{{json .}}", containerName) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return models.InfraStatusStopped, "", "", time.Time{} + } + + var container struct { + ID string `json:"Id"` + State struct { + Status string `json:"Status"` + Running bool `json:"Running"` + Health *struct { + Status string `json:"Status"` + } `json:"Health,omitempty"` + } `json:"State"` + Created string `json:"Created"` + } + + if err := json.Unmarshal(stdout.Bytes(), &container); err != nil { + return models.InfraStatusUnknown, "", "", time.Time{} + } + + containerID = container.ID[:12] + + if container.State.Running { + status = models.InfraStatusRunning + } else { + status = models.InfraStatusStopped + } + + if container.State.Health != nil { + health = container.State.Health.Status + } + + if created, err := time.Parse(time.RFC3339Nano, container.Created); err == nil { + createdAt = created + } + + return status, containerID, health, createdAt +} + +func (m *PowerDNSManager) getPowerDNSDir() string { + return filepath.Join(m.config.DeploymentsPath, "powerdns") +} + +func (m *PowerDNSManager) writeComposeFile() error { + pdnsDir := m.getPowerDNSDir() + composePath := filepath.Join(pdnsDir, "docker-compose.yml") + + cfg := m.config.Infrastructure.PowerDNS + + pdnsConf := fmt.Sprintf(`launch=gsqlite3 +gsqlite3-database=/var/lib/powerdns/pdns.sqlite3 +gsqlite3-dnssec=yes +local-address=0.0.0.0,:: +api=yes +api-key=%s +webserver=yes +webserver-address=0.0.0.0 +webserver-port=8081 +webserver-allow-from=0.0.0.0/0,::/0 +default-soa-content=ns1.@ hostmaster.@ 0 10800 3600 604800 3600 +`, cfg.APIKey) + + confPath := filepath.Join(pdnsDir, "pdns.conf") + if err := os.WriteFile(confPath, []byte(pdnsConf), 0644); err != nil { + return fmt.Errorf("failed to write pdns.conf: %w", err) + } + + content := fmt.Sprintf(`services: + pdns: + image: %s + container_name: %s + restart: unless-stopped + ports: + - "%d:53/udp" + - "%d:53/tcp" + - "127.0.0.1:%d:8081" + volumes: + - ./data:/var/lib/powerdns + - ./pdns.conf:/etc/powerdns/pdns.conf:ro + networks: + - proxy + +networks: + proxy: + external: true +`, cfg.Image, cfg.Container, cfg.DNSPort, cfg.DNSPort, cfg.APIPort) + + return os.WriteFile(composePath, []byte(content), 0644) +} + +func (m *PowerDNSManager) getAPIKey() string { + confPath := filepath.Join(m.getPowerDNSDir(), "pdns.conf") + data, err := os.ReadFile(confPath) + if err != nil { + return m.config.Infrastructure.PowerDNS.APIKey + } + + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "api-key=") { + return strings.TrimPrefix(line, "api-key=") + } + } + + return m.config.Infrastructure.PowerDNS.APIKey +} + +func (m *PowerDNSManager) apiRequest(method, path string, body interface{}) (*http.Response, error) { + cfg := m.config.Infrastructure.PowerDNS + url := fmt.Sprintf("http://127.0.0.1:%d%s", cfg.APIPort, path) + + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, err + } + + req.Header.Set("X-API-Key", m.getAPIKey()) + req.Header.Set("Content-Type", "application/json") + + return m.client.Do(req) +} + +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Serial int `json:"serial"` + DNSSec bool `json:"dnssec"` + RRSets []RRSet `json:"rrsets,omitempty"` + Nameservers []string `json:"nameservers,omitempty"` +} + +type RRSet struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + ChangeType string `json:"changetype,omitempty"` + Records []Record `json:"records"` +} + +type Record struct { + Content string `json:"content"` + Disabled bool `json:"disabled"` +} + +type ZoneCreate struct { + Name string `json:"name"` + Kind string `json:"kind"` + Nameservers []string `json:"nameservers,omitempty"` +} + +func (m *PowerDNSManager) ListZones() ([]Zone, error) { + resp, err := m.apiRequest("GET", "/api/v1/servers/localhost/zones", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s", string(body)) + } + + var zones []Zone + if err := json.NewDecoder(resp.Body).Decode(&zones); err != nil { + return nil, err + } + return zones, nil +} + +func (m *PowerDNSManager) GetZone(zoneID string) (*Zone, error) { + resp, err := m.apiRequest("GET", fmt.Sprintf("/api/v1/servers/localhost/zones/%s", zoneID), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s", string(body)) + } + + var zone Zone + if err := json.NewDecoder(resp.Body).Decode(&zone); err != nil { + return nil, err + } + return &zone, nil +} + +func (m *PowerDNSManager) CreateZone(create ZoneCreate) (*Zone, error) { + if !strings.HasSuffix(create.Name, ".") { + create.Name = create.Name + "." + } + + payload := map[string]interface{}{ + "name": create.Name, + "kind": create.Kind, + } + if len(create.Nameservers) > 0 { + payload["nameservers"] = create.Nameservers + } + + resp, err := m.apiRequest("POST", "/api/v1/servers/localhost/zones", payload) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s", string(body)) + } + + var zone Zone + if err := json.NewDecoder(resp.Body).Decode(&zone); err != nil { + return nil, err + } + return &zone, nil +} + +func (m *PowerDNSManager) DeleteZone(zoneID string) error { + resp, err := m.apiRequest("DELETE", fmt.Sprintf("/api/v1/servers/localhost/zones/%s", zoneID), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error: %s", string(body)) + } + return nil +} + +func (m *PowerDNSManager) UpdateRecords(zoneID string, rrsets []RRSet) error { + payload := map[string]interface{}{ + "rrsets": rrsets, + } + + resp, err := m.apiRequest("PATCH", fmt.Sprintf("/api/v1/servers/localhost/zones/%s", zoneID), payload) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error: %s", string(body)) + } + return nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7f67bef..4307447 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -21,6 +21,7 @@ type Config struct { Health HealthConfig `yaml:"health"` Infrastructure InfrastructureConfig `yaml:"infrastructure"` Security SecurityConfig `yaml:"security"` + Audit AuditConfig `yaml:"audit"` } type DomainConfig struct { @@ -93,6 +94,19 @@ type InfrastructureConfig struct { DefaultDatabaseNetwork string `yaml:"default_database_network" json:"default_database_network"` Database SharedDatabaseConfig `yaml:"database" json:"database"` Redis SharedRedisConfig `yaml:"redis" json:"redis"` + PowerDNS PowerDNSConfig `yaml:"powerdns" json:"powerdns"` +} + +type PowerDNSConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Container string `yaml:"container" json:"container"` + Image string `yaml:"image" json:"image"` + APIPort int `yaml:"api_port" json:"api_port"` + DNSPort int `yaml:"dns_port" json:"dns_port"` + APIKey string `yaml:"api_key" json:"api_key"` + DataPath string `yaml:"data_path" json:"data_path"` + DefaultSOA string `yaml:"default_soa" json:"default_soa"` + Nameservers string `yaml:"nameservers" json:"nameservers"` } type SharedDatabaseConfig struct { @@ -134,6 +148,15 @@ type SecurityConfig struct { InternalAPIToken string `yaml:"internal_api_token" json:"-"` } +type AuditConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + RetentionDays int `yaml:"retention_days" json:"retention_days"` + CaptureRequestBody bool `yaml:"capture_request_body" json:"capture_request_body"` + ExcludedPaths []string `yaml:"excluded_paths" json:"excluded_paths"` + SensitiveFields []string `yaml:"sensitive_fields" json:"sensitive_fields"` + CleanupInterval time.Duration `yaml:"cleanup_interval" json:"cleanup_interval"` +} + func FindConfigPath(providedPath string) string { if providedPath != "" && providedPath != "config.yml" { return providedPath @@ -280,6 +303,38 @@ func setDefaults(cfg *Config) { cfg.Security.InternalAPIToken = hex.EncodeToString(bytes) } } + // PowerDNS defaults + if cfg.Infrastructure.PowerDNS.Container == "" { + cfg.Infrastructure.PowerDNS.Container = "powerdns" + } + if cfg.Infrastructure.PowerDNS.Image == "" { + cfg.Infrastructure.PowerDNS.Image = "powerdns/pdns-auth-48:latest" + } + if cfg.Infrastructure.PowerDNS.APIPort == 0 { + cfg.Infrastructure.PowerDNS.APIPort = 8081 + } + if cfg.Infrastructure.PowerDNS.DNSPort == 0 { + cfg.Infrastructure.PowerDNS.DNSPort = 53 + } + if cfg.Infrastructure.PowerDNS.APIKey == "" { + bytes := make([]byte, 24) + if _, err := rand.Read(bytes); err == nil { + cfg.Infrastructure.PowerDNS.APIKey = hex.EncodeToString(bytes) + } + } + // Audit defaults + if cfg.Audit.RetentionDays == 0 { + cfg.Audit.RetentionDays = 30 + } + if cfg.Audit.CleanupInterval == 0 { + cfg.Audit.CleanupInterval = 24 * time.Hour + } + if cfg.Audit.ExcludedPaths == nil { + cfg.Audit.ExcludedPaths = []string{"/api/health"} + } + if cfg.Audit.SensitiveFields == nil { + cfg.Audit.SensitiveFields = []string{"password", "token", "secret", "api_key", "authorization"} + } } func Save(cfg *Config, path string) error { diff --git a/pkg/models/infrastructure.go b/pkg/models/infrastructure.go index b99ec95..80a8613 100644 --- a/pkg/models/infrastructure.go +++ b/pkg/models/infrastructure.go @@ -20,6 +20,7 @@ const ( InfraTypeCertbot = "certbot" InfraTypeDatabase = "database" InfraTypeRedis = "redis" + InfraTypePowerDNS = "powerdns" InfraStatusRunning = "running" InfraStatusStopped = "stopped" diff --git a/pkg/plugins/dns/base.go b/pkg/plugins/dns/base.go new file mode 100644 index 0000000..162c5d1 --- /dev/null +++ b/pkg/plugins/dns/base.go @@ -0,0 +1,42 @@ +package dns + +import ( + "github.com/flatrun/agent/pkg/plugins" + "github.com/gin-gonic/gin" +) + +type BaseDNSPlugin struct { + info plugins.PluginInfo + creds map[string]string +} + +func (p *BaseDNSPlugin) Info() plugins.PluginInfo { + return p.info +} + +func (p *BaseDNSPlugin) Initialize(config map[string]interface{}) error { + return nil +} + +func (p *BaseDNSPlugin) Start() error { + return nil +} + +func (p *BaseDNSPlugin) Stop() error { + return nil +} + +func (p *BaseDNSPlugin) GetCapabilities() []plugins.Capability { + return []plugins.Capability{ + plugins.CapDNSZoneManagement, + plugins.CapDNSRecordManagement, + } +} + +func (p *BaseDNSPlugin) RegisterRoutes(router *gin.RouterGroup) error { + return nil +} + +func (p *BaseDNSPlugin) GetWidgetData(deploymentName string) (interface{}, error) { + return nil, nil +} diff --git a/pkg/plugins/dns/cloudflare.go b/pkg/plugins/dns/cloudflare.go new file mode 100644 index 0000000..9940556 --- /dev/null +++ b/pkg/plugins/dns/cloudflare.go @@ -0,0 +1,396 @@ +package dns + +import ( + "context" + "fmt" + "net/http" + + "github.com/cloudflare/cloudflare-go" + "github.com/flatrun/agent/pkg/plugins" + "github.com/gin-gonic/gin" +) + +type CloudflarePlugin struct { + BaseDNSPlugin +} + +func NewCloudflarePlugin() *CloudflarePlugin { + return &CloudflarePlugin{ + BaseDNSPlugin: BaseDNSPlugin{ + info: plugins.PluginInfo{ + Name: "dns-cloudflare", + DisplayName: "Cloudflare DNS", + Version: "1.0.0", + Description: "Cloudflare DNS management", + Author: "FlatRun", + Type: plugins.TypeDNS, + Category: "dns", + Enabled: true, + Capabilities: []string{ + string(plugins.CapDNSZoneManagement), + string(plugins.CapDNSRecordManagement), + }, + }, + }, + } +} + +func (p *CloudflarePlugin) ProviderName() string { + return "cloudflare" +} + +func (p *CloudflarePlugin) RequiredCredentials() []plugins.CredentialField { + return []plugins.CredentialField{ + { + Name: "api_token", + Label: "API Token", + Type: "password", + Required: true, + HelpText: "Cloudflare API token with Zone:Read and DNS:Edit permissions", + }, + } +} + +func (p *CloudflarePlugin) RegisterRoutes(router *gin.RouterGroup) error { + provider := router.Group("/cloudflare") + { + provider.GET("/info", p.handleInfo) + provider.POST("/validate", p.handleValidate) + provider.POST("/zones", p.handleListZones) + provider.POST("/zones/:zoneId", p.handleGetZone) + provider.POST("/zones/:zoneId/records", p.handleListRecords) + provider.POST("/zones/:zoneId/records/create", p.handleCreateRecord) + provider.PUT("/zones/:zoneId/records/:recordId", p.handleUpdateRecord) + provider.DELETE("/zones/:zoneId/records/:recordId", p.handleDeleteRecord) + } + return nil +} + +type cloudflareRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` +} + +type cloudflareRecordRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordCreate `json:"record"` +} + +type cloudflareUpdateRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordUpdate `json:"record"` +} + +func (p *CloudflarePlugin) getAPI(creds map[string]string) (*cloudflare.API, error) { + token, ok := creds["api_token"] + if !ok || token == "" { + return nil, fmt.Errorf("api_token is required") + } + return cloudflare.NewWithAPIToken(token) +} + +func (p *CloudflarePlugin) handleInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "name": p.info.Name, + "display_name": p.info.DisplayName, + "provider": p.ProviderName(), + "credentials": p.RequiredCredentials(), + }) +} + +func (p *CloudflarePlugin) handleValidate(c *gin.Context) { + var req cloudflareRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + _, err = api.ListZones(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true}) +} + +func (p *CloudflarePlugin) handleListZones(c *gin.Context) { + var req cloudflareRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + zones, err := api.ListZones(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSZone + for _, z := range zones { + result = append(result, plugins.DNSZone{ + ID: z.ID, + Name: z.Name, + Status: z.Status, + NameServers: z.NameServers, + }) + } + + c.JSON(http.StatusOK, gin.H{"zones": result}) +} + +func (p *CloudflarePlugin) handleGetZone(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req cloudflareRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + z, err := api.ZoneDetails(c.Request.Context(), zoneID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, plugins.DNSZone{ + ID: z.ID, + Name: z.Name, + Status: z.Status, + NameServers: z.NameServers, + }) +} + +func (p *CloudflarePlugin) handleListRecords(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req cloudflareRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rc := cloudflare.ZoneIdentifier(zoneID) + records, _, err := api.ListDNSRecords(c.Request.Context(), rc, cloudflare.ListDNSRecordsParams{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSRecord + for _, r := range records { + record := plugins.DNSRecord{ + ID: r.ID, + ZoneID: zoneID, + Type: r.Type, + Name: r.Name, + Content: r.Content, + TTL: r.TTL, + } + if r.Priority != nil { + priority := int(*r.Priority) + record.Priority = &priority + } + proxied := r.Proxied != nil && *r.Proxied + record.Proxied = &proxied + result = append(result, record) + } + + c.JSON(http.StatusOK, gin.H{"records": result}) +} + +func (p *CloudflarePlugin) handleCreateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req cloudflareRecordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rc := cloudflare.ZoneIdentifier(zoneID) + params := cloudflare.CreateDNSRecordParams{ + Type: req.Record.Type, + Name: req.Record.Name, + Content: req.Record.Content, + TTL: req.Record.TTL, + } + + if req.Record.Priority != nil { + priority := uint16(*req.Record.Priority) + params.Priority = &priority + } + if req.Record.Proxied != nil { + params.Proxied = req.Record.Proxied + } + + r, err := api.CreateDNSRecord(c.Request.Context(), rc, params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := plugins.DNSRecord{ + ID: r.ID, + ZoneID: zoneID, + Type: r.Type, + Name: r.Name, + Content: r.Content, + TTL: r.TTL, + } + if r.Priority != nil { + priority := int(*r.Priority) + result.Priority = &priority + } + if r.Proxied != nil { + result.Proxied = r.Proxied + } + + c.JSON(http.StatusCreated, result) +} + +func (p *CloudflarePlugin) handleUpdateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + recordID := c.Param("recordId") + + var req cloudflareUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rc := cloudflare.ZoneIdentifier(zoneID) + params := cloudflare.UpdateDNSRecordParams{ID: recordID} + + if req.Record.Content != nil { + params.Content = *req.Record.Content + } + if req.Record.TTL != nil { + params.TTL = *req.Record.TTL + } + if req.Record.Priority != nil { + priority := uint16(*req.Record.Priority) + params.Priority = &priority + } + if req.Record.Proxied != nil { + params.Proxied = req.Record.Proxied + } + + r, err := api.UpdateDNSRecord(c.Request.Context(), rc, params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := plugins.DNSRecord{ + ID: r.ID, + ZoneID: zoneID, + Type: r.Type, + Name: r.Name, + Content: r.Content, + TTL: r.TTL, + } + if r.Priority != nil { + priority := int(*r.Priority) + result.Priority = &priority + } + if r.Proxied != nil { + result.Proxied = r.Proxied + } + + c.JSON(http.StatusOK, result) +} + +func (p *CloudflarePlugin) handleDeleteRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + recordID := c.Param("recordId") + + var req cloudflareRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + api, err := p.getAPI(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rc := cloudflare.ZoneIdentifier(zoneID) + if err := api.DeleteDNSRecord(c.Request.Context(), rc, recordID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Record deleted"}) +} + +func (p *CloudflarePlugin) SetCredentials(credentials map[string]string) error { + return nil +} + +func (p *CloudflarePlugin) ValidateCredentials() error { + return nil +} + +func (p *CloudflarePlugin) ListZones(ctx context.Context) ([]plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *CloudflarePlugin) GetZone(ctx context.Context, zoneID string) (*plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *CloudflarePlugin) ListRecords(ctx context.Context, zoneID string) ([]plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *CloudflarePlugin) CreateRecord(ctx context.Context, zoneID string, record plugins.DNSRecordCreate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *CloudflarePlugin) UpdateRecord(ctx context.Context, zoneID, recordID string, record plugins.DNSRecordUpdate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *CloudflarePlugin) DeleteRecord(ctx context.Context, zoneID, recordID string) error { + return fmt.Errorf("use RegisterRoutes API") +} diff --git a/pkg/plugins/dns/digitalocean.go b/pkg/plugins/dns/digitalocean.go new file mode 100644 index 0000000..7ffafcc --- /dev/null +++ b/pkg/plugins/dns/digitalocean.go @@ -0,0 +1,418 @@ +package dns + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/digitalocean/godo" + "github.com/flatrun/agent/pkg/plugins" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" +) + +type DigitalOceanPlugin struct { + BaseDNSPlugin +} + +func NewDigitalOceanPlugin() *DigitalOceanPlugin { + return &DigitalOceanPlugin{ + BaseDNSPlugin: BaseDNSPlugin{ + info: plugins.PluginInfo{ + Name: "dns-digitalocean", + DisplayName: "DigitalOcean DNS", + Version: "1.0.0", + Description: "DigitalOcean DNS management", + Author: "FlatRun", + Type: plugins.TypeDNS, + Category: "dns", + Enabled: true, + Capabilities: []string{ + string(plugins.CapDNSZoneManagement), + string(plugins.CapDNSRecordManagement), + }, + }, + }, + } +} + +func (p *DigitalOceanPlugin) ProviderName() string { + return "digitalocean" +} + +func (p *DigitalOceanPlugin) RequiredCredentials() []plugins.CredentialField { + return []plugins.CredentialField{ + { + Name: "api_token", + Label: "API Token", + Type: "password", + Required: true, + HelpText: "DigitalOcean API token with read/write access", + }, + } +} + +func (p *DigitalOceanPlugin) RegisterRoutes(router *gin.RouterGroup) error { + provider := router.Group("/digitalocean") + { + provider.GET("/info", p.handleInfo) + provider.POST("/validate", p.handleValidate) + provider.POST("/zones", p.handleListZones) + provider.POST("/zones/:zoneId", p.handleGetZone) + provider.POST("/zones/:zoneId/records", p.handleListRecords) + provider.POST("/zones/:zoneId/records/create", p.handleCreateRecord) + provider.PUT("/zones/:zoneId/records/:recordId", p.handleUpdateRecord) + provider.DELETE("/zones/:zoneId/records/:recordId", p.handleDeleteRecord) + } + return nil +} + +type digitaloceanRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` +} + +type digitaloceanRecordRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordCreate `json:"record"` +} + +type digitaloceanUpdateRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordUpdate `json:"record"` +} + +func (p *DigitalOceanPlugin) getClient(creds map[string]string) (*godo.Client, error) { + token, ok := creds["api_token"] + if !ok || token == "" { + return nil, fmt.Errorf("api_token is required") + } + + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + oauthClient := oauth2.NewClient(context.Background(), tokenSource) + return godo.NewClient(oauthClient), nil +} + +func (p *DigitalOceanPlugin) handleInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "name": p.info.Name, + "display_name": p.info.DisplayName, + "provider": p.ProviderName(), + "credentials": p.RequiredCredentials(), + }) +} + +func (p *DigitalOceanPlugin) handleValidate(c *gin.Context) { + var req digitaloceanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + _, _, err = client.Domains.List(c.Request.Context(), &godo.ListOptions{PerPage: 1}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true}) +} + +func (p *DigitalOceanPlugin) handleListZones(c *gin.Context) { + var req digitaloceanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSZone + opt := &godo.ListOptions{PerPage: 100} + + for { + domains, resp, err := client.Domains.List(c.Request.Context(), opt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for _, d := range domains { + result = append(result, plugins.DNSZone{ + ID: d.Name, + Name: d.Name, + Status: "active", + }) + } + + if resp.Links == nil || resp.Links.IsLastPage() { + break + } + + page, err := resp.Links.CurrentPage() + if err != nil { + break + } + opt.Page = page + 1 + } + + c.JSON(http.StatusOK, gin.H{"zones": result}) +} + +func (p *DigitalOceanPlugin) handleGetZone(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req digitaloceanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + domain, _, err := client.Domains.Get(c.Request.Context(), zoneID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, plugins.DNSZone{ + ID: domain.Name, + Name: domain.Name, + Status: "active", + }) +} + +func (p *DigitalOceanPlugin) handleListRecords(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req digitaloceanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSRecord + opt := &godo.ListOptions{PerPage: 100} + + for { + records, resp, err := client.Domains.Records(c.Request.Context(), zoneID, opt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for _, r := range records { + record := plugins.DNSRecord{ + ID: strconv.Itoa(r.ID), + ZoneID: zoneID, + Type: r.Type, + Name: r.Name, + Content: r.Data, + TTL: r.TTL, + } + if r.Priority > 0 { + priority := r.Priority + record.Priority = &priority + } + result = append(result, record) + } + + if resp.Links == nil || resp.Links.IsLastPage() { + break + } + + page, err := resp.Links.CurrentPage() + if err != nil { + break + } + opt.Page = page + 1 + } + + c.JSON(http.StatusOK, gin.H{"records": result}) +} + +func (p *DigitalOceanPlugin) handleCreateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req digitaloceanRecordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + createReq := &godo.DomainRecordEditRequest{ + Type: req.Record.Type, + Name: req.Record.Name, + Data: req.Record.Content, + TTL: req.Record.TTL, + } + + if req.Record.Priority != nil { + createReq.Priority = *req.Record.Priority + } + + r, _, err := client.Domains.CreateRecord(c.Request.Context(), zoneID, createReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := plugins.DNSRecord{ + ID: strconv.Itoa(r.ID), + ZoneID: zoneID, + Type: r.Type, + Name: r.Name, + Content: r.Data, + TTL: r.TTL, + } + if r.Priority > 0 { + result.Priority = &r.Priority + } + + c.JSON(http.StatusCreated, result) +} + +func (p *DigitalOceanPlugin) handleUpdateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + recordID := c.Param("recordId") + + var req digitaloceanUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := strconv.Atoi(recordID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid record ID"}) + return + } + + updateReq := &godo.DomainRecordEditRequest{} + + if req.Record.Content != nil { + updateReq.Data = *req.Record.Content + } + if req.Record.TTL != nil { + updateReq.TTL = *req.Record.TTL + } + if req.Record.Priority != nil { + updateReq.Priority = *req.Record.Priority + } + + r, _, err := client.Domains.EditRecord(c.Request.Context(), zoneID, id, updateReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := plugins.DNSRecord{ + ID: strconv.Itoa(r.ID), + ZoneID: zoneID, + Type: r.Type, + Name: r.Name, + Content: r.Data, + TTL: r.TTL, + } + if r.Priority > 0 { + result.Priority = &r.Priority + } + + c.JSON(http.StatusOK, result) +} + +func (p *DigitalOceanPlugin) handleDeleteRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + recordID := c.Param("recordId") + + var req digitaloceanRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := strconv.Atoi(recordID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid record ID"}) + return + } + + _, err = client.Domains.DeleteRecord(c.Request.Context(), zoneID, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Record deleted"}) +} + +func (p *DigitalOceanPlugin) SetCredentials(credentials map[string]string) error { + return nil +} + +func (p *DigitalOceanPlugin) ValidateCredentials() error { + return nil +} + +func (p *DigitalOceanPlugin) ListZones(ctx context.Context) ([]plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *DigitalOceanPlugin) GetZone(ctx context.Context, zoneID string) (*plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *DigitalOceanPlugin) ListRecords(ctx context.Context, zoneID string) ([]plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *DigitalOceanPlugin) CreateRecord(ctx context.Context, zoneID string, record plugins.DNSRecordCreate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *DigitalOceanPlugin) UpdateRecord(ctx context.Context, zoneID, recordID string, record plugins.DNSRecordUpdate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *DigitalOceanPlugin) DeleteRecord(ctx context.Context, zoneID, recordID string) error { + return fmt.Errorf("use RegisterRoutes API") +} diff --git a/pkg/plugins/dns/hetzner.go b/pkg/plugins/dns/hetzner.go new file mode 100644 index 0000000..13e3725 --- /dev/null +++ b/pkg/plugins/dns/hetzner.go @@ -0,0 +1,448 @@ +package dns + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/flatrun/agent/pkg/plugins" + "github.com/gin-gonic/gin" +) + +const hetznerAPIBase = "https://dns.hetzner.com/api/v1" + +type HetznerPlugin struct { + BaseDNSPlugin + httpClient *http.Client +} + +func NewHetznerPlugin() *HetznerPlugin { + return &HetznerPlugin{ + BaseDNSPlugin: BaseDNSPlugin{ + info: plugins.PluginInfo{ + Name: "dns-hetzner", + DisplayName: "Hetzner DNS", + Version: "1.0.0", + Description: "Hetzner DNS management", + Author: "FlatRun", + Type: plugins.TypeDNS, + Category: "dns", + Enabled: true, + Capabilities: []string{ + string(plugins.CapDNSZoneManagement), + string(plugins.CapDNSRecordManagement), + }, + }, + }, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (p *HetznerPlugin) ProviderName() string { + return "hetzner" +} + +func (p *HetznerPlugin) RequiredCredentials() []plugins.CredentialField { + return []plugins.CredentialField{ + { + Name: "api_token", + Label: "API Token", + Type: "password", + Required: true, + HelpText: "Hetzner DNS API token", + }, + } +} + +func (p *HetznerPlugin) RegisterRoutes(router *gin.RouterGroup) error { + provider := router.Group("/hetzner") + { + provider.GET("/info", p.handleInfo) + provider.POST("/validate", p.handleValidate) + provider.POST("/zones", p.handleListZones) + provider.POST("/zones/:zoneId", p.handleGetZone) + provider.POST("/zones/:zoneId/records", p.handleListRecords) + provider.POST("/zones/:zoneId/records/create", p.handleCreateRecord) + provider.PUT("/zones/:zoneId/records/:recordId", p.handleUpdateRecord) + provider.DELETE("/zones/:zoneId/records/:recordId", p.handleDeleteRecord) + } + return nil +} + +type hetznerRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` +} + +type hetznerRecordRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordCreate `json:"record"` +} + +type hetznerUpdateRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordUpdate `json:"record"` +} + +func (p *HetznerPlugin) doRequest(ctx context.Context, apiToken, method, path string, body interface{}) ([]byte, error) { + if apiToken == "" { + return nil, fmt.Errorf("api_token is required") + } + + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, hetznerAPIBase+path, reqBody) + if err != nil { + return nil, err + } + + req.Header.Set("Auth-API-Token", apiToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + +type hetznerZonesResponse struct { + Zones []struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + RecordsCount int `json:"records_count"` + IsSecondaryDNS bool `json:"is_secondary_dns"` + TxtVerification struct { + Name string `json:"name"` + Token string `json:"token"` + } `json:"txt_verification"` + } `json:"zones"` +} + +type hetznerZoneResponse struct { + Zone struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + RecordsCount int `json:"records_count"` + NS []string `json:"ns"` + } `json:"zone"` +} + +type hetznerRecordsResponse struct { + Records []struct { + ID string `json:"id"` + ZoneID string `json:"zone_id"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + TTL int `json:"ttl"` + Priority *int `json:"priority,omitempty"` + } `json:"records"` +} + +type hetznerRecordResponse struct { + Record struct { + ID string `json:"id"` + ZoneID string `json:"zone_id"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + TTL int `json:"ttl"` + Priority *int `json:"priority,omitempty"` + } `json:"record"` +} + +func (p *HetznerPlugin) handleInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "name": p.info.Name, + "display_name": p.info.DisplayName, + "provider": p.ProviderName(), + "credentials": p.RequiredCredentials(), + }) +} + +func (p *HetznerPlugin) handleValidate(c *gin.Context) { + var req hetznerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token := req.Credentials["api_token"] + _, err := p.doRequest(c.Request.Context(), token, "GET", "/zones", nil) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true}) +} + +func (p *HetznerPlugin) handleListZones(c *gin.Context) { + var req hetznerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token := req.Credentials["api_token"] + data, err := p.doRequest(c.Request.Context(), token, "GET", "/zones", nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp hetznerZonesResponse + if err := json.Unmarshal(data, &resp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSZone + for _, z := range resp.Zones { + result = append(result, plugins.DNSZone{ + ID: z.ID, + Name: z.Name, + Status: z.Status, + RecordCount: z.RecordsCount, + }) + } + + c.JSON(http.StatusOK, gin.H{"zones": result}) +} + +func (p *HetznerPlugin) handleGetZone(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req hetznerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token := req.Credentials["api_token"] + data, err := p.doRequest(c.Request.Context(), token, "GET", "/zones/"+zoneID, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp hetznerZoneResponse + if err := json.Unmarshal(data, &resp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, plugins.DNSZone{ + ID: resp.Zone.ID, + Name: resp.Zone.Name, + Status: resp.Zone.Status, + RecordCount: resp.Zone.RecordsCount, + NameServers: resp.Zone.NS, + }) +} + +func (p *HetznerPlugin) handleListRecords(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req hetznerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token := req.Credentials["api_token"] + data, err := p.doRequest(c.Request.Context(), token, "GET", "/records?zone_id="+zoneID, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp hetznerRecordsResponse + if err := json.Unmarshal(data, &resp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSRecord + for _, r := range resp.Records { + result = append(result, plugins.DNSRecord{ + ID: r.ID, + ZoneID: r.ZoneID, + Type: r.Type, + Name: r.Name, + Content: r.Value, + TTL: r.TTL, + Priority: r.Priority, + }) + } + + c.JSON(http.StatusOK, gin.H{"records": result}) +} + +func (p *HetznerPlugin) handleCreateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req hetznerRecordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + body := map[string]interface{}{ + "zone_id": zoneID, + "type": req.Record.Type, + "name": req.Record.Name, + "value": req.Record.Content, + "ttl": req.Record.TTL, + } + + if req.Record.Priority != nil { + body["priority"] = *req.Record.Priority + } + + token := req.Credentials["api_token"] + data, err := p.doRequest(c.Request.Context(), token, "POST", "/records", body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp hetznerRecordResponse + if err := json.Unmarshal(data, &resp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, plugins.DNSRecord{ + ID: resp.Record.ID, + ZoneID: resp.Record.ZoneID, + Type: resp.Record.Type, + Name: resp.Record.Name, + Content: resp.Record.Value, + TTL: resp.Record.TTL, + Priority: resp.Record.Priority, + }) +} + +func (p *HetznerPlugin) handleUpdateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + recordID := c.Param("recordId") + + var req hetznerUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + body := map[string]interface{}{ + "zone_id": zoneID, + } + + if req.Record.Content != nil { + body["value"] = *req.Record.Content + } + if req.Record.TTL != nil { + body["ttl"] = *req.Record.TTL + } + if req.Record.Priority != nil { + body["priority"] = *req.Record.Priority + } + + token := req.Credentials["api_token"] + data, err := p.doRequest(c.Request.Context(), token, "PUT", "/records/"+recordID, body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp hetznerRecordResponse + if err := json.Unmarshal(data, &resp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, plugins.DNSRecord{ + ID: resp.Record.ID, + ZoneID: resp.Record.ZoneID, + Type: resp.Record.Type, + Name: resp.Record.Name, + Content: resp.Record.Value, + TTL: resp.Record.TTL, + Priority: resp.Record.Priority, + }) +} + +func (p *HetznerPlugin) handleDeleteRecord(c *gin.Context) { + recordID := c.Param("recordId") + + var req hetznerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token := req.Credentials["api_token"] + _, err := p.doRequest(c.Request.Context(), token, "DELETE", "/records/"+recordID, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Record deleted"}) +} + +func (p *HetznerPlugin) SetCredentials(credentials map[string]string) error { + return nil +} + +func (p *HetznerPlugin) ValidateCredentials() error { + return nil +} + +func (p *HetznerPlugin) ListZones(ctx context.Context) ([]plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *HetznerPlugin) GetZone(ctx context.Context, zoneID string) (*plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *HetznerPlugin) ListRecords(ctx context.Context, zoneID string) ([]plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *HetznerPlugin) CreateRecord(ctx context.Context, zoneID string, record plugins.DNSRecordCreate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *HetznerPlugin) UpdateRecord(ctx context.Context, zoneID, recordID string, record plugins.DNSRecordUpdate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *HetznerPlugin) DeleteRecord(ctx context.Context, zoneID, recordID string) error { + return fmt.Errorf("use RegisterRoutes API") +} diff --git a/pkg/plugins/dns/route53.go b/pkg/plugins/dns/route53.go new file mode 100644 index 0000000..7bbc9c3 --- /dev/null +++ b/pkg/plugins/dns/route53.go @@ -0,0 +1,423 @@ +package dns + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/route53" + "github.com/aws/aws-sdk-go-v2/service/route53/types" + "github.com/flatrun/agent/pkg/plugins" + "github.com/gin-gonic/gin" +) + +type Route53Plugin struct { + BaseDNSPlugin +} + +func NewRoute53Plugin() *Route53Plugin { + return &Route53Plugin{ + BaseDNSPlugin: BaseDNSPlugin{ + info: plugins.PluginInfo{ + Name: "dns-route53", + DisplayName: "AWS Route 53", + Version: "1.0.0", + Description: "AWS Route 53 DNS management", + Author: "FlatRun", + Type: plugins.TypeDNS, + Category: "dns", + Enabled: true, + Capabilities: []string{ + string(plugins.CapDNSZoneManagement), + string(plugins.CapDNSRecordManagement), + }, + }, + }, + } +} + +func (p *Route53Plugin) ProviderName() string { + return "route53" +} + +func (p *Route53Plugin) RequiredCredentials() []plugins.CredentialField { + return []plugins.CredentialField{ + { + Name: "access_key_id", + Label: "Access Key ID", + Type: "text", + Required: true, + HelpText: "AWS Access Key ID", + }, + { + Name: "secret_access_key", + Label: "Secret Access Key", + Type: "password", + Required: true, + HelpText: "AWS Secret Access Key", + }, + { + Name: "region", + Label: "Region", + Type: "text", + Required: false, + HelpText: "AWS Region (default: us-east-1)", + }, + } +} + +func (p *Route53Plugin) RegisterRoutes(router *gin.RouterGroup) error { + provider := router.Group("/route53") + { + provider.GET("/info", p.handleInfo) + provider.POST("/validate", p.handleValidate) + provider.POST("/zones", p.handleListZones) + provider.POST("/zones/:zoneId", p.handleGetZone) + provider.POST("/zones/:zoneId/records", p.handleListRecords) + provider.POST("/zones/:zoneId/records/create", p.handleCreateRecord) + provider.DELETE("/zones/:zoneId/records/:recordId", p.handleDeleteRecord) + } + return nil +} + +type route53Request struct { + Credentials map[string]string `json:"credentials" binding:"required"` +} + +type route53RecordRequest struct { + Credentials map[string]string `json:"credentials" binding:"required"` + Record plugins.DNSRecordCreate `json:"record"` +} + +func (p *Route53Plugin) getClient(creds map[string]string) (*route53.Client, error) { + accessKey, ok := creds["access_key_id"] + if !ok || accessKey == "" { + return nil, fmt.Errorf("access_key_id is required") + } + + secretKey, ok := creds["secret_access_key"] + if !ok || secretKey == "" { + return nil, fmt.Errorf("secret_access_key is required") + } + + region := creds["region"] + if region == "" { + region = "us-east-1" + } + + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion(region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + ) + if err != nil { + return nil, err + } + + return route53.NewFromConfig(cfg), nil +} + +func (p *Route53Plugin) handleInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "name": p.info.Name, + "display_name": p.info.DisplayName, + "provider": p.ProviderName(), + "credentials": p.RequiredCredentials(), + }) +} + +func (p *Route53Plugin) handleValidate(c *gin.Context) { + var req route53Request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + _, err = client.ListHostedZones(c.Request.Context(), &route53.ListHostedZonesInput{ + MaxItems: intPtr(1), + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"valid": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true}) +} + +func (p *Route53Plugin) handleListZones(c *gin.Context) { + var req route53Request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSZone + var marker *string + + for { + output, err := client.ListHostedZones(c.Request.Context(), &route53.ListHostedZonesInput{ + Marker: marker, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for _, z := range output.HostedZones { + zoneID := strings.TrimPrefix(*z.Id, "/hostedzone/") + result = append(result, plugins.DNSZone{ + ID: zoneID, + Name: strings.TrimSuffix(*z.Name, "."), + Status: "active", + RecordCount: int(*z.ResourceRecordSetCount), + }) + } + + if !output.IsTruncated { + break + } + marker = output.NextMarker + } + + c.JSON(http.StatusOK, gin.H{"zones": result}) +} + +func (p *Route53Plugin) handleGetZone(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req route53Request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + output, err := client.GetHostedZone(c.Request.Context(), &route53.GetHostedZoneInput{ + Id: &zoneID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + z := output.HostedZone + c.JSON(http.StatusOK, plugins.DNSZone{ + ID: strings.TrimPrefix(*z.Id, "/hostedzone/"), + Name: strings.TrimSuffix(*z.Name, "."), + Status: "active", + RecordCount: int(*z.ResourceRecordSetCount), + NameServers: output.DelegationSet.NameServers, + }) +} + +func (p *Route53Plugin) handleListRecords(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req route53Request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var result []plugins.DNSRecord + + paginator := route53.NewListResourceRecordSetsPaginator(client, &route53.ListResourceRecordSetsInput{ + HostedZoneId: &zoneID, + }) + + for paginator.HasMorePages() { + output, err := paginator.NextPage(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for _, r := range output.ResourceRecordSets { + for _, rr := range r.ResourceRecords { + record := plugins.DNSRecord{ + ID: fmt.Sprintf("%s_%s_%s", *r.Name, r.Type, *rr.Value), + ZoneID: zoneID, + Type: string(r.Type), + Name: strings.TrimSuffix(*r.Name, "."), + Content: *rr.Value, + TTL: int(*r.TTL), + } + result = append(result, record) + } + } + } + + c.JSON(http.StatusOK, gin.H{"records": result}) +} + +func (p *Route53Plugin) handleCreateRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + + var req route53RecordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + name := req.Record.Name + if !strings.HasSuffix(name, ".") { + name = name + "." + } + + ttl := int64(req.Record.TTL) + if ttl == 0 { + ttl = 300 + } + + _, err = client.ChangeResourceRecordSets(c.Request.Context(), &route53.ChangeResourceRecordSetsInput{ + HostedZoneId: &zoneID, + ChangeBatch: &types.ChangeBatch{ + Changes: []types.Change{ + { + Action: types.ChangeActionCreate, + ResourceRecordSet: &types.ResourceRecordSet{ + Name: &name, + Type: types.RRType(req.Record.Type), + TTL: &ttl, + ResourceRecords: []types.ResourceRecord{ + {Value: &req.Record.Content}, + }, + }, + }, + }, + }, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := plugins.DNSRecord{ + ID: fmt.Sprintf("%s_%s_%s", name, req.Record.Type, req.Record.Content), + ZoneID: zoneID, + Type: req.Record.Type, + Name: strings.TrimSuffix(name, "."), + Content: req.Record.Content, + TTL: int(ttl), + } + + c.JSON(http.StatusCreated, result) +} + +func (p *Route53Plugin) handleDeleteRecord(c *gin.Context) { + zoneID := c.Param("zoneId") + recordID := c.Param("recordId") + + var req route53Request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client, err := p.getClient(req.Credentials) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + parts := strings.SplitN(recordID, "_", 3) + if len(parts) != 3 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid record ID format"}) + return + } + + name, recordType, content := parts[0], parts[1], parts[2] + ttl := int64(300) + + _, err = client.ChangeResourceRecordSets(c.Request.Context(), &route53.ChangeResourceRecordSetsInput{ + HostedZoneId: &zoneID, + ChangeBatch: &types.ChangeBatch{ + Changes: []types.Change{ + { + Action: types.ChangeActionDelete, + ResourceRecordSet: &types.ResourceRecordSet{ + Name: &name, + Type: types.RRType(recordType), + TTL: &ttl, + ResourceRecords: []types.ResourceRecord{ + {Value: &content}, + }, + }, + }, + }, + }, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Record deleted"}) +} + +func (p *Route53Plugin) SetCredentials(credentials map[string]string) error { + return nil +} + +func (p *Route53Plugin) ValidateCredentials() error { + return nil +} + +func (p *Route53Plugin) ListZones(ctx context.Context) ([]plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *Route53Plugin) GetZone(ctx context.Context, zoneID string) (*plugins.DNSZone, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *Route53Plugin) ListRecords(ctx context.Context, zoneID string) ([]plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *Route53Plugin) CreateRecord(ctx context.Context, zoneID string, record plugins.DNSRecordCreate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("use RegisterRoutes API") +} + +func (p *Route53Plugin) UpdateRecord(ctx context.Context, zoneID, recordID string, record plugins.DNSRecordUpdate) (*plugins.DNSRecord, error) { + return nil, fmt.Errorf("Route53 requires delete+create for updates") +} + +func (p *Route53Plugin) DeleteRecord(ctx context.Context, zoneID, recordID string) error { + return fmt.Errorf("use RegisterRoutes API") +} + +func intPtr(i int32) *int32 { + return &i +} diff --git a/pkg/plugins/types.go b/pkg/plugins/types.go index 3bb7d77..03a4ed6 100644 --- a/pkg/plugins/types.go +++ b/pkg/plugins/types.go @@ -1,6 +1,10 @@ package plugins -import "github.com/gin-gonic/gin" +import ( + "context" + + "github.com/gin-gonic/gin" +) type PluginType string @@ -9,16 +13,20 @@ const ( TypeWidget PluginType = "widget" TypeService PluginType = "service" TypeIntegration PluginType = "integration" + TypeDNS PluginType = "dns" ) type Capability string const ( - CapAutoSSL Capability = "auto_ssl" - CapAutoBackup Capability = "auto_backup" - CapAutoUpdate Capability = "auto_update" - CapMonitoring Capability = "monitoring" - CapScaling Capability = "scaling" + CapAutoSSL Capability = "auto_ssl" + CapAutoBackup Capability = "auto_backup" + CapAutoUpdate Capability = "auto_update" + CapMonitoring Capability = "monitoring" + CapScaling Capability = "scaling" + CapDNSZoneManagement Capability = "dns_zone_management" + CapDNSRecordManagement Capability = "dns_record_management" + CapDNSAutoConfig Capability = "dns_auto_config" ) type PluginInfo struct { @@ -105,3 +113,61 @@ type DeploymentStatus struct { Health string `json:"health"` Metrics map[string]interface{} `json:"metrics"` } + +type DNSPlugin interface { + Plugin + ProviderName() string + RequiredCredentials() []CredentialField + SetCredentials(credentials map[string]string) error + ValidateCredentials() error + ListZones(ctx context.Context) ([]DNSZone, error) + GetZone(ctx context.Context, zoneID string) (*DNSZone, error) + ListRecords(ctx context.Context, zoneID string) ([]DNSRecord, error) + CreateRecord(ctx context.Context, zoneID string, record DNSRecordCreate) (*DNSRecord, error) + UpdateRecord(ctx context.Context, zoneID, recordID string, record DNSRecordUpdate) (*DNSRecord, error) + DeleteRecord(ctx context.Context, zoneID, recordID string) error +} + +type CredentialField struct { + Name string `json:"name" yaml:"name"` + Label string `json:"label" yaml:"label"` + Type string `json:"type" yaml:"type"` + Required bool `json:"required" yaml:"required"` + Placeholder string `json:"placeholder,omitempty" yaml:"placeholder,omitempty"` + HelpText string `json:"help_text,omitempty" yaml:"help_text,omitempty"` +} + +type DNSZone struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + NameServers []string `json:"name_servers,omitempty"` + RecordCount int `json:"record_count"` +} + +type DNSRecord struct { + ID string `json:"id"` + ZoneID string `json:"zone_id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + TTL int `json:"ttl"` + Priority *int `json:"priority,omitempty"` + Proxied *bool `json:"proxied,omitempty"` +} + +type DNSRecordCreate struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + Content string `json:"content" binding:"required"` + TTL int `json:"ttl"` + Priority *int `json:"priority,omitempty"` + Proxied *bool `json:"proxied,omitempty"` +} + +type DNSRecordUpdate struct { + Content *string `json:"content,omitempty"` + TTL *int `json:"ttl,omitempty"` + Priority *int `json:"priority,omitempty"` + Proxied *bool `json:"proxied,omitempty"` +}