initial commit

This commit is contained in:
Benjamin Bärthlein 2020-03-28 17:56:11 +01:00
parent 4a3673e0d3
commit c4809b85b4
51 changed files with 26929 additions and 1 deletions

9
.gitignore vendored
View File

@ -11,5 +11,14 @@
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# dont want to share go.sum
go.sum
# Dependency directories (remove the comment below to include it)
# vendor/
# IntelliJ IDEA
.idea
# skip the database folder
database

20
.travis.yml Normal file
View File

@ -0,0 +1,20 @@
dist: bionic
language: go
env: GO111MODULE=on
go:
- 1.13.x
- 1.14.x
git:
depth: 1
script:
- go test -v ./...
notifications:
email:
on_success: change
on_failure: always

View File

@ -1,6 +1,7 @@
MIT License
Copyright (c) 2020 benjaminbear
Copyright (c) 2020 Benjamin Bärthlein
Copyright (c) 2016 David Prandzioch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

26
deployment/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM golang:latest as builder
ENV GO111MODULE=on
ENV GOPATH=/root/go
RUN mkdir -p /root/go/src
COPY dyndns /root/go/src/dyndns
RUN cd /root/go/src/dyndns && go mod download && GOOS=linux GOARCH=amd64 go build -o /root/go/bin/dyndns && go test -v
FROM debian:buster-slim
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -q -y bind9 dnsutils curl && \
apt-get clean
RUN chmod 770 /var/cache/bind
COPY deployment/setup.sh /root/setup.sh
RUN chmod +x /root/setup.sh
COPY deployment/named.conf.options /etc/bind/named.conf.options
WORKDIR /root
COPY --from=builder /root/go/bin/dyndns /root/dyndns
COPY dyndns/views /root/views
COPY dyndns/static /root/static
EXPOSE 53 8080
CMD ["sh", "-c", "/root/setup.sh ; service bind9 start ; /root/dyndns"]

View File

@ -0,0 +1,17 @@
version: '3'
services:
ddns:
image: bbaerthlein/docker-ddns-server:latest
restart: always
environment:
DDNS_ADMIN_LOGIN: 'admin:$$3$$abcdefg'
DDNS_DOMAIN: 'dyndns.example.com'
DDNS_PARENT_NS: 'ns.example.com'
DDNS_DEFAULT_TTL: '3600'
ports:
- "53:53"
- "53:53/udp"
- "8080:8080"
volumes:
- ./bind-data:/var/cache/bind
- ./database:/root/dyndns/database

4
deployment/envfile Normal file
View File

@ -0,0 +1,4 @@
DDNS_ADMIN_LOGIN=admin:$$3$$abcdefg
DDNS_DOMAIN=dyndns.example.com
DDNS_PARENT_NS=ns.example.com
DDNS_DEFAULT_TTL=3600

View File

@ -0,0 +1,8 @@
options {
directory "/var/cache/bind";
dnssec-validation auto;
recursion no;
allow-transfer { none; };
auth-nxdomain no;
listen-on-v6 { any; };
};

48
deployment/setup.sh Normal file
View File

