Skip to content

Commit cdd391d

Browse files
initial commit
0 parents  commit cdd391d

15 files changed

Lines changed: 1332 additions & 0 deletions

File tree

.github/workflows/release.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: goreleaser
2+
3+
on:
4+
pull_request:
5+
push:
6+
tags:
7+
- '*'
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
goreleaser:
14+
runs-on: ubuntu-latest
15+
steps:
16+
-
17+
name: Checkout
18+
uses: actions/checkout@v3
19+
with:
20+
fetch-depth: 0
21+
-
22+
name: Set up Go
23+
uses: actions/setup-go@v4
24+
with:
25+
go-version: 'stable'
26+
check-latest: true
27+
-
28+
name: Run GoReleaser
29+
uses: goreleaser/goreleaser-action@v4
30+
with:
31+
distribution: goreleaser
32+
version: latest
33+
args: release --clean
34+
env:
35+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
./ipv6ddns
2+
dist/

.goreleaser.yaml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
before:
2+
hooks:
3+
- go mod tidy
4+
- go generate ./...
5+
builds:
6+
- env:
7+
- CGO_ENABLED=0
8+
goos:
9+
# - aix
10+
# - android
11+
- darwin
12+
# - dragonfly
13+
- freebsd
14+
# - illumos
15+
# - ios
16+
- js
17+
- linux
18+
- netbsd
19+
- openbsd
20+
# - plan9
21+
- solaris
22+
# - windows # weird behaviour NDP packages not received
23+
goarch:
24+
- ppc64
25+
- 386
26+
- amd64
27+
- arm
28+
- arm64
29+
- wasm
30+
- loong64
31+
- mips
32+
- mipsle
33+
- mips64
34+
- mips64le
35+
- ppc64le
36+
- riscv64
37+
- s390x
38+
goarm:
39+
- 6
40+
- 7
41+
42+
archives:
43+
- files:
44+
- LICENSE*
45+
- README*
46+
- CHANGELOG*
47+
- config.json
48+
format: tar.gz
49+
name_template: >-
50+
{{ .ProjectName }}_
51+
{{- title .Os }}_
52+
{{- if eq .Arch "amd64" }}x86_64
53+
{{- else if eq .Arch "386" }}i386
54+
{{- else }}{{ .Arch }}{{ end }}
55+
{{- if .Arm }}v{{ .Arm }}{{ end }}
56+
format_overrides:
57+
- goos: windows
58+
format: zip
59+
checksum:
60+
name_template: 'checksums.txt'
61+
snapshot:
62+
name_template: "{{ incpatch .Version }}-next"
63+
changelog:
64+
sort: asc
65+
filters:
66+
exclude:
67+
- '^docs:'
68+
- '^test:'
69+
70+
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
71+
# vim: set ts=2 sw=2 tw=0 fo=cnqoj

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# IPv6 DDNS Updater
2+
3+
This utility discovers the IPv6 addresses of specific hosts in your network and updates DNS records dynamically. [Details](#what-does-this-do)
4+
5+
## Installation
6+
7+
Download the [latest release](https://github.com/miguelangel-nubla/ipv6ddns/releases/latest) for your architecture.
8+
9+
### Or build from source
10+
11+
Ensure you have Go installed on your system. If not, follow the instructions on the official [Go website](https://golang.org/doc/install) to install it. Then:
12+
```
13+
go install github.com/miguelangel-nubla/ipv6ddns
14+
```
15+
16+
## Usage
17+
18+
Adjust the configuration on config.json and run the binary with the desired flags:
19+
```
20+
./ipv6ddns [flags]
21+
```
22+
23+
### Flags
24+
25+
- `-config_file` (default "config.json"): Config file to use
26+
- `-log_level` (default "info"): Set the logging level (debug, info, warn, error, fatal, panic)
27+
- `-storm_delay` (default "60s"): Time to allow finishing storm of host discoveries before updating the DDNS record
28+
- `-ttl` (default "4h"): Time to keep a discovered host entry in the table after it has been last seen. This is not the TTL of the DDNS record
29+
- `-live` (default false): Show the current state live on the terminal
30+
31+
This utility needs to be executed as a superuser to be able to listen on the required ports.
32+
33+
Depending on your IPv6 network configuration, you will need to allow outside access to the hosts you want to expose.
34+
On Mikrotik devices it is particularly easy, you just need to add an address list with your domain, the IPv6 addresses to which it points to will be automatically resolved, and then you can simply create a new forwarding rule on the firewall that allows forwarding to hosts in that destination address list.
35+
The alternative on more limited systems is to either specify subnets or simply allow by destination MAC address.
36+
37+
## Configuration
38+
39+
This is the structure of the `config.json` file:
40+
41+
```
42+
{
43+
"tasks": {
44+
"load_balancer_for_web_app": { // whichever name you like for this task
45+
"subnets": ["2000::/3"], // only IPv6 addresses on these subnets will be updated. "2000::/3" is any GUA.
46+
"mac_address": ["00:11:22:33:44:55", "00:11:22:33:44:56"], // MAC addresses of the hosts to look for
47+
"endpoints": {
48+
"example-project": [ // name of the endpoint to use for this task, as configured on the credentials section at the bottom
49+
"test-webapp.example.com" // domain name whose AAAA records will be kept in sync
50+
]
51+
}
52+
}
53+
// ...
54+
// more task configurations if needed
55+
// ...
56+
},
57+
"credentials": {
58+
"example-project": { // name you will use to refer to this endpoint on the tasks
59+
"provider": "cloudflare", // one of the supported providers
60+
"settings": { // provider specific configuration
61+
"email": "email@example.com",
62+
"api_token": "CLOUDFLARETOKEN",
63+
"zone_name": "example.com",
64+
"ttl": "1h", // if proxied over cloudflare this will have no effect
65+
"proxied": true
66+
}
67+
}
68+
// ...
69+
// more credentials if needed
70+
// ...
71+
}
72+
}
73+
```
74+
75+
## DDNS providers
76+
77+
The available DDNS providers are:
78+
79+
- Cloudflare
80+
- Duckdns (provider only allows a single AAAA record)
81+
82+
- :rocket: **Adding your preferred provider is easy**:
83+
- Take a provider from `ddns/` such as cloudflare.go as a template.
84+
- Replace every reference to cloudflare with the new provider. This is case-sensitive.
85+
- Replace the API calls with the ones your provider need.
86+
- Test.
87+
- Create a PR!
88+
89+
---
90+
91+
# What does this do?
92+
93+
## The problem
94+
Having a domain name `my-web.example.com` point to your application on an IPv6 network is both simpler and more challenging than doing it with regular IPv4.
95+
96+
The classic method of directing traffic to your public IP address (i.e., your router) and then using NAT is no longer a good idea, nor necessary.
97+
98+
IPv6 addresses are globally routable, which is nice, but you will need to know the exact IP, as different machines or even containers on the same host (and even different applications) will have different "public" IPs, or GUAs in IPv6 terminology.
99+
100+
In this scenario DNS names become almost mandatory, good luck trying to remember and type a different `https://[2001:0db8:85a3:0000:8a2e:0370:7334:abcd]` to access each one of your hosts.
101+
102+
Technically, IPv6 addresses could be static, but part of the beauty of IPv6 is the ability to use and rotate multiple different IP addresses on demand. Even if you don't want to, some ISPs change your IPv6 prefix on router reboot or periodically.
103+
104+
## Rationale
105+
You might be thinking well, I will just run a DDNS updater on my server? It turns out that may not be really an option, remember each application has its own, *dynamic* IPv6? How do you indicate which IP should the DNS record point to? Does that mean the way to do that is to run the updater on the same container as your web server?
106+
107+
Unfortunately yes. That is not a great idea. Aside from the inconvenience of forking and using your own container images, there will be systems that you will not be able to modify. Think about a device on your network that has no way of running a custom executable, like an appliance, or an IP camera.
108+
109+
Wouldn't it be nice to have a utility anywhere on your network that detects the IPv6 of your desired hosts, identifies when they change, and updates the relevant DNS records accordingly?
110+
111+
## The solution
112+
This utility scans the network for the IPv6s of the hosts you want to expose, identified by their MAC address, and updates the corresponding DNS records automatically. This works for _all your network_, having the configuration and your credentials in a single place.
113+
114+
---
115+
116+
# Other nice usages
117+
118+
### Roaming over multiple IPv6 networks
119+
This utility allows you to move between IPv6 networks and to maintain inbound connectivity.
120+
You can have as many IPv6 addresses from different ISPs as you like and the AAAA records for your domain will be kept in sync.
121+
122+
For example, you can have a fiber connection, a backup LTE connection, and Starlink on your roof. Your domain will have AAAA records for every WAN connection, inbound WAN failover will simply work.

cmd/ipv6ddns/ipv6ddns.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package ipv6ddns
2+
3+
import (
4+
"flag"
5+
"log"
6+
"net/netip"
7+
"strings"
8+
"time"
9+
10+
"github.com/miguelangel-nubla/ipv6ddns/config"
11+
"github.com/miguelangel-nubla/ipv6ddns/pkg/tree"
12+
13+
"github.com/miguelangel-nubla/ipv6disc/pkg/terminal"
14+
"github.com/miguelangel-nubla/ipv6disc/pkg/worker"
15+
16+
"go.uber.org/zap"
17+
"go.uber.org/zap/zapcore"
18+
)
19+
20+
var configFile string
21+
var logLevel string
22+
var stormDelay time.Duration
23+
var ttl time.Duration
24+
var live bool
25+
26+
func init() {
27+
flag.StringVar(&configFile, "config_file", "config.json", "Path to the configuration file, default: config.json")
28+
flag.StringVar(&logLevel, "log_level", "info", "Logging level (debug, info, warn, error, fatal, panic) default: info")
29+
flag.DurationVar(&stormDelay, "storm_delay", 60*time.Second, "Time to allow for host discovery before updating the DDNS record")
30+
flag.DurationVar(&ttl, "ttl", 4*time.Hour, "Time to keep a discovered host entry in the table after it has been last seen. This is not the TTL of the DDNS record. Default: 4h")
31+
flag.BoolVar(&live, "live", false, "Show the currrent state live on the terminal, default: false")
32+
}
33+
34+
func Start() {
35+
flag.Parse()
36+
37+
startUpdater()
38+
}
39+
40+
func startUpdater() {
41+
config := config.NewConfig(configFile)
42+
43+
sugar := initializeLogger()
44+
45+
liveOutput := make(chan string)
46+
47+
table := worker.NewTable()
48+
worker.NewWorker(table, ttl, sugar).Start()
49+
50+
t := tree.NewTree()
51+
52+
onUpdate := func(endpoint *tree.Endpoint, domainName string) error {
53+
sugar.Infof("endpoint %s starting update of: %s", endpoint.ID, domainName)
54+
55+
endpoint.DomainsMutex.RLock()
56+
domain := endpoint.Domains[domainName]
57+
domain.HostsMutex.RLock()
58+
hostList := make([]string, 0, len(domain.Hosts))
59+
for _, host := range domain.Hosts {
60+
// Remove zone identifier from netip.Addr, zones strip prefixes
61+
hostList = append(hostList, netip.AddrFrom16(host.Address.As16()).String())
62+
}
63+
domain.HostsMutex.RUnlock()
64+
endpoint.DomainsMutex.RUnlock()
65+
66+
err := endpoint.Service.Update(domainName, hostList)
67+
68+
if err != nil {
69+
sugar.Errorf("endpoint %s error updating %s: %s", endpoint.ID, domainName, err)
70+
}
71+
72+
return err
73+
}
74+
75+
go func() {
76+
for {
77+
t.Update(config, table, stormDelay, onUpdate)
78+
79+
if live {
80+
var result strings.Builder
81+
result.WriteString(t.PrettyPrint(4))
82+
result.WriteString(table.PrettyPrint(4))
83+
result.WriteString(config.PrettyPrint(4))
84+
liveOutput <- result.String()
85+
}
86+
87+
time.Sleep(1 * time.Second)
88+
}
89+
}()
90+
91+
if live {
92+
terminal.LiveOutput(liveOutput)
93+
} else {
94+
select {}
95+
}
96+
}
97+
98+
func initializeLogger() *zap.SugaredLogger {
99+
zapLevel, err := getLogLevel(logLevel)
100+
if err != nil {
101+
log.Fatalf("invalid log level: %s", logLevel)
102+
}
103+
104+
if live {
105+
zapLevel = zapcore.FatalLevel
106+
}
107+
108+
cfg := zap.NewProductionConfig()
109+
cfg.Level = zap.NewAtomicLevelAt(zapLevel)
110+
cfg.OutputPaths = []string{"stdout"}
111+
cfg.ErrorOutputPaths = []string{"stderr"}
112+
cfg.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder
113+
114+
logger := zap.Must(cfg.Build())
115+
defer logger.Sync()
116+
117+
return logger.Sugar()
118+
}
119+
120+
func getLogLevel(level string) (zapcore.Level, error) {
121+
var zapLevel zapcore.Level
122+
err := zapLevel.UnmarshalText([]byte(level))
123+
if err != nil {
124+
return zap.InfoLevel, err
125+
}
126+
return zapLevel, nil
127+
}

0 commit comments

Comments
 (0)