[feature] Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.idea/
|
||||||
|
config.yaml
|
||||||
7
LICENSE
7
LICENSE
@@ -1,8 +1,7 @@
|
|||||||
ISC License:
|
ISC License
|
||||||
|
|
||||||
Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")
|
Copyright 2026 Aurora Development LLC
|
||||||
Copyright (c) 1995-2003 by Internet Software Consortium
|
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
29
config.example.yaml
Normal file
29
config.example.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interval: "1m"
|
||||||
|
|
||||||
|
dns:
|
||||||
|
provider: "example"
|
||||||
|
default:
|
||||||
|
ttl: "24h"
|
||||||
|
cname: "pangolin.example.com"
|
||||||
|
domains:
|
||||||
|
# example.com is using default
|
||||||
|
example.net:
|
||||||
|
ttl: "30m"
|
||||||
|
cname: "panglin.example.com"
|
||||||
|
example.org:
|
||||||
|
ttl: "30m"
|
||||||
|
address: "1.2.3.4"
|
||||||
|
|
||||||
|
pangolin:
|
||||||
|
url: "https://api.pangolin.instance"
|
||||||
|
key: ""
|
||||||
|
orgs:
|
||||||
|
- "your-org"
|
||||||
|
|
||||||
|
cloudflare:
|
||||||
|
token: ""
|
||||||
|
|
||||||
|
route53:
|
||||||
|
region: "us-east-1"
|
||||||
|
access_key: ""
|
||||||
|
secret_key: ""
|
||||||
24
example.go
Normal file
24
example.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/libdns/libdns"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExampleSetter struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExampleSetter) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) {
|
||||||
|
for _, record := range recs {
|
||||||
|
rr := record.RR()
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"zone": zone,
|
||||||
|
"name": rr.Name,
|
||||||
|
}).Info("Set/Update record")
|
||||||
|
}
|
||||||
|
|
||||||
|
return recs, nil
|
||||||
|
}
|
||||||
49
go.mod
Normal file
49
go.mod
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
module PangolinDNS
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/auroradevllc/apiclient v0.0.0-20260222000229-5aca4ca82829
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/libdns/cloudflare v0.2.2
|
||||||
|
github.com/libdns/libdns v1.1.1
|
||||||
|
github.com/libdns/route53 v1.6.0
|
||||||
|
github.com/samber/lo v1.53.0
|
||||||
|
github.com/sirupsen/logrus v1.9.4
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
|
||||||
|
github.com/aws/smithy-go v1.23.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
)
|
||||||
117
go.sum
Normal file
117
go.sum
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
github.com/auroradevllc/apiclient v0.0.0-20260222000229-5aca4ca82829 h1:CLoIDjfY+KBv+N7Q615+8MECxnQ55IKVUP0lWBOPBl4=
|
||||||
|
github.com/auroradevllc/apiclient v0.0.0-20260222000229-5aca4ca82829/go.mod h1:tNh5fcm93z/Y29FRzkVVKkjYKUhpN9TVid+ZrSsaH2U=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 h1:jQzRC+0eI/l5mFXVoPTyyolrqyZtKIYaKHSuKJoIJKs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3/go.mod h1:1GNaojT/gG4Ru9tT39ton6kRZ3FvptJ/QRKBoqUOVX4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
|
||||||
|
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||||
|
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||||
|
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||||
|
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
|
||||||
|
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
||||||
|
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||||
|
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||||
|
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||||
|
github.com/libdns/route53 v1.6.0 h1:1fZcoCIxagfftw9GBhIqZ2rumEiB0K58n11X7ko2DOg=
|
||||||
|
github.com/libdns/route53 v1.6.0/go.mod h1:7QGcw/2J0VxcVwHsPYpuo1I6IJLHy77bbOvi1BVK3eE=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
|
||||||
|
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
|
||||||
|
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
261
main.go
Normal file
261
main.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"PangolinDNS/pangolin"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
"github.com/libdns/cloudflare"
|
||||||
|
"github.com/libdns/libdns"
|
||||||
|
"github.com/libdns/route53"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTTL = 60 * 24 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Interval time.Duration `mapstructure:"interval" validate:"required"`
|
||||||
|
DNS DNSConfig `mapstructure:"dns" validate:"required"`
|
||||||
|
Pangolin PangolinConfig `mapstructure:"pangolin" validate:"required"`
|
||||||
|
Cloudflare CloudflareConfig `mapstructure:"cloudflare" validate:"-"`
|
||||||
|
Route53 Route53Config `mapstructure:"route53" validate:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PangolinConfig struct {
|
||||||
|
URL string `mapstructure:"url" validate:"required,url"`
|
||||||
|
Key string `mapstructure:"key" validate:"required"`
|
||||||
|
Organizations []string `mapstructure:"orgs" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloudflareConfig struct {
|
||||||
|
Token string `mapstructure:"token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Route53Config struct {
|
||||||
|
Region string `mapstructure:"region" validate:"required"`
|
||||||
|
AccessKey string `mapstructure:"access_key" validate:"required"`
|
||||||
|
SecretKey string `mapstructure:"secret_key" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSConfig struct {
|
||||||
|
Provider string `mapstructure:"provider" validate:"required"`
|
||||||
|
Default DNSTarget `mapstructure:"default" validate:"required"`
|
||||||
|
Domains map[string]DNSTarget `mapstructure:"domains" validate:"dive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSTarget struct {
|
||||||
|
CNAME string `mapstructure:"cname" validate:"required_without=Address,omitempty,hostname|fqdn"`
|
||||||
|
Address string `mapstructure:"address" validate:"required_without=CNAME,omitempty,ip"`
|
||||||
|
TTL time.Duration `mapstructure:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSSync struct {
|
||||||
|
cfg Config
|
||||||
|
pangolin *pangolin.Client
|
||||||
|
provider libdns.RecordSetter
|
||||||
|
resourceCache map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var provider libdns.RecordSetter
|
||||||
|
|
||||||
|
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
|
||||||
|
v.SetConfigFile("config.yaml")
|
||||||
|
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Error reading config")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
log.WithError(err).Fatal("Error unmarshalling config")
|
||||||
|
}
|
||||||
|
|
||||||
|
validate := validator.New()
|
||||||
|
|
||||||
|
validate.RegisterStructValidation(func(sl validator.StructLevel) {
|
||||||
|
cfg := sl.Current().Interface().(Config)
|
||||||
|
|
||||||
|
t := reflect.TypeOf(cfg)
|
||||||
|
cfgValue := reflect.ValueOf(cfg)
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
fieldName := field.Tag.Get("mapstructure")
|
||||||
|
|
||||||
|
if fieldName != cfg.DNS.Provider {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validate.Struct(cfgValue.Field(i).Interface()); err != nil {
|
||||||
|
for _, e := range err.(validator.ValidationErrors) {
|
||||||
|
sl.ReportError(
|
||||||
|
e.Value(),
|
||||||
|
fieldName+"."+e.Field(),
|
||||||
|
e.StructField(),
|
||||||
|
e.Tag(),
|
||||||
|
e.Param(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, Config{})
|
||||||
|
|
||||||
|
if err := validate.Struct(cfg); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cfg.DNS.Provider {
|
||||||
|
case "cloudflare":
|
||||||
|
provider = &cloudflare.Provider{
|
||||||
|
APIToken: cfg.Cloudflare.Token,
|
||||||
|
}
|
||||||
|
case "route53":
|
||||||
|
provider = &route53.Provider{
|
||||||
|
Region: cfg.Route53.Region,
|
||||||
|
AccessKeyId: cfg.Route53.AccessKey,
|
||||||
|
SecretAccessKey: cfg.Route53.SecretKey,
|
||||||
|
}
|
||||||
|
case "example":
|
||||||
|
provider = &ExampleSetter{}
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unsupported provider %s", cfg.DNS.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := pangolin.New(cfg.Pangolin.URL, cfg.Pangolin.Key)
|
||||||
|
|
||||||
|
s := &DNSSync{
|
||||||
|
cfg: cfg,
|
||||||
|
provider: provider,
|
||||||
|
pangolin: p,
|
||||||
|
resourceCache: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// On initial startup, always push domains
|
||||||
|
s.checkResources(true)
|
||||||
|
|
||||||
|
if cfg.Interval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := time.NewTicker(cfg.Interval)
|
||||||
|
for {
|
||||||
|
<-t.C
|
||||||
|
|
||||||
|
s.checkResources(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DNSSync) checkResources(force bool) {
|
||||||
|
for _, org := range d.cfg.Pangolin.Organizations {
|
||||||
|
log.Infof("Reading domains from org %s", org)
|
||||||
|
domains, err := d.pangolin.Domains(org)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
"org": org,
|
||||||
|
}).Fatal("Unable to fetch domains for org")
|
||||||
|
}
|
||||||
|
|
||||||
|
resources, err := d.pangolin.Resources(org)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
"org": org,
|
||||||
|
}).Fatal("Unable to fetch resources for org")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Mapping domains to keys")
|
||||||
|
|
||||||
|
domainKeys := lo.Associate(domains, func(domain pangolin.Domain) (string, string) {
|
||||||
|
return domain.ID, domain.BaseDomain
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Info("Mapping records to domains")
|
||||||
|
|
||||||
|
recordMap := lo.GroupBy(resources, func(record pangolin.Resource) string {
|
||||||
|
return domainKeys[record.DomainID]
|
||||||
|
})
|
||||||
|
|
||||||
|
for domain, res := range recordMap {
|
||||||
|
log.Infof("Building domain list for %s", domain)
|
||||||
|
zone := domain + "."
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
res = lo.Filter(res, func(record pangolin.Resource, _ int) bool {
|
||||||
|
_, ok := d.resourceCache[record.FullDomain]
|
||||||
|
return !ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
records := lo.Map(res, func(resource pangolin.Resource, _ int) libdns.Record {
|
||||||
|
if target, ok := d.cfg.DNS.Domains[domain]; ok {
|
||||||
|
return recordFromTarget(resource.FullDomain, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return recordFromTarget(resource.FullDomain, d.cfg.DNS.Default)
|
||||||
|
})
|
||||||
|
|
||||||
|
changed, err := d.provider.SetRecords(context.Background(), zone, records)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
"org": org,
|
||||||
|
"domain": domain,
|
||||||
|
}).Fatal("Unable to set records for domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, changed := range changed {
|
||||||
|
log.Infof("Updated record %s", changed.RR())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all resources we checked are set in the cache for future runs
|
||||||
|
for _, res := range resources {
|
||||||
|
d.resourceCache[res.FullDomain] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordFromTarget(name string, target DNSTarget) libdns.Record {
|
||||||
|
ttl := defaultTTL
|
||||||
|
|
||||||
|
if target.TTL > 0 {
|
||||||
|
ttl = target.TTL
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.CNAME != "" {
|
||||||
|
return libdns.CNAME{
|
||||||
|
Name: name,
|
||||||
|
TTL: ttl,
|
||||||
|
Target: target.CNAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.Address != "" {
|
||||||
|
return libdns.Address{
|
||||||
|
Name: name,
|
||||||
|
TTL: ttl,
|
||||||
|
IP: netip.MustParseAddr(target.Address),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("no target defined for domain")
|
||||||
|
}
|
||||||
17
pangolin/domain.go
Normal file
17
pangolin/domain.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package pangolin
|
||||||
|
|
||||||
|
type Domain struct {
|
||||||
|
ID string `json:"domainId"`
|
||||||
|
BaseDomain string `json:"baseDomain"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
Tries int `json:"tries"`
|
||||||
|
ConfigManaged bool `json:"configManaged"`
|
||||||
|
CertResolver string `json:"certResolver"`
|
||||||
|
PreferWildcardCert *bool `json:"preferWildcardCert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainResponse struct {
|
||||||
|
Domains []Domain `json:"domains"`
|
||||||
|
}
|
||||||
103
pangolin/pangolin.go
Normal file
103
pangolin/pangolin.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package pangolin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/auroradevllc/apiclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(apiUrl, apiKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
apiUrl: apiUrl,
|
||||||
|
apiKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
apiUrl string
|
||||||
|
apiKey string
|
||||||
|
debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response[V any] struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Pagination struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
}
|
||||||
|
Data V `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Client) Domains(orgID string) ([]Domain, error) {
|
||||||
|
var response Response[DomainResponse]
|
||||||
|
|
||||||
|
if err := p.doRequestJSON(http.MethodGet, "/org/"+orgID+"/domains", nil, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Data.Domains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Client) Resources(orgID string) ([]Resource, error) {
|
||||||
|
var response Response[ResourceResponse]
|
||||||
|
|
||||||
|
if err := p.doRequestJSON(http.MethodGet, "/org/"+orgID+"/resources?pageSize=1000", nil, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Data.Resources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Client) doRequest(method, uri string, body any) (*apiclient.Response, error) {
|
||||||
|
opts := []apiclient.Option{
|
||||||
|
apiclient.WithMethod(method),
|
||||||
|
apiclient.WithHeader("Authorization", "Bearer "+p.apiKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
if body != nil {
|
||||||
|
opts = append(opts, apiclient.WithJSON(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := apiclient.NewRequest(p.apiUrl+"/"+strings.TrimLeft(uri, "/"), opts...)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := req.Send()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Client) doRequestJSON(method, uri string, body any, out any) error {
|
||||||
|
res, err := p.doRequest(method, uri, body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If debugging, we want to read the entire body
|
||||||
|
if p.debug {
|
||||||
|
b, err := res.Bytes()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(b))
|
||||||
|
|
||||||
|
return json.Unmarshal(b, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Unmarshal(out)
|
||||||
|
}
|
||||||
31
pangolin/resource.go
Normal file
31
pangolin/resource.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package pangolin
|
||||||
|
|
||||||
|
type Resource struct {
|
||||||
|
ID int `json:"resourceId"`
|
||||||
|
NiceID string `json:"niceId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
|
FullDomain string `json:"fullDomain"`
|
||||||
|
PasswordID *int `json:"passwordId"`
|
||||||
|
PincodeID *int `json:"pincodeId"`
|
||||||
|
Whitelist bool `json:"whitelist"`
|
||||||
|
HTTP bool `json:"http"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
DomainID string `json:"domainId"`
|
||||||
|
Targets []ResourceTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceTarget struct {
|
||||||
|
ID int `json:"targetId"`
|
||||||
|
ResourceID int `json:"resourceId"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
HealthStatus string `json:"healthStatus"`
|
||||||
|
HcEnabled bool `json:"hcEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceResponse struct {
|
||||||
|
Resources []Resource `json:"resources"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user