@ -0,0 +1,48 @@
#!/bin/bash
[ -z "$DDNS_ADMIN_LOGIN" ] && echo "DDNS_ADMIN_LOGIN not set" && exit 1;
[ -z "$DDNS_DOMAIN" ] && echo "DDNS_DOMAIN not set" && exit 1;
[ -z "$DDNS_PARENT_NS" ] && echo "DDNS_PARENT_NS not set" && exit 1;
[ -z "$DDNS_DEFAULT_TTL" ] && echo "DDNS_DEFAULT_TTL not set" && exit 1;
DDNS_IP=$(curl icanhazip.com)
if ! grep 'zone "'$DDNS_DOMAIN'"' /etc/bind/named.conf > /dev/null
then
echo "creating zone...";
cat >> /etc/bind/named.conf <<EOF
zone "$DDNS_DOMAIN" {
type master;
file "$DDNS_DOMAIN.zone";
allow-query { any; };
allow-transfer { none; };
allow-update { localhost; };
};
EOF
fi
if [ ! -f /var/cache/bind/$DDNS_DOMAIN.zone ]
then
echo "creating zone file..."
cat > /var/cache/bind/$DDNS_DOMAIN.zone <<EOF
\$ORIGIN .
\$TTL 86400 ; 1 day
$DDNS_DOMAIN IN SOA ${DDNS_PARENT_NS}. root.${DDNS_DOMAIN}. (
74 ; serial
3600 ; refresh (1 hour)
900 ; retry (15 minutes)
604800 ; expire (1 week)
86400 ; minimum (1 day)
)
NS ${DDNS_PARENT_NS}.
A ${DDNS_IP}
\$ORIGIN ${DDNS_DOMAIN}.
\$TTL ${DDNS_DEFAULT_TTL}
EOF
fi
# If /var/cache/bind is a volume, permissions are probably not ok
chown root:bind /var/cache/bind
chown bind:bind /var/cache/bind/*
chmod 770 /var/cache/bind
chmod 644 /var/cache/bind/*

15
dyndns/go.mod Normal file
View File

@ -0,0 +1,15 @@
module github.com/benjaminbear/docker-ddns-server/dyndns
go 1.14
require (
github.com/foolin/goview v0.3.0
github.com/go-playground/validator/v10 v10.2.0
github.com/jinzhu/gorm v1.9.12
github.com/labstack/echo/v4 v4.1.15
github.com/labstack/gommon v0.3.0
github.com/tg123/go-htpasswd v1.0.0
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 // indirect
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect
golang.org/x/sys v0.0.0-20200327173247-9dae0f8f5775 // indirect
)

116
dyndns/handler/handler.go Normal file
View File

@ -0,0 +1,116 @@
package handler
import (
"fmt"
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"github.com/go-playground/validator/v10"
"github.com/jinzhu/gorm"
"github.com/labstack/echo/v4"
"github.com/tg123/go-htpasswd"
"os"
"strings"
)
type Handler struct {
DB *gorm.DB
AuthHost *model.Host
AuthAdmin bool
Config Envs
}
type Envs struct {
AdminLogin string
Domain string
}
type CustomValidator struct {
Validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.Validator.Struct(i)
}
type Error struct {
Message string `json:"message"`
}
func (h *Handler) Authenticate(username, password string, c echo.Context) (bool, error) {
h.AuthHost = nil
h.AuthAdmin = false
ok, err := h.authByEnv(username, password)
if err != nil {
fmt.Println("Error:", err)
return false, nil
}
if ok {
h.AuthAdmin = true
return true, nil
}
host := &model.Host{}
if err := h.DB.Where(&model.Host{UserName: username, Password: password}).First(host).Error; err != nil {
fmt.Println("Error:", err)
return false, nil
}
h.AuthHost = host
return true, nil
}
func (h *Handler) authByEnv(username, password string) (bool, error) {
hashReader := strings.NewReader(h.Config.AdminLogin)
pw, err := htpasswd.NewFromReader(hashReader, htpasswd.DefaultSystems, nil)
if err != nil {
return false, err
}
if ok := pw.Match(username, password); ok {
return true, nil
}
return false, nil
}
func (h *Handler) ParseEnvs() error {
h.Config = Envs{}
h.Config.AdminLogin = os.Getenv("DDNS_ADMIN_LOGIN")
if h.Config.AdminLogin == "" {
return fmt.Errorf("environment variable DDNS_ADMIN_LOGIN has to be set")
}
h.Config.Domain = os.Getenv("DDNS_DOMAIN")
if h.Config.Domain == "" {
return fmt.Errorf("environment variable DDNS_DOMAIN has to be set")
}
return nil
}
func (h *Handler) InitDB() (err error) {
if _, err := os.Stat("database"); os.IsNotExist(err) {
err = os.MkdirAll("database", os.ModePerm)
if err != nil {
return err
}
}
h.DB, err = gorm.Open("sqlite3", "database/ddns.db")
if err != nil {
return err
}
if !h.DB.HasTable(&model.Host{}) {
h.DB.CreateTable(&model.Host{})
}
if !h.DB.HasTable(&model.Log{}) {
h.DB.CreateTable(&model.Log{})
}
return nil
}

258
dyndns/handler/host.go Normal file
View File

@ -0,0 +1,258 @@
package handler
import (
"fmt"
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"github.com/jinzhu/gorm"
"github.com/labstack/echo/v4"
"net"
"net/http"
"strconv"
"time"
)
func (h *Handler) GetHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// Display site
return c.JSON(http.StatusOK, id)
}
func (h *Handler) ListHosts(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
hosts := new([]model.Host)
if err = h.DB.Find(hosts).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "listhosts", echo.Map{
"hosts": hosts,
"config": h.Config,
})
}
func (h *Handler) AddHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
return c.Render(http.StatusOK, "edithost", echo.Map{
"addEdit": "add",
"config": h.Config,
})
}
func (h *Handler) EditHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "edithost", echo.Map{
"host": host,
"addEdit": "edit",
"config": h.Config,
})
}
func (h *Handler) CreateHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
host := &model.Host{}
if err = c.Bind(host); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = c.Validate(host); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = h.DB.Create(host).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// If a ip is set create dns entry
if host.Ip != "" {
ipType := getIPType(host.Ip)
if ipType == "" {
return c.JSON(http.StatusBadRequest, &Error{fmt.Sprintf("ip %s is not a valid ip", host.Ip)})
}
if err = h.updateRecord(host.Hostname, host.Ip, ipType, host.Ttl); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
}
return c.JSON(http.StatusOK, host)
}
func (h *Handler) UpdateHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
hostUpdate := &model.Host{}
if err = c.Bind(hostUpdate); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
forceRecordUpdate := host.UpdateHost(hostUpdate)
if err = c.Validate(host); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = h.DB.Save(host).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// If ip or ttl changed update dns entry
if forceRecordUpdate {
ipType := getIPType(host.Ip)
if ipType == "" {
return c.JSON(http.StatusBadRequest, &Error{fmt.Sprintf("ip %s is not a valid ip", host.Ip)})
}
if err = h.updateRecord(host.Hostname, host.Ip, ipType, host.Ttl); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
}
return c.JSON(http.StatusOK, host)
}
func (h *Handler) DeleteHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
err = h.DB.Transaction(func(tx *gorm.DB) error {
if err = tx.Unscoped().Delete(host).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = tx.Where(&model.Log{HostID: uint(id)}).Delete(&model.Log{}).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return nil
})
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = h.deleteRecord(host.Hostname); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.JSON(http.StatusOK, id)
}
func (h *Handler) UpdateIP(c echo.Context) (err error) {
if h.AuthHost == nil {
return c.String(http.StatusBadRequest, "badauth\n")
}
log := &model.Log{Status: false, Host: *h.AuthHost, TimeStamp: time.Now(), UserAgent: shrinkUserAgent(c.Request().UserAgent())}
log.SentIP = c.QueryParam(("myip"))
// Get caller IP
log.CallerIP, err = getCallerIP(c.Request())
if log.CallerIP == "" {
log.CallerIP, _, err = net.SplitHostPort(c.Request().RemoteAddr)
if err != nil {
if err = h.CreateLogEntry(log); err != nil {
fmt.Println(err)
}
return c.String(http.StatusBadRequest, "badrequest\n")
}
}
// Validate hostname
hostname := c.QueryParam("hostname")
if hostname == "" || hostname != h.AuthHost.Hostname+"."+h.Config.Domain {
if err = h.CreateLogEntry(log); err != nil {
fmt.Println(err)
}
return c.String(http.StatusBadRequest, "notfqdn\n")
}
// Get IP type
ipType := getIPType(log.SentIP)
if ipType == "" {
log.SentIP = log.CallerIP
ipType = getIPType(log.SentIP)
if ipType == "" {
if err = h.CreateLogEntry(log); err != nil {
fmt.Println(err)
}
return c.String(http.StatusBadRequest, "badrequest\n")
}
}
// add/update DNS record
if err = h.updateRecord(log.Host.Hostname, log.SentIP, ipType, log.Host.Ttl); err != nil {
if err = h.CreateLogEntry(log); err != nil {
fmt.Println(err)
}
return c.String(http.StatusBadRequest, "dnserr\n")
}
log.Host.Ip = log.SentIP
log.Host.LastUpdate = log.TimeStamp
log.Status = true
if err = h.CreateLogEntry(log); err != nil {
fmt.Println(err)
}
return c.String(http.StatusOK, "good\n")
}

53
dyndns/handler/log.go Normal file
View File

@ -0,0 +1,53 @@
package handler
import (
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"github.com/labstack/echo/v4"
"net/http"
"strconv"
)
func (h *Handler) CreateLogEntry(log *model.Log) (err error) {
if err = h.DB.Create(log).Error; err != nil {
return err
}
return nil
}
func (h *Handler) ShowLogs(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
logs := new([]model.Log)
if err = h.DB.Preload("Host").Limit(30).Find(logs).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "listlogs", echo.Map{
"logs": logs,
"config": h.Config,
})
}
func (h *Handler) ShowHostLogs(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{"You are not allow to view that content"})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
logs := new([]model.Log)
if err = h.DB.Preload("Host").Where(&model.Log{HostID: uint(id)}).Limit(30).Find(logs).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "listlogs", echo.Map{
"logs": logs,
"config": h.Config,
})
}

181
dyndns/handler/update.go Normal file
View File

@ -0,0 +1,181 @@
package handler
import (
"bufio"
"bytes"
"errors"
"fmt"
"github.com/benjaminbear/docker-ddns-server/dyndns/ipparser"
"io/ioutil"
"net"
"net/http"
"os"
"os/exec"
"strings"
)
func (h *Handler) updateRecord(hostname string, ipAddr string, addrType string, ttl int) error {
fmt.Printf("%s record update request: %s -> %s\n", addrType, hostname, ipAddr)
f, err := ioutil.TempFile(os.TempDir(), "dyndns")
if err != nil {
return err
}
defer os.Remove(f.Name())
w := bufio.NewWriter(f)
w.WriteString(fmt.Sprintf("server %s\n", "localhost"))
w.WriteString(fmt.Sprintf("zone %s\n", h.Config.Domain))
w.WriteString(fmt.Sprintf("update delete %s.%s %s\n", hostname, h.Config.Domain, addrType))
w.WriteString(fmt.Sprintf("update add %s.%s %v %s %s\n", hostname, h.Config.Domain, ttl, addrType, ipAddr))
w.WriteString("send\n")
w.Flush()
f.Close()
cmd := exec.Command("/usr/bin/nsupdate", f.Name())
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("%v: %v", err, stderr.String())
}
if out.String() != "" {
return fmt.Errorf(out.String())
}
return nil
}
func (h *Handler) deleteRecord(hostname string) error {
fmt.Printf("record delete request: %s\n", hostname)
f, err := ioutil.TempFile(os.TempDir(), "dyndns")
if err != nil {
return err
}
defer os.Remove(f.Name())
w := bufio.NewWriter(f)
w.WriteString(fmt.Sprintf("server %s\n", "localhost"))
w.WriteString(fmt.Sprintf("zone %s\n", h.Config.Domain))
w.WriteString(fmt.Sprintf("update delete %s.%s\n", hostname, h.Config.Domain))
w.WriteString("send\n")
w.Flush()
f.Close()
cmd := exec.Command("/usr/bin/nsupdate", f.Name())
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("%v: %v", err, stderr.String())
}
if out.String() != "" {
return fmt.Errorf(out.String())
}
return nil
}
func getIPType(ipAddr string) string {
if ipparser.ValidIP4(ipAddr) {
return "A"
} else if ipparser.ValidIP6(ipAddr) {
return "AAAA"
} else {
return ""
}
}
func getCallerIP(r *http.Request) (string, error) {
fmt.Println("request", r.Header)
for _, h := range []string{"X-Real-Ip", "X-Forwarded-For"} {
addresses := strings.Split(r.Header.Get(h), ",")
// march from right to left until we get a public address
// that will be the address right before our proxy.
for i := len(addresses) - 1; i >= 0; i-- {
ip := strings.TrimSpace(addresses[i])
// header can contain spaces too, strip those out.
realIP := net.ParseIP(ip)
if !realIP.IsGlobalUnicast() || isPrivateSubnet(realIP) {
// bad address, go to next
continue
}
return ip, nil
}
}
return "", errors.New("no match")
}
//ipRange - a structure that holds the start and end of a range of ip addresses
type ipRange struct {
start net.IP
end net.IP
}
// inRange - check to see if a given ip address is within a range given
func inRange(r ipRange, ipAddress net.IP) bool {
// strcmp type byte comparison
if bytes.Compare(ipAddress, r.start) >= 0 && bytes.Compare(ipAddress, r.end) < 0 {
return true
}
return false
}
var privateRanges = []ipRange{
ipRange{
start: net.ParseIP("10.0.0.0"),
end: net.ParseIP("10.255.255.255"),
},
ipRange{
start: net.ParseIP("100.64.0.0"),
end: net.ParseIP("100.127.255.255"),
},
ipRange{
start: net.ParseIP("172.16.0.0"),
end: net.ParseIP("172.31.255.255"),
},
ipRange{
start: net.ParseIP("192.0.0.0"),
end: net.ParseIP("192.0.0.255"),
},
ipRange{
start: net.ParseIP("192.168.0.0"),
end: net.ParseIP("192.168.255.255"),
},
ipRange{
start: net.ParseIP("198.18.0.0"),
end: net.ParseIP("198.19.255.255"),
},
}
// isPrivateSubnet - check to see if this ip is in a private subnet
func isPrivateSubnet(ipAddress net.IP) bool {
// my use case is only concerned with ipv4 atm
if ipCheck := ipAddress.To4(); ipCheck != nil {
// iterate over all our ranges
for _, r := range privateRanges {
// check if this ip is in a private range
if inRange(r, ipAddress) {
return true
}
}
}
return false
}
func shrinkUserAgent(agent string) string {
agentParts := strings.Split(agent, " ")
return agentParts[0]
}

View File

@ -0,0 +1,23 @@
package ipparser
import (
"net"
)
func ValidIP4(ipAddress string) bool {
testInput := net.ParseIP(ipAddress)
if testInput == nil {
return false
}
return (testInput.To4() != nil)
}
func ValidIP6(ip6Address string) bool {
testInputIP6 := net.ParseIP(ip6Address)
if testInputIP6 == nil {
return false
}
return (testInputIP6.To16() != nil)
}

View File

@ -0,0 +1,29 @@
package ipparser
import (
"testing"
)
func TestValidIP4ToReturnTrueOnValidAddress(t *testing.T) {
result := ValidIP4("1.2.3.4")
if result != true {
t.Fatalf("Expected ValidIP(1.2.3.4) to be true but got false")
}
}
func TestValidIP4ToReturnFalseOnInvalidAddress(t *testing.T) {
result := ValidIP4("abcd")
if result == true {
t.Fatalf("Expected ValidIP(abcd) to be false but got true")
}
}
func TestValidIP4ToReturnFalseOnEmptyAddress(t *testing.T) {
result := ValidIP4("")
if result == true {
t.Fatalf("Expected ValidIP() to be false but got true")
}
}

67
dyndns/main.go Normal file
View File

@ -0,0 +1,67 @@
package main
import (
"net/http"
"github.com/benjaminbear/docker-ddns-server/dyndns/handler"
"github.com/foolin/goview/supports/echoview-v4"
"github.com/go-playground/validator/v10"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/gommon/log"
)
func main() {
e := echo.New()
e.Logger.SetLevel(log.ERROR)
e.Use(middleware.Logger())
// Set Renderer
e.Renderer = echoview.Default()
// Set Validator
e.Validator = &handler.CustomValidator{Validator: validator.New()}
// Set Statics
e.Static("/static", "static")
// Initialize handler
h := &handler.Handler{}
// Database connection
if err := h.InitDB(); err != nil {
e.Logger.Fatal(err)
}
defer h.DB.Close()
if err := h.ParseEnvs(); err != nil {
e.Logger.Fatal(err)
}
e.Use(middleware.BasicAuth(h.Authenticate))
// UI Routes
e.GET("/", func(c echo.Context) error {
//render with master
return c.Render(http.StatusOK, "index", nil)
})
e.GET("/hosts/add", h.AddHost)
e.GET("/hosts/edit/:id", h.EditHost)
e.GET("/hosts", h.ListHosts)
e.GET("/logs", h.ShowLogs)
e.GET("/logs/host/:id", h.ShowHostLogs)
// Rest Routes
e.POST("/hosts/add", h.CreateHost)
e.POST("/hosts/edit/:id", h.UpdateHost)
e.GET("/hosts/delete/:id", h.DeleteHost)
e.GET("/update", h.UpdateIP)
// Start server
e.Logger.Fatal(e.Start(":8080"))
}

32
dyndns/model/host.go Normal file
View File

@ -0,0 +1,32 @@
package model
import (
"time"
"github.com/jinzhu/gorm"
)
type Host struct {
gorm.Model
Hostname string `gorm:"unique;not null" form:"hostname" validate:"required,hostname"`
Ip string `form:"ip" validate:"omitempty,ipv4"`
Ttl int `form:"ttl" validate:"required,min=20,max=86400"`
LastUpdate time.Time `form:"lastupdate"`
UserName string `gorm:"unique" form:"username" validate:"min=8"`
Password string `form:"password" validate:"min=8"`
}
func (h *Host) UpdateHost(updateHost *Host) (updateRecord bool) {
updateRecord = false
if h.Ip != updateHost.Ip || h.Ttl != updateHost.Ttl {
updateRecord = true
h.LastUpdate = time.Now()
}
h.Ip = updateHost.Ip
h.Ttl = updateHost.Ttl
h.UserName = updateHost.UserName
h.Password = updateHost.Password
return
}

18
dyndns/model/log.go Normal file
View File

@ -0,0 +1,18 @@
package model
import (
"time"
"github.com/jinzhu/gorm"
)
type Log struct {
gorm.Model
Status bool
Host Host
HostID uint
SentIP string
CallerIP string
TimeStamp time.Time
UserAgent string
}

3719
dyndns/static/css/bootstrap-grid.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

331
dyndns/static/css/bootstrap-reboot.css vendored Normal file
View File

@ -0,0 +1,331 @@
/*!
* Bootstrap Reboot v4.3.1 (https://getbootstrap.com/)
* Copyright 2011-2019 The Bootstrap Authors
* Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
display: block;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
[tabindex="-1"]:focus {
outline: 0 !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: .5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
a {
color: #007bff;
text-decoration: none;
background-color: transparent;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]):not([tabindex]) {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):focus {
outline: 0;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
}
figure {
margin: 0 0 1rem;
}
img {
vertical-align: middle;
border-style: none;
}
svg {
overflow: hidden;
vertical-align: middle;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #6c757d;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
button {
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
select {
word-wrap: normal;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button:not(:disabled),
[type="button"]:not(:disabled),
[type="reset"]:not(:disabled),
[type="submit"]:not(:disabled) {
cursor: pointer;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: .5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
cursor: pointer;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v4.3.1 (https://getbootstrap.com/)
* Copyright 2011-2019 The Bootstrap Authors
* Copyright 2011-2019 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

10038
dyndns/static/css/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
dyndns/static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,79 @@
/* Space out content a bit */
body {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
/* Everything but the jumbotron gets side spacing for mobile first views */
.header,
.marketing,
.footer {
padding-right: 1rem;
padding-left: 1rem;
}
/* Custom page header */
.header {
padding-bottom: 1rem;
border-bottom: .05rem solid #e5e5e5;
}
/* Make the masthead heading the same height as the navigation */
.header h3 {
margin-top: 0;
margin-bottom: 0;
line-height: 3rem;
}
/* Custom page footer */
.footer {
padding-top: 1.5rem;
color: #777;
border-top: .05rem solid #e5e5e5;
}
/* Customize container */
@media (min-width: 56em) {
.container {
max-width: 54rem;
}
}
.container-narrow > hr {
margin: 2rem 0;
}
/* Main marketing message and sign up button */
.jumbotron {
text-align: center;
border-bottom: .05rem solid #e5e5e5;
}
.jumbotron .btn {
padding: .75rem 1.5rem;
font-size: 1.5rem;
}
/* Supporting marketing content */
.marketing {
margin: 3rem 0;
}
.marketing p + h4 {
margin-top: 1.5rem;
}
/* Responsive: Portrait tablets and up */
@media screen and (min-width: 56em) {
/* Remove the padding we set earlier */
.header,
.marketing,
.footer {
padding-right: 0;
padding-left: 0;
}
/* Space out the masthead */
.header {
margin-bottom: 2rem;
}
/* Remove the bottom border on the jumbotron for visual effect */
.jumbotron {
border-bottom: 0;
}
}

View File

@ -0,0 +1,4 @@
<svg class="bi bi-clipboard" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M4 1.5H3a2 2 0 00-2 2V14a2 2 0 002 2h10a2 2 0 002-2V3.5a2 2 0 00-2-2h-1v1h1a1 1 0 011 1V14a1 1 0 01-1 1H3a1 1 0 01-1-1V3.5a1 1 0 011-1h1v-1z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M9.5 1h-3a.5.5 0 00-.5.5v1a.5.5 0 00.5.5h3a.5.5 0 00.5-.5v-1a.5.5 0 00-.5-.5zm-3-1A1.5 1.5 0 005 1.5v1A1.5 1.5 0 006.5 4h3A1.5 1.5 0 0011 2.5v-1A1.5 1.5 0 009.5 0h-3z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@ -0,0 +1,4 @@
<svg class="bi bi-pencil" width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M13.293 3.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM14 4l2 2-9 9-3 1 1-3 9-9z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M14.146 8.354l-2.5-2.5.708-.708 2.5 2.5-.708.708zM5 12v.5a.5.5 0 00.5.5H6v.5a.5.5 0 00.5.5H7v.5a.5.5 0 00.5.5H8v-1.5a.5.5 0 00-.5-.5H7v-.5a.5.5 0 00-.5-.5H5z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@ -0,0 +1,7 @@
<svg class="bi bi-table" width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M16 3H4a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1V4a1 1 0 00-1-1zM4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M17 6H3V5h14v1z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M7 17.5v-14h1v14H7zm5 0v-14h1v14h-1z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M17 10H3V9h14v1zm0 4H3v-1h14v1z" clip-rule="evenodd"/>
<path d="M2 4a2 2 0 012-2h12a2 2 0 012 2v2H2V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

View File

@ -0,0 +1,4 @@
<svg class="bi bi-trash" width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 7.5A.5.5 0 018 8v6a.5.5 0 01-1 0V8a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V8a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V8z"/>
<path fill-rule="evenodd" d="M16.5 5a1 1 0 01-1 1H15v9a2 2 0 01-2 2H7a2 2 0 01-2-2V6h-.5a1 1 0 01-1-1V4a1 1 0 011-1H8a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1v1zM6.118 6L6 6.059V15a1 1 0 001 1h6a1 1 0 001-1V6.059L13.882 6H6.118zM4.5 5V4h11v1h-11z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@ -0,0 +1,83 @@
function deleteHost(id) {
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
type: 'GET',
url: "/hosts/delete/" + id
}).done(function(data, textStatus, jqXHR) {
location.href="/hosts";
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
location.reload()
});
}
function addEditHost(id, addedit) {
if (id == null) {
id = ""
} else {
id = "/"+id
}
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
data: $('#edithostform').serialize(),
type: 'POST',
url: '/hosts/'+addedit+id,
}).done(function(data, textStatus, jqXHR) {
location.href="/hosts";
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
});
return false;
}
function logOut(){
try {
// This is for Firefox
$.ajax({
// This can be any path on your same domain which requires HTTPAuth
url: "",
username: 'reset',
password: 'reset',
// If the return is 401, refresh the page to request new details.
statusCode: { 401: function() {
document.location = document.location;
}
}
});
} catch (exception) {
// Firefox throws an exception since we didn't handle anything but a 401 above
// This line works only in IE
if (!document.execCommand("ClearAuthenticationCache")) {
// exeCommand returns false if it didn't work (which happens in Chrome) so as a last
// resort refresh the page providing new, invalid details.
document.location = "http://reset:reset@" + document.location.hostname + document.location.pathname;
}
}
}
function randomHash() {
var chars = "abcdefghijklmnopqrstuvwxyz!@#$%^&*()-+<>ABCDEFGHIJKLMNOP1234567890";
var pass = "";
for (var x = 0; x < 32; x++) {
var i = Math.floor(Math.random() * chars.length);
pass += chars.charAt(i);
}
return pass;
}
function generateUsername() {
edithostform.username.value = randomHash();
}
function generatePassword() {
edithostform.password.value = randomHash();
}
function copyToClipboard(inputId) {
var copyText = document.getElementById(inputId);
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
}

