Add API compatible ddns

This commit is contained in:
nemunaire 2024-01-21 22:10:09 +01:00
parent cbb750f3b9
commit 77467383e7
3 changed files with 313 additions and 0 deletions

221
actions/ddns.go Normal file
View File

@ -0,0 +1,221 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"fmt"
"log"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v4/models"
"go.uber.org/multierr"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
"git.happydns.org/happyDomain/storage"
)
func DynamicUpdate(user *happydns.User, subdomains []string, ipv4, ipv6 string) error {
domains, err := storage.MainStore.GetDomains(user)
if err != nil {
log.Printf("An error occurs when trying to GetDomains from DynamicUpdate: %s", err.Error())
return fmt.Errorf("unable to retrive your domains.")
}
for _, hostname := range subdomains {
var possibleDomains []string
// Search domain name in account
for _, dn := range domains {
if strings.HasSuffix(hostname, dn.DomainName) {
possibleDomains = append(possibleDomains, dn.DomainName)
}
}
if len(possibleDomains) == 0 {
return fmt.Errorf("Unable to find any parent domain for %q in your account. Please check you already registered a parent domain.", hostname)
}
// If many possibleDomains, find the most precise one
if len(possibleDomains) > 1 {
var domainWithMaxLen int
var nbDomainWithMaxLen int = -1
for i, dn := range possibleDomains {
if len(possibleDomains[domainWithMaxLen]) == len(dn) {
nbDomainWithMaxLen += 1
} else if len(possibleDomains[domainWithMaxLen]) < len(dn) {
domainWithMaxLen = i
nbDomainWithMaxLen = 0
}
}
// There are multiple domain with maximal precision, abort
if nbDomainWithMaxLen > 1 {
var dnList []string
for _, dn := range possibleDomains {
if len(dn) == len(possibleDomains[domainWithMaxLen]) {
dnList = append(dnList, dn)
}
}
return fmt.Errorf("Multiple registered domains in your account match for updating your given hostname: " + strings.Join(dnList, ", ") + ". I don't know which one use.")
}
possibleDomains = []string{possibleDomains[domainWithMaxLen]}
}
// dnscontrol wants hostname without leading .
hostname = strings.TrimSuffix(hostname, ".")
// Retrieve the domain ID
var domainToUpdate *happydns.Domain
for _, dn := range domains {
if possibleDomains[0] == dn.DomainName {
domainToUpdate = dn
break
}
}
// Retrieve corresponding provider
provider, err := storage.MainStore.GetProvider(user, domainToUpdate.IdProvider)
if err != nil {
return fmt.Errorf("Unable to retrieve domain's provider: %w", err)
}
// Fetch the current zone
zone, err := provider.ImportZone(domainToUpdate)
if err != nil {
return fmt.Errorf("Unable to retrieve current zone: %w", err)
}
// Make the modification
var recordsToDrop []int
for i, record := range zone {
if record.GetLabelFQDN() == hostname {
if (ipv4 != "" && record.Type == "A") ||
(ipv6 != "" && record.Type == "AAAA") {
recordsToDrop = append(recordsToDrop, i)
}
}
}
for i := len(recordsToDrop) - 1; i >= 0; i-- {
zone = append(zone[0:recordsToDrop[i]], zone[recordsToDrop[i]+1:]...)
}
if ipv4 != "" {
record := &models.RecordConfig{Type: "A"}
record.SetLabelFromFQDN(hostname, strings.TrimSuffix(domainToUpdate.DomainName, "."))
record.SetTarget(ipv4)
zone = append(
zone,
record,
)
}
if ipv6 != "" {
record := &models.RecordConfig{Type: "AAAA"}
record.SetLabelFromFQDN(hostname, strings.TrimSuffix(domainToUpdate.DomainName, "."))
record.SetTarget(ipv6)
zone = append(
zone,
record,
)
}
// Push the new updated zone
dc := &models.DomainConfig{
Name: strings.TrimSuffix(domainToUpdate.DomainName, "."),
Records: zone,
}
corrections, err := provider.GetDomainCorrections(domainToUpdate, dc)
if err != nil {
return fmt.Errorf("Unable to compute domain corrections: %w", err)
}
var errs error
for i, cr := range corrections {
log.Printf("%s: apply ddns correction: %s", domainToUpdate.DomainName, cr.Msg)
err := cr.F()
if err != nil {
log.Printf("%s: unable to apply ddns correction: %s", domainToUpdate.DomainName, err.Error())
storage.MainStore.CreateDomainLog(domainToUpdate, happydns.NewDomainLog(user, happydns.LOG_ERR, fmt.Sprintf("DDNS API: Failed record update (%s): %s", cr.Msg, err.Error())))
errs = multierr.Append(errs, fmt.Errorf("%s: %w", cr.Msg, err))
// Stop the zone update if we didn't change it yet
if i == 0 {
break
}
}
}
if len(multierr.Errors(errs)) > 0 {
return errs
}
// Prepare the corresponding history item
services, defaultTTL, err := svcs.AnalyzeZone(domainToUpdate.DomainName, zone)
if err != nil {
return fmt.Errorf("Unable to perform the analysis of the new zone: %w", err)
}
now := time.Now()
commitmsg := fmt.Sprintf("API DDNS update: IPv4=%s IPv6=%s", ipv4, ipv6)
newZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
IdAuthor: domainToUpdate.IdUser,
DefaultTTL: defaultTTL,
LastModified: now,
CommitMsg: &commitmsg,
CommitDate: &now,
Published: &now,
},
Services: services,
}
// Save in history
err = storage.MainStore.CreateZone(newZone)
if err != nil {
return fmt.Errorf("unable to create the zone in history: %w", err)
}
storage.MainStore.CreateDomainLog(domainToUpdate, happydns.NewDomainLog(user, happydns.LOG_ACK, fmt.Sprintf("DDNS API: Zone published (%s), %d corrections applied with success", newZone.Id.String(), len(corrections))))
if len(domainToUpdate.ZoneHistory) > 0 {
domainToUpdate.ZoneHistory = append([]happydns.Identifier{domainToUpdate.ZoneHistory[0], newZone.Id}, domainToUpdate.ZoneHistory[1:]...)
} else {
domainToUpdate.ZoneHistory = []happydns.Identifier{newZone.Id}
}
err = storage.MainStore.UpdateDomain(domainToUpdate)
if err != nil {
return fmt.Errorf("unable to save the zone in history: %w", err)
}
}
return nil
}

88
api/ddns.go Normal file
View File

@ -0,0 +1,88 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/actions"
"git.happydns.org/happyDomain/config"
)
func declareApiCompatRoutes(cfg *config.Options, router *gin.RouterGroup) {
router.GET("/nic/update", noipUpdateRoute)
}
func noipUpdateRoute(c *gin.Context) {
user := myUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined"})
return
}
hostnames := strings.Split(c.Query("hostname"), ",")
if len(hostnames) == 0 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "hostname parameter is required"})
return
}
// Standardize hostnames
for i := range hostnames {
hostnames[i] = dns.CanonicalName(hostnames[i])
}
myips := strings.Split(c.Query("myip"), ",")
myipv6 := c.Query("myipv6")
if len(myips) > 2 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "myip should not contains more than 2 IP (1 ipv4 and 1 ipv6)"})
return
}
var myipv4 string
for _, ip := range myips {
if strings.Contains(ip, ":") && myipv6 == "" {
myipv6 = ip
} else {
myipv4 = ip
}
}
err := actions.DynamicUpdate(user, hostnames, myipv4, myipv6)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to update your domain: %s", err.Error())})
return
}
if offline := c.Query("offline"); offline != "" {
// This is just a warning, not error
c.AbortWithStatusJSON(http.StatusOK, gin.H{"errmsg": "Please note that offline parameter is not handled by happyDomain."})
return
}
c.Status(http.StatusOK)
}

View File

@ -48,6 +48,10 @@ import (
// @description Description for what is this security definition being used
func DeclareRoutes(cfg *config.Options, router *gin.Engine) {
authRoutes := router.Group("")
authRoutes.Use(authMiddleware(cfg, false))
declareApiCompatRoutes(cfg, authRoutes)
apiRoutes := router.Group("/api")
declareAuthenticationRoutes(cfg, apiRoutes)