initial build

This commit is contained in:
James Tombleson 2022-08-17 16:58:45 +00:00
parent c0bb5199ee
commit 816d3c098c
11 changed files with 589 additions and 1 deletions

64
.github/workflows/docker.build.yaml vendored Normal file
View File

@ -0,0 +1,64 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
#schedule:
# - cron: '21 19 * * *'
push:
branches: [ master ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
#pull_request:
# branches: [ master ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
#images: ${{ env.REGISTRY }}/newsbot.worker
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

3
.gitignore vendored
View File

@ -1,4 +1,7 @@
.env
# Binaries for programs and plugins
ddns
*.exe
*.exe~
*.dll

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM golang:1.19 as build
COPY . /app
WORKDIR /app
RUN go build .
FROM alpine:latest as app
RUN apk --no-cache add bash libc6-compat && \
mkdir /app
COPY --from=build /app/ddns /app
CMD [ "/app/ddns" ]

View File

@ -1,2 +1,30 @@
# cloudflare-ddns
Golang based tool to maintain Cloudflare Dynamic DNS for your sites.
Golang based tool to maintain Cloudflare Dynamic DNS for your sites. Run this locally or as a Docker image.
When the application starts, it will run every 15 minutes and check your defined hosts and compare the IP Address listed. If they dont match, it will be updated to refect the public IP address of the server its running from.
This does not have any UI elements so you need to check the logs to se how its going.
## Usage
```yaml
# docker-compose.yaml
version: "3"
services:
app:
image: ghcr.io/jtom38/cloudflare-ddns:master
container_name: cfddns
environment:
EMAIL: "yourcloudflareemailaddress"
API_TOKEN: "cloudflare-api-key"
DOMAIN: "exampledomain.com"
HOSTS: "example1,example2,www"
```
## Credit
The original post that gave me this idea can be found [here](https://adamtheautomator.com/cloudflare-dynamic-dns/). This was written in PowerShell and thought I could improve on it with Go and Docker.

248
cloudflare.go Normal file
View File

@ -0,0 +1,248 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"time"
)
type CloudFlareClient struct {
apiToken string
email string
httpClient http.Client
}
func NewCloudFlareClient(ApiToken string, UserEmail string) *CloudFlareClient {
c := CloudFlareClient{
apiToken: ApiToken,
email: UserEmail,
}
c.httpClient = http.Client{}
return &c
}
type listDomainZones struct {
Result []struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
Paused bool `json:"paused,omitempty"`
Type string `json:"type,omitempty"`
DevelopmentMode int `json:"development_mode,omitempty"`
NameServers []string `json:"name_servers,omitempty"`
OriginalNameServers []string `json:"original_name_servers,omitempty"`
OriginalRegistrar string `json:"original_registrar,omitempty"`
OriginalDnshost interface{} `json:"original_dnshost,omitempty"`
ModifiedOn time.Time `json:"modified_on,omitempty"`
CreatedOn time.Time `json:"created_on,omitempty"`
ActivatedOn time.Time `json:"activated_on,omitempty"`
Meta struct {
Step int `json:"step,omitempty"`
CustomCertificateQuota int `json:"custom_certificate_quota,omitempty"`
PageRuleQuota int `json:"page_rule_quota,omitempty"`
PhishingDetected bool `json:"phishing_detected,omitempty"`
MultipleRailgunsAllowed bool `json:"multiple_railguns_allowed,omitempty"`
} `json:"meta,omitempty"`
Owner struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Email string `json:"email,omitempty"`
} `json:"owner,omitempty"`
Account struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
} `json:"account,omitempty"`
Tenant struct {
ID interface{} `json:"id,omitempty"`
Name interface{} `json:"name,omitempty"`
} `json:"tenant,omitempty"`
TenantUnit struct {
ID interface{} `json:"id,omitempty"`
} `json:"tenant_unit,omitempty"`
Permissions []string `json:"permissions,omitempty"`
Plan struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Price int `json:"price,omitempty"`
Currency string `json:"currency,omitempty"`
Frequency string `json:"frequency,omitempty"`
IsSubscribed bool `json:"is_subscribed,omitempty"`
CanSubscribe bool `json:"can_subscribe,omitempty"`
LegacyID string `json:"legacy_id,omitempty"`
LegacyDiscount bool `json:"legacy_discount,omitempty"`
ExternallyManaged bool `json:"externally_managed,omitempty"`
} `json:"plan,omitempty"`
} `json:"result,omitempty"`
ResultInfo struct {
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
Count int `json:"count,omitempty"`
TotalCount int `json:"total_count,omitempty"`
} `json:"result_info,omitempty"`
Success bool `json:"success,omitempty"`
Errors []interface{} `json:"errors,omitempty"`
Messages []interface{} `json:"messages,omitempty"`
}
// Lists out all the zones bound to an ac
//
// https://api.cloudflare.com/#zone-list-zones
func (c *CloudFlareClient) GetDomainByName(domain string) (*listDomainZones, error) {
var items listDomainZones
uri := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%v", domain)
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
return &items, err
}
req.Header.Set("X-Auth-Email", c.email)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.apiToken))
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return &items, err
}
if resp.StatusCode != 200 {
return &items, ErrInvalidStatusCode
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return &items, ErrWasNotJson
}
//log.Print(string(body))
err = json.Unmarshal(body, &items)
if err != nil {
return &items, ErrFailedToDecodeJson
}
if !items.Success {
log.Println("Failed to find the requested domain on Cloudflare.")
return &items, ErrDomainNotFound
}
return &items, nil
}
type DnsDetails struct {
Success bool `json:"success"`
Errors []interface{} `json:"errors"`
Messages []interface{} `json:"messages"`
Result []struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Proxiable bool `json:"proxiable"`
Proxied bool `json:"proxied"`
TTL int `json:"ttl"`
Locked bool `json:"locked"`
ZoneID string `json:"zone_id"`
ZoneName string `json:"zone_name"`
CreatedOn time.Time `json:"created_on"`
ModifiedOn time.Time `json:"modified_on"`
Data struct {
} `json:"data"`
Meta struct {
AutoAdded bool `json:"auto_added"`
Source string `json:"source"`
} `json:"meta"`
} `json:"result"`
}
func (c *CloudFlareClient) GetDnsEntriesByDomain(DomainId string, Host string, Domain string) (*DnsDetails, error) {
var items DnsDetails
name := fmt.Sprintf("%v.%v", Host, Domain)
uri := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%v/dns_records?name=%v", DomainId, name)
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
return &items, err
}
req.Header.Set("X-Auth-Email", c.email)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.apiToken))
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return &items, err
}
if resp.StatusCode != 200 {
return &items, ErrInvalidStatusCode
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return &items, ErrWasNotJson
}
err = json.Unmarshal(body, &items)
if err != nil {
return &items, ErrFailedToDecodeJson
}
if !items.Success {
log.Println("Failed to find the requested domain on Cloudflare.")
return &items, ErrDomainNotFound
}
return &items, nil
}
type dnsUpdate struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
Ttl int `json:"ttl"`
Proxied bool `json:"proxied"`
}
func (c *CloudFlareClient) UpdateDnsEntry(DomainId string, DnsDetails *DnsDetails, NewIpAddress string) error {
param := dnsUpdate{
Type: DnsDetails.Result[0].Type,
Name: DnsDetails.Result[0].Name,
Content: NewIpAddress,
Ttl: DnsDetails.Result[0].TTL,
Proxied: DnsDetails.Result[0].Proxied,
}
endpoint := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%v/dns_records/%v", DomainId, DnsDetails.Result[0].ID)
body, err := json.Marshal(param)
if err != nil {
return err
}
req, err := http.NewRequest("GET", endpoint, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("X-Auth-Email", c.email)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.apiToken))
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 200 {
log.Println(resp.Status)
return errors.New("failed to update the IP address")
}
//log.Print(resp)
return nil
}