7013
dyndns/static/js/bootstrap.bundle.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4435
dyndns/static/js/bootstrap.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
dyndns/static/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
/*!
* IE10 viewport hack for Surface/desktop Windows 8 bug
* Copyright 2014-2017 The Bootstrap Authors
* Copyright 2014-2017 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
// See the Getting Started docs for more information:
// https://getbootstrap.com/getting-started/#support-ie10-width
(function () {
'use strict'
if (navigator.userAgent.match(/IEMobile\/10\.0/)) {
var msViewportStyle = document.createElement('style')
msViewportStyle.appendChild(
document.createTextNode(
'@-ms-viewport{width:auto!important}'
)
)
document.head.appendChild(msViewportStyle)
}
}())

2
dyndns/static/js/jquery-3.4.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,69 @@
{{define "content"}}
<div class="p-4" style="background-color: #e9ecef">
<h3 class="text-center mb-4">{{if eq .addEdit "edit" }}Edit{{else if eq .addEdit "add" }}Add{{end}} Host Entry</h3>
<form id="edithostform" action="javascript:void(0);">
<div class="row mt-3">
<div class="col-1"></div>
<div class="col-2 text-right">Hostname:</div>
<div class="col-8 input-group">
<input type="text" class="form-control" placeholder="Enter hostname" name="hostname" value="{{.host.Hostname}}" {{if eq .addEdit "edit" }}readonly{{end}}>
<div class="input-group-append">
<span class="input-group-text" id="basic-addon2">.{{.config.Domain}}</span>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row mt-3">
<div class="col-1"></div>
<div class="col-2 text-right">IP Address:</div>
<div class="col-8"><input type="text" class="form-control" placeholder="Enter IP Address" name="ip" value="{{.host.Ip}}"></div>
<div class="col-1"></div>
</div>
<div class="row mt-3">
<div class="col-1"></div>
<div class="col-2 text-right">TTL:</div>
<div class="col-8">
<select class="form-control" name="ttl">
<option value="20" {{if .host.Ttl }}{{if eq .host.Ttl 20 }}selected{{end}}{{end}}>20 s. Super dynamic DNS for frequent updates</option>
<option value="60" {{if .host.Ttl }}{{if eq .host.Ttl 60 }}selected{{end}}{{end}}>60 s. Default dynamic DNS value</option>
<option value="3600" {{if .host.Ttl }}{{if eq .host.Ttl 3600 }}selected{{end}}{{end}}>1 hr. Rarely updated IP address</option>
<option value="14400" {{if .host.Ttl }}{{if eq .host.Ttl 14400 }}selected{{end}}{{end}}>4 hrs. Static record with benefits of DNS caching</option>
</select>
</div>
<div class="col-1"></div>
</div>
<div class="row mt-3">
<div class="col-1"></div>
<div class="col-2 text-right">Username:</div>
<div class="col-8 input-group">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('username')"><img src="/static/icons/clipboard.svg" style="vertical-align: baseline" alt="" width="16" height="16" title="Copy"></button>
</div>
<input type="text" class="form-control" placeholder="Enter username" name="username" id="username" value="{{.host.UserName}}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" onclick="generateUsername()">Generate</button>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row mt-3">
<div class="col-1"></div>
<div class="col-2 text-right">Password:</div>
<div class="col-8 input-group">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('password')"><img src="/static/icons/clipboard.svg" style="vertical-align: baseline" alt="" width="16" height="16" title="Copy"></button>
</div>
<input type="text" class="form-control" placeholder="Enter password" name="password" id="password" value="{{.host.Password}}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" onclick="generatePassword()">Generate</button>
</div>
</div>
<div class="col-1"></div>
</div>
<div class="row mt-3">
<div class="col-11 d-flex justify-content-end"><button class="btn btn-primary" onclick="addEditHost({{.host.ID}}, {{.addEdit}})">{{if eq .addEdit "edit" }}Edit{{else if eq .addEdit "add" }}Add{{end}} Host Entry</button></div>
<div class="col-1"></div>
</div>
</form>
</div>
{{end}}

31
dyndns/views/index.html Normal file
View File

@ -0,0 +1,31 @@
{{define "content"}}
<div class="jumbotron">
<h1 class="display-3">Jumbotron heading</h1>
<p class="lead">Cras justo odio, dapibus ac facilisis in, egestas eget quam. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.</p>
<p><a class="btn btn-lg btn-success" href="#" role="button">Sign up today</a></p>
</div>
<div class="row marketing">
<div class="col-lg-6">
<h4>Subheading</h4>
<p>Donec id elit non mi porta gravida at eget metus. Maecenas faucibus mollis interdum.</p>
<h4>Subheading</h4>
<p>Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Cras mattis consectetur purus sit amet fermentum.</p>
<h4>Subheading</h4>
<p>Maecenas sed diam eget risus varius blandit sit amet non magna.</p>
</div>
<div class="col-lg-6">
<h4>Subheading</h4>
<p>Donec id elit non mi porta gravida at eget metus. Maecenas faucibus mollis interdum.</p>
<h4>Subheading</h4>
<p>Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Cras mattis consectetur purus sit amet fermentum.</p>
<h4>Subheading</h4>
<p>Maecenas sed diam eget risus varius blandit sit amet non magna.</p>
</div>
</div>
{{end}}

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<link rel="icon" href="/static/icons/favicon.ico">
<title>Narrow Jumbotron Template for Bootstrap</title>
<!-- Bootstrap core CSS -->
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<!-- Custom styles for this template -->
<link href="/static/css/narrow-jumbotron.css" rel="stylesheet">
</head>
<body>
<div class="container">
<!-- Navigation -->
<div class="header clearfix">
<nav>
<ul class="nav nav-pills float-right">
<li class="nav-item">
<a class="nav-link active" href="/hosts">Hosts <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logs">Logs</a>
</li>
<li class="nav-item">
<a class="nav-link" href="" onclick="logOut()">Logout</a>
</li>
</ul>
</nav>
<h3 class="text-muted">TheBBCloud DynDNS</h3>
</div>
<!-- Page Content -->
{{template "content" .}}
<footer class="footer">
<p>&copy; TheBBCloud 2020</p>
</footer>
</div> <!-- /container -->
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<script src="/static/js/ie10-viewport-bug-workaround.js"></script>
<script src="/static/js/jquery-3.4.1.min.js"></script>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/additional.js"></script>
</body>
</html>

View File

@ -0,0 +1,27 @@
{{define "content"}}
<div class="container marketing">
<h3 class="text-center mb-4">DNS Host Entries</h3>
<table class="table table-striped text-center">
<thead>
<tr>
<th>Hostname</th>
<th>IP</th>
<th>TTL</th>
<th>LastUpdate</th>
<th><button class="btn btn-primary" onclick="location.href='/hosts/add'">Add Host Entry</button></th>
</tr>
</thead>
<tbody>
{{range .hosts}}
<tr>
<td>{{.Hostname}}.{{$.config.Domain}}</td>
<td>{{.Ip}}</td>
<td>{{.Ttl}}</td>
<td>{{.LastUpdate.Format "01/02/2006 15:04 MEZ"}}</td>
<td><button onclick="location.href='/hosts/edit/{{.ID}}'" class="btn btn-outline-secondary btn-sm"><img src="/static/icons/pencil.svg" alt="" width="16" height="16" title="Edit"></button>&nbsp;<button class="btn btn-outline-secondary btn-sm" onclick="deleteHost('{{.ID}}')"><img src="/static/icons/trash.svg" alt="" width="16" height="16" title="Delete"></button> <button class="btn btn-outline-secondary btn-sm" onclick="location.href='/logs/host/{{.ID}}'"><img src="/static/icons/table.svg" alt="" width="16" height="16" title="Logs"></button></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

View File

@ -0,0 +1,29 @@
{{define "content"}}
<div class="container marketing">
<h3 class="text-center mb-4">Log Entries</h3>
<table class="table table-striped text-center" style="font-size: 14px">
<thead>
<tr>
<th>Status</th>
<th>Hostname</th>
<th>IP sent</th>
<th>Timestamp</th>
<th>User Agent</th>
<th>Caller IP</th>
</tr>
</thead>
<tbody>
{{range .logs}}
<tr>
<td class="align-middle mx-auto"><div class="{{if .Status}}bg-success{{else}}bg-danger{{end}}" style="width: 16px; height: 16px; margin: auto"></div></td>
<td>{{.Host.Hostname}}.{{$.config.Domain}}</td>
<td>{{.SentIP}}</td>
<td>{{.CreatedAt.Format "01/02/2006 15:04"}}</td>
<td>{{.UserAgent}}</td>
<td>{{.CallerIP}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}