diff --git a/.github/workflows/build_test.yaml b/.github/workflows/build_test.yaml new file mode 100644 index 0000000..bd98059 --- /dev/null +++ b/.github/workflows/build_test.yaml @@ -0,0 +1,25 @@ +name: Go + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.21.5' ] + + steps: + - uses: actions/checkout@v4 + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Display Go version + run: go version + - name: Install deps + run: go get + - name: Build + run: go build + # - name: Test with the Go CLI + # run: go test diff --git a/LICENSE b/LICENSE index bc1194c..11c7abc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +Copyright (c) 2024, Профком студентов физфака МГУ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5ebbe61..ea3eb19 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,49 @@ -**DEVELOPER INSTRUCTIONS:** +# Yandex Cloud DNS for [`libdns`](https://github.com/libdns/libdns) -This repo is a template for developers to use when creating new [libdns](https://github.com/libdns/libdns) provider implementations. +This package implements the [libdns interfaces](https://github.com/libdns/libdns) for [Yandex Cloud API](https://yandex.cloud/en/docs/dns/api-ref/) allowing you to manage DNS records. -Be sure to update: -- The package name -- The Go module name in go.mod -- The latest `libdns/libdns` version in go.mod -- All comments and documentation, including README below and godocs -- License (must be compatible with Apache/MIT) -- All "TODO:"s is in the code -- All methods that currently do nothing +## Authenticate -Remove this section from the readme before publishing. +To authenticate API you need to supply a Yandex Cloud IAM token. It will automatically ensure from Service Account authorization keys. ---- +More info: -\ for [`libdns`](https://github.com/libdns/libdns) -======================= -[![Go Reference](https://pkg.go.dev/badge/test.svg)](https://pkg.go.dev/github.com/libdns/TODO:PROVIDER_NAME) +## Usage -This package implements the [libdns interfaces](https://github.com/libdns/libdns) for \, allowing you to manage DNS records. +```go +package main -TODO: Show how to configure and use. Explain any caveats. +import ( + "context" + "fmt" + "os" + "time" + + yandex_cloud "github.com/profcomff/libdns-yandex-cloud" +) + +func main() { + p := &yandex_cloud.Provider{ServiceAccountConfigPath: "./authorized_keys.json"} + // File structure + // { + // "id": "...", + // "service_account_id": "...", + // "created_at": "2024-08-04T14:00:38.626813184Z", + // "key_algorithm": "RSA_2048", + // "public_key": "-----BEGIN PUBLIC KEY-----\n ... \n-----END PUBLIC KEY-----\n", + // "private_key": "PLEASE DO NOT REMOVE THIS LINE! Yandex.Cloud SA Key ID <...>\n-----BEGIN PRIVATE KEY-----\n ... \n-----END PRIVATE KEY-----\n", + // "dns_zone_id": "..." + // } + + + records, err := p.GetRecords(context.WithTimeout(context.Background(), time.Duration(15*time.Second)), "") + if err != nil { + fmt.Printf("Error: %s", err.Error()) + return + } + + fmt.Println(records) +} +``` diff --git a/client.go b/client.go new file mode 100644 index 0000000..b2e6f6e --- /dev/null +++ b/client.go @@ -0,0 +1,332 @@ +package libdns_yandex_cloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httputil" + "strconv" + "strings" + "time" + "github.com/golang-jwt/jwt/v5" + "github.com/libdns/libdns" + "crypto/rsa" +) + +type serviceConfig struct { + ID string `json:"id"` + PrivateKey string `json:"private_key"` + ServiceAccountID string `json:"service_account_id"` + DnsZoneId string `json:"dns_zone_id"` +} + +type getAllZonesResponse struct { + DnsZones []zone `json:"dnsZones"` +} + +type getAllRecordsResponse struct { + Records []record `json:"recordSets"` +} + +type createRecordResponse struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + Response upsertRecordsBody `json:"response"` + CreatedAt string `json:"createdAt"` + CreatedBy string `json:"createdBy"` + ModifiedAt string `json:"modifiedAt"` + IsDone bool `json:"done"` +} + +type updateRecordResponse struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + Response updateRecordsBody `json:"response"` + CreatedAt string `json:"createdAt"` + CreatedBy string `json:"createdBy"` + ModifiedAt string `json:"modifiedAt"` + IsDone bool `json:"done"` +} + +type upsertRecordsBody struct { + Deletions []record `json:"deletions"` + Replacements []record `json:"replacements"` + Merges []record `json:"merges"` +} + +type updateRecordsBody struct { + Deletions []record `json:"deletions"` + Additions []record `json:"additions"` +} + +type record struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Data []string `json:"data"` + TTL string `json:"ttl"` +} + +type zone struct { + ID string `json:"id,omitempty"` + FolderId string `json:"folderId,omitempty"` + Zone string `json:"zone,omitempty"` + Type string `json:"type"` + Name string `json:"name"` +} + +func doRequest(token string, request *http.Request) ([]byte, error) { + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{} + reqDump, err := httputil.DumpRequestOut(request, true) + + response, err := client.Do(request) + if err != nil { + return nil, err + } + resDump, err := httputil.DumpResponse(response, true) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("%s\n%s", string(resDump) , string(reqDump)) + } + + defer response.Body.Close() + data, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return data, nil +} + +func getZoneName(ctx context.Context, token string, zoneID string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://dns.api.cloud.yandex.net/dns/v1/zones/%s", zoneID), nil) + data, err := doRequest(token, req) + if err != nil { + return "", err + } + + result := zone{} + if err := json.Unmarshal(data, &result); err != nil { + return "", err + } + + return unFQDN(result.Zone), nil +} + +func getAllRecords(ctx context.Context, token string, zoneID string) ([]libdns.Record, error) { + url := fmt.Sprintf("https://dns.api.cloud.yandex.net/dns/v1/zones/%s:listRecordSets", zoneID) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + + data, err := doRequest(token, req) + if err != nil { + return nil, err + } + + result := getAllRecordsResponse{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + records := []libdns.Record{} + for _, r := range result.Records { + intTtl, err := strconv.Atoi(r.TTL) + if err != nil{ + return []libdns.Record{}, err + } + records = append(records, libdns.Record{ + ID: r.ID, + Type: r.Type, + Name: r.Name, + Value: r.Data[0], + TTL: time.Duration(intTtl) * time.Second, + }) + } + + return records, nil +} + +func upsertRecords(ctx context.Context, token string, zoneID string, rs []libdns.Record, method string) ([]libdns.Record, error) { + reqData := upsertRecordsBody{ + Replacements: []record{}, + Deletions: []record{}, + Merges: []record{}, + } + zoneName, err := getZoneName(ctx, token, zoneID) + if err != nil{ + return []libdns.Record{}, err + } + for _, r := range rs{ + recordData := record{ + Type: r.Type, + Name: normalizeRecordName(r.Name, zoneName), + Data: []string{r.Value}, + TTL: fmt.Sprint(r.TTL.Seconds()), + } + if method == "DELETE" { + reqData.Replacements = append(reqData.Replacements, recordData) + } + if method == "REPLACE" { + reqData.Deletions = append(reqData.Deletions, recordData) + } + if method == "MERGE" { + reqData.Merges = append(reqData.Merges, recordData) + } + } + reqBuffer, err := json.Marshal(reqData) + if err != nil { + return []libdns.Record{}, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://dns.api.cloud.yandex.net/dns/v1/zones/%s:upsertRecordSets", zoneID), bytes.NewBuffer(reqBuffer)) + data, err := doRequest(token, req) + if err != nil { + return []libdns.Record{}, err + } + + result := createRecordResponse{} + if err := json.Unmarshal(data, &result); err != nil { + return []libdns.Record{}, err + } + return rs, nil // mixx3: this is a КОСТЫЛь to match the interface and pass the tests, this method does not return any info about rcords, see: https://cloud.yandex.ru/ru/docs/dns/api-ref/DnsZone/upsertRecordSets +} + +func updateRecords(ctx context.Context, token string, zoneID string, rs []libdns.Record, method string) ([]libdns.Record, error) { + zoneName, err := getZoneName(ctx, token, zoneID) + if err != nil { + return []libdns.Record{}, err + } + + reqData := updateRecordsBody{ + Additions: []record{}, + Deletions: []record{}, + } + + for _, r := range rs{ + recordData := record{ + Type: r.Type, + Name: normalizeRecordName(r.Name, zoneName), + Data: []string{r.Value}, + TTL: fmt.Sprint(r.TTL.Seconds()), + } + if method == "DELETE" { + reqData.Deletions = append(reqData.Deletions, recordData) + } + if method == "ADD" { + reqData.Additions = append(reqData.Additions, recordData) + } + } + + reqBuffer, err := json.Marshal(reqData) + if err != nil { + return []libdns.Record{}, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://dns.api.cloud.yandex.net/dns/v1/zones/%s:updateRecordSets", zoneID), bytes.NewBuffer(reqBuffer)) + data, err := doRequest(token, req) + if err != nil { + return []libdns.Record{}, err + } + + result := updateRecordResponse{} + if err := json.Unmarshal(data, &result); err != nil { + return []libdns.Record{}, err + } + resultList := []record{} + if method == "DELETE" { + resultList = result.Response.Deletions + } + if method == "ADD" { + resultList = result.Response.Additions + } + res := make([]libdns.Record, 0) + for _, r := range resultList{ + intTtl, _ := strconv.Atoi(r.TTL) + res = append(res, libdns.Record{ + ID: result.ID, + Type: r.Type, + Name: normalizeRecordName(r.Name, zoneName), + Value: r.Data[0], + TTL: time.Duration(intTtl) * time.Second, + }) + } + return res, nil +} + +func normalizeRecordName(recordName string, zone string) string { + // Workaround for https://github.com/caddy-dns/hetzner/issues/3 + // Can be removed after https://github.com/libdns/libdns/issues/12 + normalized := unFQDN(recordName) + normalized = strings.TrimSuffix(normalized, unFQDN(zone)) + return unFQDN(normalized) +} + +func loadPrivateKey(serviceConfigParsed serviceConfig) *rsa.PrivateKey { + rsaPrivateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(serviceConfigParsed.PrivateKey)) + if err != nil { + panic(err) + } + return rsaPrivateKey +} + +func signedToken(serviceConfigParsed serviceConfig) string { + claims := jwt.RegisteredClaims{ + Issuer: serviceConfigParsed.ServiceAccountID, + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(1 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + NotBefore: jwt.NewNumericDate(time.Now().UTC()), + Audience: []string{"https://iam.api.cloud.yandex.net/iam/v1/tokens"}, + } + token := jwt.NewWithClaims(jwt.SigningMethodPS256, claims) + token.Header["kid"] = serviceConfigParsed.ID + + privateKey := loadPrivateKey(serviceConfigParsed) + + signed, err := token.SignedString(privateKey) + if err != nil { + panic(err) + } + return signed +} + +func parseServiceConfig(keyFile string, sConf *serviceConfig) *serviceConfig{ + data, err := ioutil.ReadFile(keyFile) + if err != nil { + panic(err) + } + + err1 := json.Unmarshal(data, sConf) + if err1 != nil { + panic(err) + } + return sConf +} + +func getIAMToken(serviceConfigParsed serviceConfig) (string, error) { + jot := signedToken(serviceConfigParsed) + resp, err := http.Post( + "https://iam.api.cloud.yandex.net/iam/v1/tokens", + "application/json", + strings.NewReader(fmt.Sprintf(`{"jwt":"%s"}`, jot)), + ) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + panic(fmt.Sprintf("%s: %s", resp.Status, body)) + return "", err + } + var data struct { + IAMToken string `json:"iamToken"` + } + err = json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + return "", err + } + return data.IAMToken, nil +} diff --git a/go.mod b/go.mod index 7c2fb94..74ece32 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,8 @@ -module github.com/libdns/template +module github.com/profcomff/libdns-yandex-cloud -go 1.18 +go 1.21.5 -require github.com/libdns/libdns v0.2.1 +require ( + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/libdns/libdns v0.2.1 +) diff --git a/go.sum b/go.sum index ba9d0cf..2a83c7b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= diff --git a/provider.go b/provider.go index ea8a2f4..6c04152 100644 --- a/provider.go +++ b/provider.go @@ -1,53 +1,82 @@ -// Package libdnstemplate implements a DNS record management client compatible -// with the libdns interfaces for . TODO: This package is a -// template only. Customize all godocs for actual implementation. -package libdnstemplate +package libdns_yandex_cloud import ( - "context" - "fmt" - - "github.com/libdns/libdns" + "context" + "strings" + "github.com/libdns/libdns" ) -// TODO: Providers must not require additional provisioning steps by the callers; it -// should work simply by populating a struct and calling methods on it. If your DNS -// service requires long-lived state or some extra provisioning step, do it implicitly -// when methods are called; sync.Once can help with this, and/or you can use a -// sync.(RW)Mutex in your Provider struct to synchronize implicit provisioning. - -// Provider facilitates DNS record manipulation with . type Provider struct { - // TODO: put config fields here (with snake_case json - // struct tags on exported fields), for example: - APIToken string `json:"api_token,omitempty"` + ServiceAccountConfigPath string + ServiceConfigParsed serviceConfig + AuthAPIToken string `json:"auth_api_token"` +} + +func (p *Provider) UpdateApiToken() (error) { + if p.ServiceConfigParsed == (serviceConfig{}) { + parseServiceConfig(p.ServiceAccountConfigPath, &p.ServiceConfigParsed) + } + token, err := getIAMToken(p.ServiceConfigParsed) + if err != nil { + return err + } + p.AuthAPIToken = token + return nil } // GetRecords lists all the records in the zone. func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { - return nil, fmt.Errorf("TODO: not implemented") + p.UpdateApiToken() + records, err := getAllRecords(ctx, p.AuthAPIToken, p.ServiceConfigParsed.DnsZoneId) + if err != nil { + return nil, err + } + + return records, nil } // AppendRecords adds records to the zone. It returns the records that were added. func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { - return nil, fmt.Errorf("TODO: not implemented") + p.UpdateApiToken() + newRecords, err := updateRecords(ctx, p.AuthAPIToken, p.ServiceConfigParsed.DnsZoneId, records, "ADD") + if err != nil { + return nil, err + } + + return newRecords, nil } -// SetRecords sets the records in the zone, either by updating existing records or creating new ones. -// It returns the updated records. +// DeleteRecords deletes the records from the zone. +func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + p.UpdateApiToken() + _, err := updateRecords(ctx, p.AuthAPIToken, p.ServiceConfigParsed.DnsZoneId, records, "DELETE") + if err != nil { + return nil, err + } + + return records, nil +} + +// SetRecords sets the records in the zone, either by updating existing records +// or creating new ones. It returns the updated records. func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { - return nil, fmt.Errorf("TODO: not implemented") + p.UpdateApiToken() + setRecords, err := upsertRecords(ctx, p.AuthAPIToken, p.ServiceConfigParsed.DnsZoneId, records, "MERGE") + if err != nil { + return setRecords, err + } + return records, nil } -// DeleteRecords deletes the records from the zone. It returns the records that were deleted. -func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { - return nil, fmt.Errorf("TODO: not implemented") +// unFQDN trims any trailing "." from fqdn. Hetzner's API does not use FQDNs. +func unFQDN(fqdn string) string { + return strings.TrimSuffix(fqdn, ".") } // Interface guards var ( - _ libdns.RecordGetter = (*Provider)(nil) - _ libdns.RecordAppender = (*Provider)(nil) - _ libdns.RecordSetter = (*Provider)(nil) - _ libdns.RecordDeleter = (*Provider)(nil) + _ libdns.RecordGetter = (*Provider)(nil) + _ libdns.RecordAppender = (*Provider)(nil) + _ libdns.RecordSetter = (*Provider)(nil) + _ libdns.RecordDeleter = (*Provider)(nil) )