Compare commits

...

6 commits

Author SHA1 Message Date
012ace2fc9 Use hash in session db key to reduce key size (required for oracle-nosql)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 10:19:33 +07:00
638bc1b4d0 Bump dnscontrol to 4.30.0 2026-01-12 10:19:33 +07:00
9657345cd6 Add PostgreSQL storage backend support 2026-01-12 10:19:33 +07:00
f7cc234407 Don't display NO FIELD in production mode 2026-01-12 10:19:33 +07:00
0794192679 Update and migrate GSuite to store records instead of generating them 2026-01-12 10:19:33 +07:00
2d39de5eb9 Add service initialization endpoint with DNS type detection
Implement backend-driven service initialization to replace hazardous
frontend initialization logic. Services can now provide custom
initialization via ServiceInitializer interface or get sensible
defaults automatically.
2026-01-12 10:19:33 +07:00
24 changed files with 1273 additions and 44 deletions

View file

@ -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

View file

@ -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"

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 {

View 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
}

View 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
}

View file

@ -37,6 +37,8 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{
migrateFrom5,
migrateFrom6,
migrateFrom7,
migrateFrom8,
migrateFrom9,
}
type Version struct {

View 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)
}

View 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,
}
}

View 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
}

View 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
}
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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,

View 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))
}

View file

@ -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);
}

View file

@ -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 = "";

View file

@ -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}

View file

@ -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}

View file

@ -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>