72
config.go Normal file
View File

@ -0,0 +1,72 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
const (
ConfigEmail = "EMAIL"
ConfigToken = "API_TOKEN"
ConfigDomain = "DOMAIN"
ConfigHosts = "HOSTS"
)
type ConfigClient struct{}
func NewConfigClient() ConfigClient {
c := ConfigClient{}
c.RefreshEnv()
return c
}
func (cc *ConfigClient) GetConfig(key string) string {
res, filled := os.LookupEnv(key)
if !filled {
log.Printf("Missing the a value for '%v'. Could generate errors.", key)
}
return res
}
func (cc *ConfigClient) GetFeature(flag string) (bool, error) {
cc.RefreshEnv()
res, filled := os.LookupEnv(flag)
if !filled {
errorMessage := fmt.Sprintf("'%v' was not found", flag)
return false, errors.New(errorMessage)
}
b, err := strconv.ParseBool(res)
if err != nil {
return false, err
}
return b, nil
}
// Use this when your ConfigClient has been opened for awhile and you want to ensure you have the most recent env changes.
func (cc *ConfigClient) RefreshEnv() {
// Check to see if we have the env file on the system
_, err := os.Stat(".env")
// We have the file, load it.
if err == nil {
_, err := os.Open(".env")
if err == nil {
loadEnvFile()
}
}
}
func loadEnvFile() {
err := godotenv.Load()
if err != nil {
log.Fatalln(err)
}
}

59
cron.go Normal file
View File

@ -0,0 +1,59 @@
package main
import (
"fmt"
"log"
"github.com/robfig/cron/v3"
)
type cronClient struct{
scheduler *cron.Cron
}
func NewCron() cronClient {
c := cronClient{
scheduler: cron.New(),
}
return c
}
func (c cronClient) RunCloudflareCheck(ApiToken string, Email string, Domain string, Hosts []string) {
log.Println("Starting check...")
log.Println("Checking the current IP Address")
currentIp, err := GetCurrentIpAddress()
if err != nil {
log.Println(err)
return
}
cf := NewCloudFlareClient(ApiToken, Email)
log.Println("Checking domain information on CloudFlare")
domainDetails, err := cf.GetDomainByName(Domain)
if err != nil {
log.Println("Unable to get information from CloudFlare.")
log.Println("Double check the API Token to make sure it's valid.")
log.Println(err)
return
}
for _, host := range Hosts {
hostname := fmt.Sprintf("%v.%v", host, Domain)
log.Printf("Reviewing '%v'", hostname)
dns, err := cf.GetDnsEntriesByDomain(domainDetails.Result[0].ID, host, Domain)
if err != nil {
log.Println("failed to collect dns entry")
return
}
if dns.Result[0].Content != currentIp {
log.Println("IP Address no longer matches, sending an update")
err = cf.UpdateDnsEntry(domainDetails.Result[0].ID, dns, currentIp)
if err != nil {
log.Println("Failed to update the DNS record!")
}
}
}
log.Println("Done!")
}

11
docker-compose.yaml Normal file
View File

@ -0,0 +1,11 @@
version: "3"
services:
app:
image: ghcr.io/jtom38/cloudflare-ddns:master
container_name: cfddns
environment:
EMAIL: "yourcloudflareemailaddress"
API_TOKEN: "cloudflare-api-key"
DOMAIN: "exampledomain.com"
HOSTS: "example1,example2,www"

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module github.com/jtom38/cloudflare/ddns
go 1.19
require (
github.com/joho/godotenv v1.4.0
github.com/robfig/cron/v3 v3.0.1
)

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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=

77
main.go Normal file
View File

@ -0,0 +1,77 @@
package main
import (
"errors"
"io"
"log"
"net/http"
"strings"
"time"
)
var (
ErrInvalidStatusCode error = errors.New("did not get an acceptiable status code from the server")
ErrFailedToDecodeBody error = errors.New("unable to decode the body")
ErrFailedToDecodeJson error = errors.New("unexpected json format was returned")
ErrWasNotJson error = errors.New("response from server was not json")
ErrDomainNotFound error = errors.New("unable to find requested domain on cloudflare")
)
func GetCurrentIpAddress() (string, error) {
resp, err := http.Get("https://v4.ident.me")
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
return "", ErrInvalidStatusCode
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", ErrFailedToDecodeBody
}
return string(body), nil
}
func main() {
config := NewConfigClient()
email := config.GetConfig(ConfigEmail)
if email == "" {
log.Println("Unable to find 'EMAIL' env value.")
return
}
token := config.GetConfig(ConfigToken)
if token == "" {
log.Println("Unable to find 'API_TOKEN' env value.")
}
domain := config.GetConfig(ConfigDomain)
if token == "" {
log.Println("Unable to find 'DOMAIN' env value.")
}
hosts := config.GetConfig(ConfigHosts)
if token == "" {
log.Println("Unable to find 'HOSTS' env value.")
}
hostsArray := strings.Split(hosts, ",")
log.Println("Env Check: OK")
cron := NewCron()
log.Println("Cloudflare Check will run every 15 minutes.")
cron.scheduler.AddFunc("0,15,30,45 * * * *", func() {
cron.RunCloudflareCheck(token, email, domain, hostsArray)
})
cron.scheduler.Start()
log.Println("Application has started!")
for {
time.Sleep(30 * time.Second)
}
}