Initial commit
This commit is contained in:
commit
b259d9ef18
17 changed files with 809 additions and 0 deletions
46
checker/abstract.go
Normal file
46
checker/abstract.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Service type identifiers as exposed by happyDomain core.
|
||||
const (
|
||||
serviceTypeOrigin = "abstract.Origin"
|
||||
serviceTypeNSOnlyOrigin = "abstract.NSOnlyOrigin"
|
||||
)
|
||||
|
||||
// originPayload is a minimal local copy of services/abstract.Origin keeping
|
||||
// only the field this checker reads. The JSON tag matches the upstream wire
|
||||
// format ("ns").
|
||||
type originPayload struct {
|
||||
NameServers []*dns.NS `json:"ns"`
|
||||
}
|
||||
|
||||
// nsOnlyOriginPayload is a minimal local copy of
|
||||
// services/abstract.NSOnlyOrigin keeping only the field this checker reads.
|
||||
type nsOnlyOriginPayload struct {
|
||||
NameServers []*dns.NS `json:"ns"`
|
||||
}
|
||||
|
||||
// nsFromService extracts the list of NS records from an Origin or
|
||||
// NSOnlyOrigin service payload.
|
||||
func nsFromService(svc *serviceMessage) []*dns.NS {
|
||||
switch svc.Type {
|
||||
case serviceTypeOrigin:
|
||||
var o originPayload
|
||||
if err := json.Unmarshal(svc.Service, &o); err != nil {
|
||||
return nil
|
||||
}
|
||||
return o.NameServers
|
||||
case serviceTypeNSOnlyOrigin:
|
||||
var o nsOnlyOriginPayload
|
||||
if err := json.Unmarshal(svc.Service, &o); err != nil {
|
||||
return nil
|
||||
}
|
||||
return o.NameServers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
164
checker/checks.go
Normal file
164
checker/checks.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// checkAXFR returns (ok bool, detail string).
|
||||
// ok=false means the server accepted the zone transfer (CRITICAL).
|
||||
func checkAXFR(ctx context.Context, domain, addr string) (bool, string) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetAxfr(dns.Fqdn(domain))
|
||||
|
||||
t := &dns.Transfer{}
|
||||
t.DialTimeout = 5 * time.Second
|
||||
t.ReadTimeout = 10 * time.Second
|
||||
|
||||
ch, err := t.In(msg, net.JoinHostPort(addr, "53"))
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("transfer refused: %s", err)
|
||||
}
|
||||
|
||||
for env := range ch {
|
||||
if env.Error != nil {
|
||||
return true, fmt.Sprintf("transfer error: %s", env.Error)
|
||||
}
|
||||
for _, rr := range env.RR {
|
||||
if rr.Header().Rrtype == dns.TypeSOA {
|
||||
return false, "AXFR zone transfer accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, "AXFR refused"
|
||||
}
|
||||
|
||||
// checkIXFR returns (ok bool, detail string).
|
||||
// ok=false means the server answered with records (WARN).
|
||||
func checkIXFR(ctx context.Context, domain, addr string) (bool, string) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetIxfr(dns.Fqdn(domain), 0, "", "")
|
||||
|
||||
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("query failed: %s", err)
|
||||
}
|
||||
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return true, fmt.Sprintf("IXFR refused (rcode=%s)", dns.RcodeToString[resp.Rcode])
|
||||
}
|
||||
if len(resp.Answer) > 0 {
|
||||
return false, fmt.Sprintf("IXFR accepted with %d answer(s)", len(resp.Answer))
|
||||
}
|
||||
|
||||
return true, "IXFR refused or empty"
|
||||
}
|
||||
|
||||
// checkNoRecursion returns (ok bool, detail string).
|
||||
// ok=false means the server offers recursion (WARN).
|
||||
func checkNoRecursion(ctx context.Context, domain, addr string) (bool, string) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA)
|
||||
msg.RecursionDesired = true
|
||||
|
||||
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("query failed: %s", err)
|
||||
}
|
||||
|
||||
if resp.RecursionAvailable {
|
||||
return false, "recursion available (RA bit set)"
|
||||
}
|
||||
return true, "recursion not available"
|
||||
}
|
||||
|
||||
// checkANYHandled returns (ok bool, detail string).
|
||||
// ok=false means the server returned a full record set for ANY (WARN).
|
||||
// Per RFC 8482, servers should return HINFO or a minimal response.
|
||||
func checkANYHandled(ctx context.Context, domain, addr string) (bool, string) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeANY)
|
||||
|
||||
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("query failed: %s", err)
|
||||
}
|
||||
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return true, fmt.Sprintf("ANY refused (rcode=%s)", dns.RcodeToString[resp.Rcode])
|
||||
}
|
||||
|
||||
if len(resp.Answer) == 1 {
|
||||
if _, ok := resp.Answer[0].(*dns.HINFO); ok {
|
||||
return true, "RFC 8482 compliant HINFO response"
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Answer) == 0 {
|
||||
return true, "ANY returned empty answer"
|
||||
}
|
||||
|
||||
return false, fmt.Sprintf("ANY returned %d records (not RFC 8482 compliant)", len(resp.Answer))
|
||||
}
|
||||
|
||||
// checkIsAuthoritative returns (ok bool, detail string).
|
||||
// ok=false means the server is not authoritative for the zone (INFO).
|
||||
func checkIsAuthoritative(ctx context.Context, domain, addr string) (bool, string) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA)
|
||||
|
||||
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("query failed: %s", err)
|
||||
}
|
||||
|
||||
if resp.Authoritative {
|
||||
return true, "server is authoritative (AA bit set)"
|
||||
}
|
||||
return false, "server is not authoritative (AA bit not set)"
|
||||
}
|
||||
|
||||
// Stable check names. They are part of the JSON wire format of
|
||||
// NSRestrictionsReport and used by individual rules to look up their
|
||||
// corresponding entry, so they MUST NOT change without coordinating with
|
||||
// the rule definitions.
|
||||
const (
|
||||
checkNameAXFR = "AXFR refused"
|
||||
checkNameIXFR = "IXFR refused"
|
||||
checkNameNoRecursion = "No recursion"
|
||||
checkNameANYHandled = "ANY handled (RFC 8482)"
|
||||
checkNameIsAuthoritative = "Is authoritative"
|
||||
)
|
||||
|
||||
// checkServerAddr runs all NS security checks against a single IP address.
|
||||
func checkServerAddr(ctx context.Context, domain, nsHost, addr string) NSServerResult {
|
||||
result := NSServerResult{Name: nsHost, Address: addr}
|
||||
|
||||
type checkDef struct {
|
||||
name string
|
||||
fn func(context.Context, string, string) (bool, string)
|
||||
}
|
||||
checks := []checkDef{
|
||||
{checkNameAXFR, checkAXFR},
|
||||
{checkNameIXFR, checkIXFR},
|
||||
{checkNameNoRecursion, checkNoRecursion},
|
||||
{checkNameANYHandled, checkANYHandled},
|
||||
{checkNameIsAuthoritative, checkIsAuthoritative},
|
||||
}
|
||||
|
||||
for _, ch := range checks {
|
||||
ok, detail := ch.fn(ctx, domain, addr)
|
||||
result.Checks = append(result.Checks, NSCheckItem{Name: ch.name, OK: ok, Detail: detail})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
117
checker/collect.go
Normal file
117
checker/collect.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Collect performs the NS security restriction checks for the configured
|
||||
// service and returns an NSRestrictionsReport.
|
||||
func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
svc, err := serviceFromOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if svc.Type != serviceTypeOrigin && svc.Type != serviceTypeNSOnlyOrigin {
|
||||
return nil, fmt.Errorf("service is %s, expected %s or %s", svc.Type, serviceTypeOrigin, serviceTypeNSOnlyOrigin)
|
||||
}
|
||||
|
||||
domainName := ""
|
||||
if v, ok := opts["domainName"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
domainName = s
|
||||
}
|
||||
}
|
||||
if domainName == "" {
|
||||
domainName = svc.Domain
|
||||
}
|
||||
if domainName == "" {
|
||||
return nil, fmt.Errorf("domain name not provided and not present in service")
|
||||
}
|
||||
|
||||
nameServers := nsFromService(svc)
|
||||
if len(nameServers) == 0 {
|
||||
return nil, fmt.Errorf("no nameservers found in service")
|
||||
}
|
||||
|
||||
report := &NSRestrictionsReport{}
|
||||
for _, ns := range nameServers {
|
||||
nsHost := strings.TrimSuffix(ns.Ns, ".")
|
||||
results := checkNameServer(ctx, domainName, nsHost)
|
||||
report.Servers = append(report.Servers, results...)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// serviceFromOptions extracts a *serviceMessage from the options. It accepts
|
||||
// either a direct value (in-process plugin path) or a JSON-decoded
|
||||
// map[string]any (HTTP path) — both are normalized via a JSON round-trip.
|
||||
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
|
||||
v, ok := opts["service"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("service not defined")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal service option: %w", err)
|
||||
}
|
||||
|
||||
var svc serviceMessage
|
||||
if err := json.Unmarshal(raw, &svc); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode service option: %w", err)
|
||||
}
|
||||
return &svc, nil
|
||||
}
|
||||
|
||||
// checkNameServer resolves nsHost and runs checks on each address.
|
||||
func checkNameServer(ctx context.Context, domain, nsHost string) []NSServerResult {
|
||||
addrs, err := net.LookupHost(nsHost)
|
||||
if err != nil {
|
||||
return []NSServerResult{{
|
||||
Name: nsHost,
|
||||
Address: "",
|
||||
Checks: []NSCheckItem{{
|
||||
Name: "DNS resolution",
|
||||
OK: false,
|
||||
Detail: fmt.Sprintf("lookup failed: %s", err),
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
var results []NSServerResult
|
||||
for _, addr := range addrs {
|
||||
// Skip IPv6 addresses when there is no IPv6 connectivity.
|
||||
if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil {
|
||||
conn, err := net.DialTimeout("udp", net.JoinHostPort(addr, "53"), 3*time.Second)
|
||||
if errors.Is(err, syscall.ENETUNREACH) {
|
||||
results = append(results, NSServerResult{
|
||||
Name: nsHost,
|
||||
Address: addr,
|
||||
Checks: []NSCheckItem{{
|
||||
Name: "IPv6 connectivity",
|
||||
OK: true,
|
||||
Detail: "unable to test due to the lack of IPv6 connectivity",
|
||||
}},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, checkServerAddr(ctx, domain, nsHost, addr))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
51
checker/definition.go
Normal file
51
checker/definition.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the checker version reported in CheckerDefinition.Version.
|
||||
//
|
||||
// It defaults to "built-in", which is appropriate when the checker package is
|
||||
// imported directly. Standalone binaries and plugin entrypoints override this
|
||||
// from their own Version variable at the start of main(), which makes it easy
|
||||
// for CI to inject a version with a single -ldflags "-X main.Version=..."
|
||||
// flag instead of targeting the nested package path.
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the NS security restrictions
|
||||
// checker.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "ns_restrictions",
|
||||
Name: "NS Security Restrictions",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{serviceTypeOrigin, serviceTypeNSOnlyOrigin},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyNSRestrictions},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
ServiceOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "service",
|
||||
Label: "Service",
|
||||
AutoFill: sdk.AutoFillService,
|
||||
},
|
||||
{
|
||||
Id: "domainName",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
21
checker/provider.go
Normal file
21
checker/provider.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns a new NS restrictions observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &nsProvider{}
|
||||
}
|
||||
|
||||
type nsProvider struct{}
|
||||
|
||||
func (p *nsProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyNSRestrictions
|
||||
}
|
||||
|
||||
// Definition implements sdk.CheckerDefinitionProvider.
|
||||
func (p *nsProvider) Definition() *sdk.CheckerDefinition {
|
||||
return Definition()
|
||||
}
|
||||
136
checker/rule.go
Normal file
136
checker/rule.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns one rule per individual NS security check. Every rule
|
||||
// reads the same shared observation produced by Collect and only looks
|
||||
// at its own check entry, so a single network round trip feeds all rules.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_axfr_refused",
|
||||
description: "Verifies that AXFR zone transfers are refused by every authoritative nameserver",
|
||||
checkName: checkNameAXFR,
|
||||
failStatus: sdk.StatusCrit,
|
||||
code: "ns_axfr",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_ixfr_refused",
|
||||
description: "Verifies that IXFR zone transfers are refused by every authoritative nameserver",
|
||||
checkName: checkNameIXFR,
|
||||
failStatus: sdk.StatusWarn,
|
||||
code: "ns_ixfr",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_no_recursion",
|
||||
description: "Verifies that authoritative nameservers do not advertise recursion (RA bit unset)",
|
||||
checkName: checkNameNoRecursion,
|
||||
failStatus: sdk.StatusWarn,
|
||||
code: "ns_recursion",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_any_handled",
|
||||
description: "Verifies that ANY queries are handled per RFC 8482 (HINFO or minimal answer)",
|
||||
checkName: checkNameANYHandled,
|
||||
failStatus: sdk.StatusWarn,
|
||||
code: "ns_any",
|
||||
},
|
||||
&singleCheckRule{
|
||||
ruleName: "ns_is_authoritative",
|
||||
description: "Verifies that nameservers answer authoritatively (AA bit set) for the zone",
|
||||
checkName: checkNameIsAuthoritative,
|
||||
failStatus: sdk.StatusInfo,
|
||||
code: "ns_authoritative",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// singleCheckRule evaluates one named check across all servers in the
|
||||
// shared NSRestrictionsReport observation.
|
||||
type singleCheckRule struct {
|
||||
ruleName string
|
||||
description string
|
||||
checkName string
|
||||
failStatus sdk.Status
|
||||
code string
|
||||
}
|
||||
|
||||
func (r *singleCheckRule) Name() string { return r.ruleName }
|
||||
func (r *singleCheckRule) Description() string { return r.description }
|
||||
|
||||
func (r *singleCheckRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
|
||||
var report NSRestrictionsReport
|
||||
if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err),
|
||||
Code: r.code + "_error",
|
||||
}
|
||||
}
|
||||
|
||||
status := sdk.StatusOK
|
||||
var summaryParts []string
|
||||
failingServers := make([]map[string]string, 0)
|
||||
|
||||
for _, srv := range report.Servers {
|
||||
item, found := findCheck(srv.Checks, r.checkName)
|
||||
if !found {
|
||||
// The collect step did not run this check on this server
|
||||
// (e.g. IPv6 unreachable, DNS resolution failure). Surface
|
||||
// the reason from whichever entry the server does have.
|
||||
if len(srv.Checks) > 0 {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%s: skipped (%s)", serverLabel(srv), srv.Checks[0].Detail))
|
||||
} else {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%s: skipped", serverLabel(srv)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if item.OK {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%s: OK", serverLabel(srv)))
|
||||
continue
|
||||
}
|
||||
|
||||
if status < r.failStatus {
|
||||
status = r.failStatus
|
||||
}
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%s: FAIL (%s)", serverLabel(srv), item.Detail))
|
||||
failingServers = append(failingServers, map[string]string{
|
||||
"name": srv.Name,
|
||||
"address": srv.Address,
|
||||
"detail": item.Detail,
|
||||
})
|
||||
}
|
||||
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Message: strings.Join(summaryParts, " | "),
|
||||
Code: r.code + "_result",
|
||||
Meta: map[string]any{
|
||||
"check": r.checkName,
|
||||
"failing_servers": failingServers,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(items []NSCheckItem, name string) (NSCheckItem, bool) {
|
||||
for _, it := range items {
|
||||
if it.Name == name {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return NSCheckItem{}, false
|
||||
}
|
||||
|
||||
func serverLabel(srv NSServerResult) string {
|
||||
if srv.Address == "" {
|
||||
return srv.Name
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", srv.Name, srv.Address)
|
||||
}
|
||||
35
checker/types.go
Normal file
35
checker/types.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package checker
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ObservationKeyNSRestrictions is the observation key for NS security
|
||||
// restrictions data.
|
||||
const ObservationKeyNSRestrictions = "ns_restrictions"
|
||||
|
||||
// NSRestrictionsReport contains the results of NS security restriction checks.
|
||||
type NSRestrictionsReport struct {
|
||||
Servers []NSServerResult `json:"servers"`
|
||||
}
|
||||
|
||||
// NSServerResult holds the check results for a single nameserver IP.
|
||||
type NSServerResult struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Checks []NSCheckItem `json:"checks"`
|
||||
}
|
||||
|
||||
// NSCheckItem represents one security check for an NS server.
|
||||
type NSCheckItem struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// serviceMessage is a minimal local copy of happydns.ServiceMessage matching
|
||||
// the JSON wire shape, so this plugin does not depend on the happyDomain core
|
||||
// repository.
|
||||
type serviceMessage struct {
|
||||
Type string `json:"_svctype"`
|
||||
Domain string `json:"_domain"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue