Compare commits
6 commits
b9fa89af0e
...
012ace2fc9
| Author | SHA1 | Date | |
|---|---|---|---|
| 012ace2fc9 | |||
| 638bc1b4d0 | |||
| 9657345cd6 | |||
| f7cc234407 | |||
| 0794192679 | |||
| 2d39de5eb9 |
24 changed files with 1273 additions and 44 deletions
27
README.md
27
README.md
|
|
@ -85,7 +85,7 @@ The help command `./happyDomain -help` shows you the available engines:
|
|||
|
||||
```
|
||||
-storage-engine value
|
||||
Select the storage engine between [inmemory leveldb oracle-nosql] (default leveldb)
|
||||
Select the storage engine between [inmemory leveldb oracle-nosql postgresql] (default leveldb)
|
||||
```
|
||||
|
||||
#### LevelDB
|
||||
|
|
@ -104,6 +104,29 @@ You can change it to a more meaningful/persistant path.
|
|||
|
||||
Data are stored in memory and lost when service is stopped.
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
PostgreSQL support is provided primarily for installations that already have an existing PostgreSQL database infrastructure in place. This allows you to leverage your current database setup, backup procedures, and operational tooling without deploying additional database systems.
|
||||
|
||||
happyDomain uses PostgreSQL in a key-value storage mode, storing all data in a single table with `key` and `value` columns. While this works reliably, note that PostgreSQL is not the optimal choice for key-value workloads compared to dedicated key-value stores. If you're deploying from scratch and need scalability beyond LevelDB, consider using a storage backend specifically designed for key-value operations instead.
|
||||
|
||||
```
|
||||
-postgres-database string
|
||||
PostgreSQL database name (default "happydomain")
|
||||
-postgres-host string
|
||||
PostgreSQL server hostname (default "localhost")
|
||||
-postgres-password string
|
||||
PostgreSQL password
|
||||
-postgres-port int
|
||||
PostgreSQL server port (default 5432)
|
||||
-postgres-ssl-mode string
|
||||
PostgreSQL SSL mode (disable, require, verify-ca, verify-full) (default "disable")
|
||||
-postgres-table string
|
||||
PostgreSQL table name for key-value storage (default "happydomain_kv")
|
||||
-postgres-user string
|
||||
PostgreSQL username (default "happydomain")
|
||||
```
|
||||
|
||||
#### Oracle NoSQL Database
|
||||
|
||||
Oracle NoSQL Database is a fully managed cloud service from Oracle Cloud Infrastructure (OCI) that provides on-demand throughput and storage-based provisioning. happyDomain can use it as a scalable, cloud-based storage backend for production deployments.
|
||||
|
|
@ -131,7 +154,7 @@ Configure the following options to connect happyDomain to your Oracle NoSQL Data
|
|||
|
||||
#### DBMS
|
||||
|
||||
DBMS as Mysql/Mariadb or Postgres are no more supported or planned.
|
||||
DBMS as Mysql/Mariadb are no more supported or planned.
|
||||
|
||||
### Persistent configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import (
|
|||
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/postgresql"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
_ "git.happydns.org/happyDomain/services/abstract"
|
||||
_ "git.happydns.org/happyDomain/services/providers/google"
|
||||
|
|
|
|||
|
|
@ -24,4 +24,5 @@ package main
|
|||
//go:generate go run tools/gen_icon.go providers providers
|
||||
//go:generate go run tools/gen_icon.go services svcs
|
||||
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
|
||||
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
|
||||
//go:generate swag init --generalInfo internal/api/route/route.go
|
||||
|
|
|
|||
3
go.mod
3
go.mod
|
|
@ -15,6 +15,7 @@ require (
|
|||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.70
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oracle/nosql-go-sdk v1.4.7
|
||||
|
|
@ -235,7 +236,7 @@ require (
|
|||
moul.io/http2curl v1.0.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/StackExchange/dnscontrol/v4 => github.com/happyDomain/dnscontrol/v4 v4.29.100
|
||||
replace github.com/StackExchange/dnscontrol/v4 => github.com/happyDomain/dnscontrol/v4 v4.30.100
|
||||
|
||||
// https://github.com/kataras/iris/issues/2587
|
||||
replace github.com/kataras/golog v0.1.15 => github.com/kataras/golog v0.1.13
|
||||
|
|
|
|||
7
go.sum
7
go.sum
|
|
@ -305,8 +305,8 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
|
|||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/happyDomain/dnscontrol/v4 v4.29.100 h1:Om9PpHlCRMVuEFI8UxYssOzGOztkWUk4qJx8PQq4500=
|
||||
github.com/happyDomain/dnscontrol/v4 v4.29.100/go.mod h1:3B1uAEvrIwred1v0T+Id5wsIFQE7uNlLBVaN7kQWF9s=
|
||||
github.com/happyDomain/dnscontrol/v4 v4.30.100 h1:xQOABUKMyto/XdQpjjTYejnpeddhcEZ4C+vrIhJsqgw=
|
||||
github.com/happyDomain/dnscontrol/v4 v4.30.100/go.mod h1:eXy38+eOHGGKPKzQBKHd7aXXH6NEopnjX6vk+7uccTI=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
|
|
@ -383,6 +383,8 @@ github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1
|
|||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
|
||||
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
|
||||
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
||||
|
|
@ -674,6 +676,7 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
|
|||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
|
|
|
|||
|
|
@ -102,3 +102,28 @@ func (ssc *ServiceSpecsController) GetServiceSpec(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, specs)
|
||||
}
|
||||
|
||||
// InitializeServiceSpec returns an initialized service instance with default values.
|
||||
//
|
||||
// @Summary Initialize a new service instance.
|
||||
// @Schemes
|
||||
// @Description Return an initialized service instance with default or custom values.
|
||||
// @Tags service_specs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param serviceType path string true "The service's type"
|
||||
// @Success 200 {object} interface{}
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Service type does not exist"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal error"
|
||||
// @Router /service_specs/{serviceType}/init [post]
|
||||
func (ssc *ServiceSpecsController) InitializeServiceSpec(c *gin.Context) {
|
||||
svctype := c.MustGet("servicetype").(reflect.Type)
|
||||
|
||||
initialized, err := ssc.sSpecsServices.InitializeService(svctype)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, initialized)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,4 +40,5 @@ func DeclareServiceSpecsRoutes(router *gin.RouterGroup, dependancies happydns.Us
|
|||
apiServiceSpecsRoutes.Use(middleware.ServiceSpecsHandler)
|
||||
|
||||
apiServiceSpecsRoutes.GET("", ssc.GetServiceSpec)
|
||||
apiServiceSpecsRoutes.POST("/init", ssc.InitializeServiceSpec)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,21 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// sessionKey generates a hashed database key for a session ID
|
||||
func sessionKey(id string) string {
|
||||
hash := sha256.Sum256([]byte(id))
|
||||
encoded := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
return fmt.Sprintf("user.session-%s", encoded)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAllSessions() (happydns.Iterator[happydns.Session], error) {
|
||||
iter := s.db.Search("user.session-")
|
||||
return NewKVIterator[happydns.Session](s.db, iter), nil
|
||||
|
|
@ -43,7 +52,7 @@ func (s *KVStorage) getSession(id string) (*happydns.Session, error) {
|
|||
}
|
||||
|
||||
func (s *KVStorage) GetSession(id string) (session *happydns.Session, err error) {
|
||||
return s.getSession(fmt.Sprintf("user.session-%s", id))
|
||||
return s.getSession(sessionKey(id))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListAuthUserSessions(user *happydns.UserAuth) (sessions []*happydns.Session, err error) {
|
||||
|
|
@ -85,11 +94,11 @@ func (s *KVStorage) ListUserSessions(userid happydns.Identifier) (sessions []*ha
|
|||
}
|
||||
|
||||
func (s *KVStorage) UpdateSession(session *happydns.Session) error {
|
||||
return s.db.Put(fmt.Sprintf("user.session-%s", session.Id), session)
|
||||
return s.db.Put(sessionKey(session.Id), session)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteSession(id string) error {
|
||||
return s.db.Delete(fmt.Sprintf("user.session-%s", id))
|
||||
return s.db.Delete(sessionKey(id))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearSessions() error {
|
||||
|
|
|
|||
96
internal/storage/kvtpl/updates-from-8.go
Normal file
96
internal/storage/kvtpl/updates-from-8.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 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 database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/helpers"
|
||||
"git.happydns.org/happyDomain/services/providers/google"
|
||||
)
|
||||
|
||||
func migrateFrom8(s *KVStorage) (err error) {
|
||||
migrateFrom7SvcType = make(map[string]func(json.RawMessage) (json.RawMessage, error))
|
||||
|
||||
// google.GSuite
|
||||
migrateFrom7SvcType["google.GSuite"] = func(in json.RawMessage) (json.RawMessage, error) {
|
||||
val := map[string]interface{}{}
|
||||
|
||||
err := json.Unmarshal(in, &val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var gsuite google.GSuite
|
||||
gsuite.Initialize()
|
||||
|
||||
if code, ok := val["validationCode"]; ok {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("zZzZ. 0 IN MX 15 %s", code.(string)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rr != nil {
|
||||
gsuite.ValidationMX = helpers.RRRelative(rr, "zZzZ").(*dns.MX)
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(gsuite)
|
||||
}
|
||||
|
||||
zones, err := s.ListAllZones()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for zones.Next() {
|
||||
zone := zones.Item()
|
||||
for _, svcs := range zone.Services {
|
||||
changed := false
|
||||
|
||||
for i, svc := range svcs {
|
||||
if m, ok := migrateFrom7SvcType[svc.Type]; ok {
|
||||
svcs[i].Service, err = m(svc.Service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
// Save zone
|
||||
err = s.UpdateZoneMessage(zone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Migrated zone %s", zone.Id.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
48
internal/storage/kvtpl/updates-from-9.go
Normal file
48
internal/storage/kvtpl/updates-from-9.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 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 database
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
func migrateFrom9(s *KVStorage) (err error) {
|
||||
sessions, err := s.ListAllSessions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for sessions.Next() {
|
||||
session := sessions.Item()
|
||||
err := s.UpdateSession(session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Migrated session %s[...]", session.Id[:10])
|
||||
err = sessions.DropItem()
|
||||
if err != nil {
|
||||
log.Printf("Unable to delete original session %s[...]: %s", session.Id[:10], err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{
|
|||
migrateFrom5,
|
||||
migrateFrom6,
|
||||
migrateFrom7,
|
||||
migrateFrom8,
|
||||
migrateFrom9,
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
|
|
|
|||
62
internal/storage/postgresql/config.go
Normal file
62
internal/storage/postgresql/config.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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 database
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
|
||||
)
|
||||
|
||||
type PostgreSQLConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
Table string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
var cfg PostgreSQLConfig
|
||||
|
||||
func init() {
|
||||
storage.StorageEngines["postgresql"] = Instantiate
|
||||
|
||||
flag.StringVar(&cfg.Host, "postgres-host", "localhost", "PostgreSQL server hostname")
|
||||
flag.IntVar(&cfg.Port, "postgres-port", 5432, "PostgreSQL server port")
|
||||
flag.StringVar(&cfg.User, "postgres-user", "happydomain", "PostgreSQL username")
|
||||
flag.StringVar(&cfg.Password, "postgres-password", "", "PostgreSQL password")
|
||||
flag.StringVar(&cfg.Database, "postgres-database", "happydomain", "PostgreSQL database name")
|
||||
flag.StringVar(&cfg.Table, "postgres-table", "happydomain_kv", "PostgreSQL table name for key-value storage")
|
||||
flag.StringVar(&cfg.SSLMode, "postgres-ssl-mode", "disable", "PostgreSQL SSL mode (disable, require, verify-ca, verify-full)")
|
||||
}
|
||||
|
||||
func Instantiate() (storage.Storage, error) {
|
||||
db, err := NewPostgreSQLStorage(&cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv.NewKVDatabase(db)
|
||||
}
|
||||
233
internal/storage/postgresql/database.go
Normal file
233
internal/storage/postgresql/database.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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 database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type PostgreSQLStorage struct {
|
||||
db *sql.DB
|
||||
table string
|
||||
}
|
||||
|
||||
// NewPostgreSQLStorage establishes the connection to the PostgreSQL database
|
||||
func NewPostgreSQLStorage(cfg *PostgreSQLConfig) (s *PostgreSQLStorage, err error) {
|
||||
// Build connection string
|
||||
connStr := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Database, cfg.SSLMode,
|
||||
)
|
||||
|
||||
// Open database connection
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open PostgreSQL connection: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err = db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to ping PostgreSQL server: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
log.Printf("Connected to PostgreSQL database: %s@%s:%d/%s", cfg.User, cfg.Host, cfg.Port, cfg.Database)
|
||||
|
||||
s = &PostgreSQLStorage{
|
||||
db: db,
|
||||
table: cfg.Table,
|
||||
}
|
||||
|
||||
// Initialize database schema
|
||||
if err = s.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// initSchema creates the table and index if they don't exist
|
||||
func (s *PostgreSQLStorage) initSchema() error {
|
||||
// Create table with JSONB column
|
||||
createTableSQL := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
key TEXT PRIMARY KEY,
|
||||
data JSONB NOT NULL
|
||||
)
|
||||
`, s.table)
|
||||
|
||||
_, err := s.db.Exec(createTableSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create table: %w", err)
|
||||
}
|
||||
|
||||
// Create index for prefix searches
|
||||
createIndexSQL := fmt.Sprintf(`
|
||||
CREATE INDEX IF NOT EXISTS idx_%s_key_prefix
|
||||
ON %s (key text_pattern_ops)
|
||||
`, s.table, s.table)
|
||||
|
||||
_, err = s.db.Exec(createIndexSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("PostgreSQL schema initialized successfully (table: %s)", s.table)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) Close() error {
|
||||
if s.db != nil {
|
||||
log.Println("Closing PostgreSQL connection...")
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) DecodeData(data interface{}, v interface{}) error {
|
||||
var bytes []byte
|
||||
|
||||
switch d := data.(type) {
|
||||
case []byte:
|
||||
bytes = d
|
||||
case string:
|
||||
bytes = []byte(d)
|
||||
default:
|
||||
return fmt.Errorf("data to decode is not in []byte or string format (%T)", data)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, v)
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) Has(key string) (bool, error) {
|
||||
query := fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM %s WHERE key = $1)", s.table)
|
||||
|
||||
var exists bool
|
||||
err := s.db.QueryRow(query, key).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check key existence: %w", err)
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) Get(key string, v interface{}) error {
|
||||
query := fmt.Sprintf("SELECT data FROM %s WHERE key = $1", s.table)
|
||||
|
||||
var jsonData []byte
|
||||
err := s.db.QueryRow(query, key).Scan(&jsonData)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return happydns.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to get key %q: %w", key, err)
|
||||
}
|
||||
|
||||
return json.Unmarshal(jsonData, v)
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) Put(key string, v interface{}) error {
|
||||
// Marshal value to JSON
|
||||
jsonData, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal value: %w", err)
|
||||
}
|
||||
|
||||
// Use UPSERT to handle both insert and update
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (key, data)
|
||||
VALUES ($1, $2::jsonb)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET data = EXCLUDED.data
|
||||
`, s.table)
|
||||
|
||||
_, err = s.db.Exec(query, key, jsonData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to put key %q: %w", key, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) FindIdentifierKey(prefix string) (key string, id happydns.Identifier, err error) {
|
||||
found := true
|
||||
for found {
|
||||
id, err = happydns.NewRandomIdentifier()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
key = fmt.Sprintf("%s%s", prefix, id.String())
|
||||
|
||||
found, err = s.Has(key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) Delete(key string) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE key = $1", s.table)
|
||||
|
||||
_, err := s.db.Exec(query, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete key %q: %w", key, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgreSQLStorage) Search(prefix string) storage.Iterator {
|
||||
query := fmt.Sprintf("SELECT key, data FROM %s WHERE key LIKE $1 || '%%' ORDER BY key", s.table)
|
||||
|
||||
rows, err := s.db.Query(query, prefix)
|
||||
if err != nil {
|
||||
log.Printf("PostgreSQL Search error: %v", err)
|
||||
// Return an iterator with the error
|
||||
return &PostgreSQLIterator{
|
||||
rows: nil,
|
||||
err: err,
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
return &PostgreSQLIterator{
|
||||
rows: rows,
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
91
internal/storage/postgresql/iterator.go
Normal file
91
internal/storage/postgresql/iterator.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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 database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
)
|
||||
|
||||
// PostgreSQLIterator implements the storage.Iterator interface for PostgreSQL
|
||||
type PostgreSQLIterator struct {
|
||||
rows *sql.Rows
|
||||
key string
|
||||
value []byte
|
||||
valid bool
|
||||
err error
|
||||
}
|
||||
|
||||
// Release closes the underlying sql.Rows and releases resources
|
||||
func (it *PostgreSQLIterator) Release() {
|
||||
if it.rows != nil {
|
||||
it.rows.Close()
|
||||
it.rows = nil
|
||||
}
|
||||
it.valid = false
|
||||
}
|
||||
|
||||
// Next advances the iterator to the next row
|
||||
func (it *PostgreSQLIterator) Next() bool {
|
||||
// If there was a previous error or rows is nil, return false
|
||||
if it.err != nil || it.rows == nil {
|
||||
it.valid = false
|
||||
return false
|
||||
}
|
||||
|
||||
// Advance to next row
|
||||
if !it.rows.Next() {
|
||||
it.valid = false
|
||||
// Check for any error that occurred during iteration
|
||||
if err := it.rows.Err(); err != nil {
|
||||
it.err = err
|
||||
log.Printf("PostgreSQL iterator error: %v", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Scan the current row
|
||||
if err := it.rows.Scan(&it.key, &it.value); err != nil {
|
||||
it.err = err
|
||||
it.valid = false
|
||||
log.Printf("PostgreSQL iterator scan error: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
it.valid = true
|
||||
return true
|
||||
}
|
||||
|
||||
// Valid returns whether the iterator is at a valid position
|
||||
func (it *PostgreSQLIterator) Valid() bool {
|
||||
return it.valid && it.err == nil
|
||||
}
|
||||
|
||||
// Key returns the current key
|
||||
func (it *PostgreSQLIterator) Key() string {
|
||||
return it.key
|
||||
}
|
||||
|
||||
// Value returns the current value as []byte
|
||||
func (it *PostgreSQLIterator) Value() interface{} {
|
||||
return it.value
|
||||
}
|
||||
199
internal/usecase/service_specs_dns_types.go
Normal file
199
internal/usecase/service_specs_dns_types.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// This file was generated by tools/gen_dns_type_mapping.go
|
||||
// Last generation: Sun Jan 11 21:36:57 +07 2026
|
||||
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// getRRType maps a DNS record type to its corresponding RR type constant
|
||||
func (ssu *serviceSpecsUsecase) getRRType(t reflect.Type) uint16 {
|
||||
// Get the type name (e.g., "A", "AAAA", "MX", etc.)
|
||||
typeName := t.Name()
|
||||
|
||||
// Map type names to their RR type constants
|
||||
switch typeName {
|
||||
case "None":
|
||||
return dns.TypeNone
|
||||
case "A":
|
||||
return dns.TypeA
|
||||
case "NS":
|
||||
return dns.TypeNS
|
||||
case "MD":
|
||||
return dns.TypeMD
|
||||
case "MF":
|
||||
return dns.TypeMF
|
||||
case "CNAME":
|
||||
return dns.TypeCNAME
|
||||
case "SOA":
|
||||
return dns.TypeSOA
|
||||
case "MB":
|
||||
return dns.TypeMB
|
||||
case "MG":
|
||||
return dns.TypeMG
|
||||
case "MR":
|
||||
return dns.TypeMR
|
||||
case "NULL":
|
||||
return dns.TypeNULL
|
||||
case "PTR":
|
||||
return dns.TypePTR
|
||||
case "HINFO":
|
||||
return dns.TypeHINFO
|
||||
case "MINFO":
|
||||
return dns.TypeMINFO
|
||||
case "MX":
|
||||
return dns.TypeMX
|
||||
case "TXT":
|
||||
return dns.TypeTXT
|
||||
case "RP":
|
||||
return dns.TypeRP
|
||||
case "AFSDB":
|
||||
return dns.TypeAFSDB
|
||||
case "X25":
|
||||
return dns.TypeX25
|
||||
case "ISDN":
|
||||
return dns.TypeISDN
|
||||
case "RT":
|
||||
return dns.TypeRT
|
||||
case "NSAP-PTR":
|
||||
return dns.TypeNSAPPTR
|
||||
case "SIG":
|
||||
return dns.TypeSIG
|
||||
case "KEY":
|
||||
return dns.TypeKEY
|
||||
case "PX":
|
||||
return dns.TypePX
|
||||
case "GPOS":
|
||||
return dns.TypeGPOS
|
||||
case "AAAA":
|
||||
return dns.TypeAAAA
|
||||
case "LOC":
|
||||
return dns.TypeLOC
|
||||
case "NXT":
|
||||
return dns.TypeNXT
|
||||
case "EID":
|
||||
return dns.TypeEID
|
||||
case "NIMLOC":
|
||||
return dns.TypeNIMLOC
|
||||
case "SRV":
|
||||
return dns.TypeSRV
|
||||
case "ATMA":
|
||||
return dns.TypeATMA
|
||||
case "NAPTR":
|
||||
return dns.TypeNAPTR
|
||||
case "KX":
|
||||
return dns.TypeKX
|
||||
case "CERT":
|
||||
return dns.TypeCERT
|
||||
case "DNAME":
|
||||
return dns.TypeDNAME
|
||||
case "OPT":
|
||||
return dns.TypeOPT
|
||||
case "APL":
|
||||
return dns.TypeAPL
|
||||
case "DS":
|
||||
return dns.TypeDS
|
||||
case "SSHFP":
|
||||
return dns.TypeSSHFP
|
||||
case "IPSECKEY":
|
||||
return dns.TypeIPSECKEY
|
||||
case "RRSIG":
|
||||
return dns.TypeRRSIG
|
||||
case "NSEC":
|
||||
return dns.TypeNSEC
|
||||
case "DNSKEY":
|
||||
return dns.TypeDNSKEY
|
||||
case "DHCID":
|
||||
return dns.TypeDHCID
|
||||
case "NSEC3":
|
||||
return dns.TypeNSEC3
|
||||
case "NSEC3PARAM":
|
||||
return dns.TypeNSEC3PARAM
|
||||
case "TLSA":
|
||||
return dns.TypeTLSA
|
||||
case "SMIMEA":
|
||||
return dns.TypeSMIMEA
|
||||
case "HIP":
|
||||
return dns.TypeHIP
|
||||
case "NINFO":
|
||||
return dns.TypeNINFO
|
||||
case "RKEY":
|
||||
return dns.TypeRKEY
|
||||
case "TALINK":
|
||||
return dns.TypeTALINK
|
||||
case "CDS":
|
||||
return dns.TypeCDS
|
||||
case "CDNSKEY":
|
||||
return dns.TypeCDNSKEY
|
||||
case "OPENPGPKEY":
|
||||
return dns.TypeOPENPGPKEY
|
||||
case "CSYNC":
|
||||
return dns.TypeCSYNC
|
||||
case "ZONEMD":
|
||||
return dns.TypeZONEMD
|
||||
case "SVCB":
|
||||
return dns.TypeSVCB
|
||||
case "HTTPS":
|
||||
return dns.TypeHTTPS
|
||||
case "SPF":
|
||||
return dns.TypeSPF
|
||||
case "UINFO":
|
||||
return dns.TypeUINFO
|
||||
case "UID":
|
||||
return dns.TypeUID
|
||||
case "GID":
|
||||
return dns.TypeGID
|
||||
case "UNSPEC":
|
||||
return dns.TypeUNSPEC
|
||||
case "NID":
|
||||
return dns.TypeNID
|
||||
case "L32":
|
||||
return dns.TypeL32
|
||||
case "L64":
|
||||
return dns.TypeL64
|
||||
case "LP":
|
||||
return dns.TypeLP
|
||||
case "EUI48":
|
||||
return dns.TypeEUI48
|
||||
case "EUI64":
|
||||
return dns.TypeEUI64
|
||||
case "NXNAME":
|
||||
return dns.TypeNXNAME
|
||||
case "TKEY":
|
||||
return dns.TypeTKEY
|
||||
case "TSIG":
|
||||
return dns.TypeTSIG
|
||||
case "IXFR":
|
||||
return dns.TypeIXFR
|
||||
case "AXFR":
|
||||
return dns.TypeAXFR
|
||||
case "MAILB":
|
||||
return dns.TypeMAILB
|
||||
case "MAILA":
|
||||
return dns.TypeMAILA
|
||||
case "ANY":
|
||||
return dns.TypeANY
|
||||
case "URI":
|
||||
return dns.TypeURI
|
||||
case "CAA":
|
||||
return dns.TypeCAA
|
||||
case "AVC":
|
||||
return dns.TypeAVC
|
||||
case "AMTRELAY":
|
||||
return dns.TypeAMTRELAY
|
||||
case "RESINFO":
|
||||
return dns.TypeRESINFO
|
||||
case "TA":
|
||||
return dns.TypeTA
|
||||
case "DLV":
|
||||
return dns.TypeDLV
|
||||
case "Reserved":
|
||||
return dns.TypeReserved
|
||||
default:
|
||||
return dns.TypeNone
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/services"
|
||||
)
|
||||
|
|
@ -62,6 +64,119 @@ func (ssu *serviceSpecsUsecase) GetServiceSpecs(svctype reflect.Type) (*happydns
|
|||
return ssu.getSpecs(svctype)
|
||||
}
|
||||
|
||||
func (ssu *serviceSpecsUsecase) InitializeService(svctype reflect.Type) (interface{}, error) {
|
||||
// Create a new instance of the service
|
||||
svcPtr := reflect.New(svctype)
|
||||
svc := svcPtr.Interface()
|
||||
|
||||
// Check if the service implements ServiceInitializer interface
|
||||
if initializer, ok := svc.(happydns.ServiceInitializer); ok {
|
||||
return initializer.Initialize()
|
||||
}
|
||||
|
||||
// Otherwise, initialize with default empty values
|
||||
svcValue := svcPtr.Elem()
|
||||
ssu.initializeStructFields(svcValue)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (ssu *serviceSpecsUsecase) initializeStructFields(v reflect.Value) {
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := v.Type().Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle anonymous embedded structs
|
||||
if fieldType.Anonymous {
|
||||
if field.Kind() == reflect.Struct {
|
||||
ssu.initializeStructFields(field)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Initialize based on field type
|
||||
switch field.Kind() {
|
||||
case reflect.Slice:
|
||||
// Initialize slices as empty (non-nil)
|
||||
field.Set(reflect.MakeSlice(field.Type(), 0, 0))
|
||||
case reflect.Map:
|
||||
// Initialize maps as empty (non-nil)
|
||||
field.Set(reflect.MakeMap(field.Type()))
|
||||
case reflect.Ptr:
|
||||
// For pointer types, check if it's a DNS type first
|
||||
elemType := field.Type().Elem()
|
||||
if ssu.isDNSType(elemType) {
|
||||
newVal := reflect.New(elemType)
|
||||
ssu.initializeDNSRecord(newVal.Elem())
|
||||
field.Set(newVal)
|
||||
} else if elemType.Kind() == reflect.Struct {
|
||||
newVal := reflect.New(elemType)
|
||||
ssu.initializeStructFields(newVal.Elem())
|
||||
field.Set(newVal)
|
||||
}
|
||||
case reflect.Struct:
|
||||
// Check if it's a DNS type
|
||||
if ssu.isDNSType(field.Type()) {
|
||||
ssu.initializeDNSRecord(field)
|
||||
} else {
|
||||
// Recursively initialize nested structs
|
||||
ssu.initializeStructFields(field)
|
||||
}
|
||||
// Numeric types, strings, bools, etc. already have their zero values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isDNSType checks if a type is from the miekg/dns package or a happyDomain DNS abstraction
|
||||
func (ssu *serviceSpecsUsecase) isDNSType(t reflect.Type) bool {
|
||||
pkgPath := t.PkgPath()
|
||||
|
||||
// Check if it's from miekg/dns package
|
||||
if pkgPath == "github.com/miekg/dns" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's a happyDomain DNS abstraction (e.g., happydns.TXT, happydns.SPF)
|
||||
// These have a dns.RR_Header field named "Hdr"
|
||||
if pkgPath == "git.happydns.org/happyDomain/model" && t.Kind() == reflect.Struct {
|
||||
if field, ok := t.FieldByName("Hdr"); ok {
|
||||
return field.Type == reflect.TypeOf(dns.RR_Header{})
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// initializeDNSRecord initializes a DNS record with sensible defaults
|
||||
func (ssu *serviceSpecsUsecase) initializeDNSRecord(v reflect.Value) {
|
||||
if v.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the Rrtype based on the DNS record type name
|
||||
rrtype := ssu.getRRType(v.Type())
|
||||
|
||||
// Initialize the Hdr field if it exists
|
||||
hdrField := v.FieldByName("Hdr")
|
||||
if hdrField.IsValid() && hdrField.CanSet() {
|
||||
hdrField.Set(reflect.ValueOf(dns.RR_Header{
|
||||
Name: "",
|
||||
Rrtype: rrtype,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: 3600,
|
||||
Rdlength: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// Initialize other fields to their zero values (empty strings, 0 for numbers, etc.)
|
||||
// This is already done by Go's zero value initialization
|
||||
}
|
||||
|
||||
func (ssu *serviceSpecsUsecase) getSpecs(svcType reflect.Type) (*happydns.ServiceSpecs, error) {
|
||||
fields := []happydns.Field{}
|
||||
for i := 0; i < svcType.NumField(); i += 1 {
|
||||
|
|
|
|||
|
|
@ -91,8 +91,15 @@ type ServiceSpecs struct {
|
|||
Fields []Field `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceInitializer is an optional interface that services can implement
|
||||
// to provide custom initialization logic for creating new service instances.
|
||||
type ServiceInitializer interface {
|
||||
Initialize() (interface{}, error)
|
||||
}
|
||||
|
||||
type ServiceSpecsUsecase interface {
|
||||
ListServices() map[string]ServiceInfos
|
||||
GetServiceIcon(string) ([]byte, error)
|
||||
GetServiceSpecs(reflect.Type) (*ServiceSpecs, error)
|
||||
InitializeService(reflect.Type) (interface{}, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,18 +32,27 @@ import (
|
|||
)
|
||||
|
||||
type GSuite struct {
|
||||
ValidationCode string `json:"validationCode,omitempty" happydomain:"label=Validation Code,placeholder=abcdef0123.mx-verification.google.com.,description=The verification code will be displayed during the initial domain setup and will not be usefull after Google validation."`
|
||||
MX []*dns.MX `json:"mx,omitempty"`
|
||||
SPF *happydns.TXT `json:"txt"`
|
||||
ValidationMX *dns.MX `json:"validationMX,omitempty"`
|
||||
}
|
||||
|
||||
func (s *GSuite) GetNbResources() int {
|
||||
return 1
|
||||
nb := len(s.MX)
|
||||
if s.SPF != nil {
|
||||
nb += 1
|
||||
}
|
||||
if s.ValidationMX != nil {
|
||||
nb += 1
|
||||
}
|
||||
return nb
|
||||
}
|
||||
|
||||
func (s *GSuite) GenComment() string {
|
||||
return "5 MX + SPF directives"
|
||||
}
|
||||
|
||||
func (s *GSuite) GetRecords(domain string, ttl uint32, origin string) (rrs []happydns.Record, e error) {
|
||||
func (s *GSuite) Initialize() (interface{}, error) {
|
||||
for i, mx := range []string{
|
||||
"aspmx.l.google.com.",
|
||||
"alt1.aspmx.l.google.com.",
|
||||
|
|
@ -51,7 +60,7 @@ func (s *GSuite) GetRecords(domain string, ttl uint32, origin string) (rrs []hap
|
|||
"alt3.aspmx.l.google.com.",
|
||||
"alt4.aspmx.l.google.com.",
|
||||
} {
|
||||
rr := helpers.NewRecord(domain, "MX", ttl, origin)
|
||||
rr := helpers.NewRecord("", "MX", 0, "")
|
||||
rr.(*dns.MX).Mx = mx
|
||||
if i == 0 {
|
||||
rr.(*dns.MX).Preference = 1
|
||||
|
|
@ -61,19 +70,27 @@ func (s *GSuite) GetRecords(domain string, ttl uint32, origin string) (rrs []hap
|
|||
rr.(*dns.MX).Preference = 10
|
||||
}
|
||||
|
||||
rrs = append(rrs, rr)
|
||||
s.MX = append(s.MX, rr.(*dns.MX))
|
||||
}
|
||||
|
||||
if len(s.ValidationCode) > 0 {
|
||||
rr := helpers.NewRecord(domain, "MX", ttl, origin)
|
||||
rr.(*dns.MX).Mx = s.ValidationCode
|
||||
rr.(*dns.MX).Preference = 15
|
||||
rrs = append(rrs, rr)
|
||||
s.SPF = happydns.NewTXT(helpers.NewRecord("", "TXT", 0, "").(*dns.TXT))
|
||||
s.SPF.Txt = "v=spf1 include:_spf.google.com ~all"
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *GSuite) GetRecords(domain string, ttl uint32, origin string) (rrs []happydns.Record, e error) {
|
||||
for _, mx := range s.MX {
|
||||
rrs = append(rrs, mx)
|
||||
}
|
||||
|
||||
rr := happydns.NewTXT(helpers.NewRecord(domain, "TXT", ttl, origin).(*dns.TXT))
|
||||
rr.Txt = "v=spf1 include:_spf.google.com ~all"
|
||||
rrs = append(rrs, rr)
|
||||
if s.SPF != nil {
|
||||
rrs = append(rrs, s.SPF)
|
||||
}
|
||||
|
||||
if s.ValidationMX != nil {
|
||||
rrs = append(rrs, s.ValidationMX)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -97,7 +114,7 @@ func gsuite_analyze(a *svcs.Analyzer) (err error) {
|
|||
for _, record := range a.SearchRR(svcs.AnalyzerRecordFilter{Type: dns.TypeMX, Domain: dn}) {
|
||||
if mx, ok := record.(*dns.MX); ok {
|
||||
if strings.HasSuffix(mx.Mx, "mx-verification.google.com.") {
|
||||
googlerr.ValidationCode = mx.Mx
|
||||
googlerr.ValidationMX = mx
|
||||
if err = a.UseRR(
|
||||
record,
|
||||
dn,
|
||||
|
|
@ -106,6 +123,7 @@ func gsuite_analyze(a *svcs.Analyzer) (err error) {
|
|||
return
|
||||
}
|
||||
} else if strings.HasSuffix(mx.Mx, "google.com.") {
|
||||
googlerr.MX = append(googlerr.MX, mx)
|
||||
if err = a.UseRR(
|
||||
record,
|
||||
dn,
|
||||
|
|
@ -121,6 +139,7 @@ func gsuite_analyze(a *svcs.Analyzer) (err error) {
|
|||
if txt, ok := record.(*happydns.TXT); ok {
|
||||
content := txt.Txt
|
||||
if strings.HasPrefix(content, "v=spf1") && strings.Contains(content, "_spf.google.com") {
|
||||
googlerr.SPF = txt
|
||||
if err = a.UseRR(
|
||||
record,
|
||||
dn,
|
||||
|
|
|
|||
133
tools/gen_dns_type_mapping.go
Normal file
133
tools/gen_dns_type_mapping.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 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/>.
|
||||
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
type DNSType struct {
|
||||
Name string
|
||||
Constant string
|
||||
}
|
||||
|
||||
// getSortedTypes returns DNS types sorted by their numeric value for reproducible output
|
||||
func getSortedTypes() []uint16 {
|
||||
types := make([]uint16, 0, len(dns.TypeToString))
|
||||
for ty := range dns.TypeToString {
|
||||
types = append(types, ty)
|
||||
}
|
||||
sort.Slice(types, func(i, j int) bool {
|
||||
return types[i] < types[j]
|
||||
})
|
||||
return types
|
||||
}
|
||||
|
||||
func main() {
|
||||
output := flag.String("o", "", "output file path")
|
||||
flag.Parse()
|
||||
|
||||
if *output == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: output file path is required\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s -o <output-file>\n", os.Args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fd, err := os.Create(*output)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
// Collect DNS types
|
||||
var dnsTypes []DNSType
|
||||
for _, ty := range getSortedTypes() {
|
||||
typeName := dns.TypeToString[ty]
|
||||
// Remove hyphens from constant names (e.g., "NSAP-PTR" -> "TypeNSAPPTR")
|
||||
constantName := fmt.Sprintf("Type%s", strings.ReplaceAll(typeName, "-", ""))
|
||||
|
||||
dnsTypes = append(dnsTypes, DNSType{
|
||||
Name: typeName,
|
||||
Constant: constantName,
|
||||
})
|
||||
}
|
||||
|
||||
// For reproducible builds, use SOURCE_DATE_EPOCH if set
|
||||
timestamp := time.Now()
|
||||
if epoch := os.Getenv("SOURCE_DATE_EPOCH"); epoch != "" {
|
||||
timestamp = time.Unix(0, 0)
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("dns_types").Parse(`// Code generated by go generate; DO NOT EDIT.
|
||||
// This file was generated by tools/gen_dns_type_mapping.go
|
||||
// Last generation: {{.Timestamp}}
|
||||
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// getRRType maps a DNS record type to its corresponding RR type constant
|
||||
func (ssu *serviceSpecsUsecase) getRRType(t reflect.Type) uint16 {
|
||||
// Get the type name (e.g., "A", "AAAA", "MX", etc.)
|
||||
typeName := t.Name()
|
||||
|
||||
// Map type names to their RR type constants
|
||||
switch typeName {
|
||||
{{- range .Types}}
|
||||
case "{{.Name}}":
|
||||
return dns.{{.Constant}}
|
||||
{{- end}}
|
||||
default:
|
||||
return dns.TypeNone
|
||||
}
|
||||
}
|
||||
`))
|
||||
|
||||
data := struct {
|
||||
Timestamp string
|
||||
Types []DNSType
|
||||
}{
|
||||
Timestamp: timestamp.Format(time.UnixDate),
|
||||
Types: dnsTypes,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(fd, data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated %s with %d DNS types\n", *output, len(dnsTypes))
|
||||
}
|
||||
|
|
@ -41,3 +41,11 @@ export async function getServiceSpec(ssid: string): Promise<ServiceSpec> {
|
|||
return await handleApiResponse<ServiceSpec>(res);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeService(ssid: string): Promise<any> {
|
||||
const res = await fetch(`/api/service_specs/${ssid}/init`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
return await handleApiResponse(res);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
import { Input, Modal, ModalBody } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { getServiceSpec } from "$lib/api/service_specs";
|
||||
import { initializeService } from "$lib/api/service_specs";
|
||||
import { getRrtype, newRR } from "$lib/dns_rr";
|
||||
import ModalFooter from "$lib/components/modals/Footer.svelte";
|
||||
import ModalHeader from "$lib/components/modals/Header.svelte";
|
||||
|
|
@ -41,7 +41,6 @@
|
|||
import { fqdn } from "$lib/dns";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { ServiceCombined } from "$lib/model/service.svelte";
|
||||
import { newRecord } from "$lib/model/service_specs.svelte";
|
||||
import { filteredName } from "$lib/stores/serviceSelector";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
|
@ -68,25 +67,7 @@
|
|||
|
||||
if (value !== null) {
|
||||
toggle();
|
||||
getServiceSpec(value).then((specs) => {
|
||||
const svc: Record<string, any> = { };
|
||||
|
||||
if (specs.fields) {
|
||||
for (const field of specs.fields) {
|
||||
if (field.type.replace(/^(\[\])?\*(happy)?/, "").startsWith("dns")) {
|
||||
svc[field.id] = newRecord(field);
|
||||
} else if (field.type.indexOf("int") >= 0) {
|
||||
svc[field.id] = 0;
|
||||
} else {
|
||||
svc[field.id] = "";
|
||||
}
|
||||
|
||||
if (field.type.startsWith("[]")) {
|
||||
svc[field.id] = [svc[field.id]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeService(value).then((svc) => {
|
||||
dispatch("show-next-modal", { _svctype: value, _domain: dn, Service: svc });
|
||||
});
|
||||
$filteredName = "";
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@
|
|||
import SVCField from './ServiceField.svelte'
|
||||
import type { ServiceSpec } from "$lib/model/service_specs.svelte";
|
||||
|
||||
const { MODE } = import.meta.env;
|
||||
|
||||
interface Props {
|
||||
specs: ServiceSpec;
|
||||
value: any;
|
||||
|
|
@ -44,6 +46,6 @@
|
|||
bind:value={value[field.id]}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
{:else if MODE != "production"}
|
||||
NO FIELD
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
import TLSAsEditor from '$lib/components/services/editors/svcs.TLSAs.svelte'
|
||||
import TLS_RPTEditor from '$lib/components/services/editors/svcs.TLS_RPT.svelte'
|
||||
import UnknownSRVEditor from '$lib/components/services/editors/svcs.UnknownSRV.svelte'
|
||||
import GSuiteEditor from '$lib/components/services/editors/providers.google.GSuite.svelte'
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -119,6 +120,8 @@
|
|||
<TLS_RPTEditor {dn} {origin} bind:value={value} />
|
||||
{:else if type == "svcs.UnknownSRV"}
|
||||
<UnknownSRVEditor {dn} {origin} bind:value={value} />
|
||||
{:else if type == "google.GSuite"}
|
||||
<GSuiteEditor {dn} {origin} bind:value={value} />
|
||||
{:else}
|
||||
<OrphanEditor
|
||||
{dn}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2020-2026 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/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import RecordLine from "$lib/components/services/editors/RecordLine.svelte";
|
||||
import TableRecords from "$lib/components/records/TableRecords.svelte";
|
||||
import RawInput from "$lib/components/inputs/raw.svelte";
|
||||
import BasicInput from "$lib/components/inputs/basic.svelte";
|
||||
import type { dnsResource, dnsTypeMX, dnsTypeTXT } from "$lib/dns_rr";
|
||||
import { getRrtype, newRR } from "$lib/dns_rr";
|
||||
import { servicesSpecs } from "$lib/stores/services";
|
||||
|
||||
interface Props {
|
||||
dn: string;
|
||||
origin: Domain;
|
||||
readonly?: boolean;
|
||||
value: dnsResource & { validationMX?: dnsTypeMX; };
|
||||
}
|
||||
|
||||
let { dn, origin, readonly = false, value = $bindable({}) }: Props = $props();
|
||||
const type = "google.GSuite";
|
||||
|
||||
// Ensure mx is always an array at runtime
|
||||
$effect(() => {
|
||||
if (value["mx"] && !Array.isArray(value["mx"])) {
|
||||
value["mx"] = [value["mx"]];
|
||||
}
|
||||
});
|
||||
|
||||
// Extract validation code from ValidationMX record
|
||||
let validationCode = $state(
|
||||
value["validationMX"]?.Mx?.replace(/\.mx-verification\.google\.com\.?$/, "") || ""
|
||||
);
|
||||
|
||||
// Sync validation code to ValidationMX record
|
||||
$effect(() => {
|
||||
if (validationCode && validationCode.trim() !== "") {
|
||||
// Create ValidationMX record if it doesn't exist
|
||||
if (!value["validationMX"]) {
|
||||
value["validationMX"] = newRR(dn, getRrtype("MX")) as dnsTypeMX;
|
||||
value["validationMX"].Preference = 15;
|
||||
}
|
||||
// Update the Mx field with proper formatting
|
||||
const cleanCode = validationCode.trim().replace(/\.mx-verification\.google\.com\.?$/, "");
|
||||
value["validationMX"].Mx = cleanCode + ".mx-verification.google.com.";
|
||||
} else if (value["validationMX"] && (!validationCode || validationCode.trim() === "")) {
|
||||
// Remove ValidationMX if validation code is empty
|
||||
delete value["validationMX"];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $servicesSpecs && $servicesSpecs[type]}
|
||||
<p class="text-muted">
|
||||
{$servicesSpecs[type].description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>G Suite / Google Workspace Configuration</strong>
|
||||
<p class="mb-0">
|
||||
This service configures MX records for Google's mail servers and SPF directives.
|
||||
The validation MX record is optional and only needed during initial domain setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation MX Record -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-primary pb-1 border-bottom border-1">Validation MX Record (Optional)</h4>
|
||||
<p class="text-muted small">
|
||||
This verification record is only needed during initial Google domain setup and can be removed after verification.
|
||||
</p>
|
||||
<BasicInput
|
||||
class="mt-3"
|
||||
edit={!readonly}
|
||||
index="validation-code"
|
||||
specs={{
|
||||
id: "validation-code",
|
||||
label: "Validation Code",
|
||||
description: "Enter the verification code from Google (e.g., abcdef0123)",
|
||||
type: "string",
|
||||
placeholder: "abcdef0123",
|
||||
}}
|
||||
bind:value={validationCode}
|
||||
/>
|
||||
{#if value["validationMX"]}
|
||||
<div class="mt-3">
|
||||
<RecordLine {dn} {origin} bind:rr={value["validationMX"]!} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- MX Records -->
|
||||
{#if value["mx"]}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-primary pb-1 border-bottom border-1">Google MX Records</h4>
|
||||
<TableRecords
|
||||
class="mt-3"
|
||||
{dn}
|
||||
edit={!readonly}
|
||||
{origin}
|
||||
rrs={value["mx"] as dnsTypeMX[]}
|
||||
rrtype="MX"
|
||||
>
|
||||
{#snippet header(field: string)}
|
||||
{#if field == "Mx"}
|
||||
Mail Server
|
||||
{:else if field == "Preference"}
|
||||
Priority
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet field(idx: number, field: string)}
|
||||
{#if value["mx"] && (value["mx"] as dnsTypeMX[])[idx]}
|
||||
{#if field == "Preference"}
|
||||
<RawInput
|
||||
edit={!readonly}
|
||||
index={field + idx.toString()}
|
||||
specs={{
|
||||
id: "preference",
|
||||
type: "uint",
|
||||
}}
|
||||
bind:value={(value["mx"] as dnsTypeMX[])[idx].Preference}
|
||||
/>
|
||||
{:else if field == "Mx"}
|
||||
<RawInput
|
||||
edit={!readonly}
|
||||
index={field + idx.toString()}
|
||||
bind:value={(value["mx"] as dnsTypeMX[])[idx].Mx}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TableRecords>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SPF TXT Record -->
|
||||
{#if value["txt"]}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-primary pb-1 border-bottom border-1">SPF Record</h4>
|
||||
<RecordLine {dn} {origin} bind:rr={value["txt"]!} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue