feat: complete basic functions

Support http,dns and mysql connection. Support custom http, dns response. Support dns rebinding.
Support mysql load local files and jdbc deserialize exploit.
This commit is contained in:
Li4n0
2021-04-21 18:55:16 +08:00
parent e9db8ddb24
commit 3559894acc
114 changed files with 41617 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
package cli
+65
View File
@@ -0,0 +1,65 @@
package cli
import (
"io/ioutil"
"os"
"sort"
"github.com/li4n0/revsuit/pkg/server"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v2"
log "unknwon.dev/clog/v2"
)
func Start() {
app := &cli.App{
Name: "RevSuit",
Usage: "An Open-Sourced Reverse Platform Designed for Receive Various Kinds of Connection",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "addr",
Usage: "platform listened address",
},
&cli.StringFlag{
Name: "token",
Usage: "token used to manage platform",
},
&cli.StringFlag{
Name: "flags",
Usage: "for http and dns connection, platform will only record the connection those match these regex flags. * meaning record all",
},
&cli.StringFlag{
Name: "db",
Usage: "database file path",
},
},
Action: func(c *cli.Context) error {
conf := &server.Config{}
if content, err := ioutil.ReadFile("config.yaml"); err == nil {
if err := yaml.Unmarshal(content, conf); err != nil {
log.Fatal(err.Error())
}
} else {
log.Fatal(err.Error())
}
if c.String("addr") != "" {
conf.Addr = c.String("addr")
}
if c.String("token") != "" {
conf.Addr = c.String("token")
}
if c.String("db") != "" {
conf.Database = c.String("db")
}
server.New(conf).Run()
return nil
},
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
err := app.Run(os.Args)
if err != nil {
log.Fatal(err.Error())
}
}
+13
View File
@@ -0,0 +1,13 @@
package cli
import "math/rand"
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()"
func genToken() string {
b := make([]byte, 8)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
+13
View File
@@ -0,0 +1,13 @@
package database
import "gorm.io/gorm"
var DB *gorm.DB
func InitDB(driver, dsn string) (err error) {
switch driver {
case "sqlite":
DB, err = NewSqlite3(dsn)
}
return
}
+27
View File
@@ -0,0 +1,27 @@
package database
import (
"database/sql/driver"
"encoding/json"
)
type MapField map[string]string
func (f MapField) Value() (driver.Value, error) {
return json.Marshal(f)
}
func (f *MapField) Scan(data interface{}) error {
return json.Unmarshal(data.([]byte), f)
}
type ListField []string
func (f ListField) Value() (driver.Value, error) {
return json.Marshal(f)
}
func (f *ListField) Scan(data interface{}) error {
return json.Unmarshal(data.([]byte), f)
}
+15
View File
@@ -0,0 +1,15 @@
package database
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func NewSqlite3(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
db.Exec("PRAGMA foreign_keys=ON")
return db, nil
}
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+12
View File
@@ -0,0 +1,12 @@
language: go
go:
- "1.12"
- tip
env:
- GO111MODULE=on
before_install:
- go get github.com/mattn/goveralls
install:
- go get -t ./...
script:
- $HOME/gopath/bin/goveralls -service=travis-ci
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Joël Gähwiler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
all: fmt vet lint
fmt:
go fmt .
vet:
go vet .
lint:
golint .
+99
View File
@@ -0,0 +1,99 @@
# newdns
[![Build Status](https://travis-ci.org/256dpi/newdns.svg?branch=master)](https://travis-ci.org/256dpi/newdns)
[![Coverage Status](https://coveralls.io/repos/github/256dpi/newdns/badge.svg?branch=master)](https://coveralls.io/github/256dpi/newdns?branch=master)
[![GoDoc](https://godoc.org/github.com/256dpi/newdns?status.svg)](http://godoc.org/github.com/256dpi/newdns)
[![Release](https://img.shields.io/github/release/256dpi/newdns.svg)](https://github.com/256dpi/newdns/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/256dpi/newdns)](https://goreportcard.com/report/github.com/256dpi/newdns)
**A library for building custom DNS servers in Go.**
The newdns library wraps the widely used, but low-level [github.com/miekg/dns](https://github.com/miekg/dns) package with a simple interface to quickly build custom DNS servers. The implemented server only supports a subset of record types (A, AAAA, CNAME, MX, TXT) and is intended to be used as a leaf authoritative name server only. It supports UDP and TCP as transport protocols and implements EDNS0. Conformance is tested by issuing a corpus of tests against a zone in AWS Route53 and comparing the response and behavior.
The intention of this project is not to build a feature-complete alternative to "managed zone" offerings by major cloud platforms. However, some projects may require frequent synchronization of many records between a custom database and a cloud-hosted "managed zone". In this scenario, a custom DNS server that queries the own database might be a lot simpler to manage and operate. Also, the distributed nature of the DNS system offers interesting qualities that could be leveraged by future applications.
## Example
```go
// create zone
zone := &newdns.Zone{
Name: "example.com.",
MasterNameServer: "ns1.hostmaster.com.",
AllNameServers: []string{
"ns1.hostmaster.com.",
"ns2.hostmaster.com.",
"ns3.hostmaster.com.",
},
Handler: func(name string) ([]newdns.Set, error) {
// return apex records
if name == "" {
return []newdns.Set{
{
Name: "example.com.",
Type: newdns.A,
Records: []newdns.Record{
{Address: "1.2.3.4"},
},
},
{
Name: "example.com.",
Type: newdns.AAAA,
Records: []newdns.Record{
{Address: "1:2:3:4::"},
},
},
}, nil
}
// return sub records
if name == "foo" {
return []newdns.Set{
{
Name: "foo.example.com.",
Type: newdns.CNAME,
Records: []newdns.Record{
{Address: "bar.example.com."},
},
},
}, nil
}
return nil, nil
},
}
// create server
server := newdns.NewServer(newdns.Config{
Handler: func(name string) (*newdns.Zone, error) {
// check name
if newdns.InZone("example.com.", name) {
return zone, nil
}
return nil, nil
},
Logger: func(e newdns.Event, msg *dns.Msg, err error, reason string) {
fmt.Println(e, err, reason)
},
})
// run server
go func() {
err := server.Run(":1337")
if err != nil {
panic(err)
}
}()
// print info
fmt.Println("Query apex: dig example.com @0.0.0.0 -p 1337")
fmt.Println("Query other: dig foo.example.com @0.0.0.0 -p 1337")
// wait forever
select {}
```
## Credits
- https://github.com/miekg/dns
- https://github.com/coredns/coredns
+85
View File
@@ -0,0 +1,85 @@
package newdns
import "github.com/miekg/dns"
// Event denotes an event type emitted to the logger.
type Event int
const (
// Ignored are requests that haven been dropped by leaving the connection
// hanging to mitigate attacks. Inspect the reason for more information.
Ignored Event = iota
// Request is emitted for every accepted request. For every request event
// a finish event fill follow. You can inspect the message to see the
// complete request sent by the client.
Request Event = iota
// Refused are requests that received an error due to some incompatibility.
// Inspect the reason for more information.
Refused Event = iota
// BackendError is emitted with errors returned by the callback and
// validation functions. Inspect the error for more information.
BackendError Event = iota
// NetworkError is emitted with errors returned by the connection. Inspect
// the error for more information.
NetworkError Event = iota
// Response is emitted with the final response to the client. You can inspect
// the message to see the complete response to the client.
Response Event = iota
// Finish is emitted when a request has been processed.
Finish Event = iota
// ProxyRequest is emitted with every request forwarded to the fallback
// DNS server.
ProxyRequest Event = iota
// ProxyResponse is emitted with ever response received from the fallback
// DNS server.
ProxyResponse Event = iota
// ProxyError is emitted with errors returned by the fallback DNS server.
// Inspect the error for more information.
ProxyError Event = iota
)
// String will return the name of the event.
func (e Event) String() string {
switch e {
case Ignored:
return "Ignored"
case Request:
return "Request"
case Refused:
return "Refused"
case BackendError:
return "BackendError"
case NetworkError:
return "NetworkError"
case Response:
return "Response"
case Finish:
return "Finish"
case ProxyRequest:
return "ProxyRequest"
case ProxyResponse:
return "ProxyResponse"
case ProxyError:
return "ProxyError"
default:
return "Unknown"
}
}
// Logger is function that accepts logging events.
type Logger func(e Event, msg *dns.Msg, err error, reason string)
func log(l Logger, e Event, msg *dns.Msg, err error, reason string) {
if l != nil {
l(e, msg, err, reason)
}
}
+38
View File
@@ -0,0 +1,38 @@
package newdns
import "github.com/miekg/dns"
// Proxy returns a handler that proxies requests to the provided DNS server. The
// optional logger is called with events about the processing of requests.
func Proxy(addr string, logger Logger) dns.Handler {
return dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
// log request
if logger != nil {
logger(ProxyRequest, req, nil, "")
}
// forward request to fallback
rs, err := dns.Exchange(req, addr)
if err != nil {
if logger != nil {
logger(ProxyError, nil, err, "")
}
_ = w.Close()
return
}
// log response
if logger != nil {
logger(ProxyResponse, rs, nil, "")
}
// write response
err = w.WriteMsg(rs)
if err != nil {
if logger != nil {
logger(NetworkError, nil, err, "")
}
_ = w.Close()
}
})
}
+48
View File
@@ -0,0 +1,48 @@
package newdns
import (
"time"
"github.com/miekg/dns"
)
// Query can be used to query a DNS server over the provided protocol on its
// address for the specified name and type. The supplied function can be set to
// mutate the sent request.
func Query(proto, addr, name, typ string, fn func(*dns.Msg)) (*dns.Msg, error) {
// prepare request
req := &dns.Msg{
MsgHdr: dns.MsgHdr{
Id: dns.Id(),
},
Question: []dns.Question{
{
Name: name,
Qtype: dns.StringToType[typ],
Qclass: dns.ClassINET,
},
},
}
// call function if available
if fn != nil {
fn(req)
}
// prepare client
client := dns.Client{
Net: proto,
Timeout: time.Second,
}
// send request
res, _, err := client.Exchange(req, addr)
if err != nil {
return nil, err
}
// reset id to allow direct comparison
res.Id = 0
return res, nil
}
+67
View File
@@ -0,0 +1,67 @@
package newdns
import (
"net"
"github.com/pkg/errors"
)
// Record holds a single DNS record.
type Record struct {
// The target address for A, AAAA, CNAME and MX records.
Address string
// The priority for MX records.
Priority int
// The data for TXT records.
Data []string
}
// Validate will validate the record.
func (r *Record) Validate(typ Type) error {
// validate A address
if typ == A {
ip := net.ParseIP(r.Address)
if ip == nil || ip.To4() == nil {
return errors.Errorf("invalid IPv4 address: %s", r.Address)
}
}
// validate AAAA address
if typ == AAAA {
ip := net.ParseIP(r.Address)
if ip == nil || ip.To16() == nil {
return errors.Errorf("invalid IPv6 address: %s", r.Address)
}
}
// validate CNAME and MX addresses
if typ == CNAME || typ == MX {
if !IsDomain(r.Address, true) {
return errors.Errorf("invalid domain name: %s", r.Address)
}
}
// check TXT data
if typ == TXT {
if len(r.Data) == 0 {
return errors.Errorf("missing data")
}
for _, data := range r.Data {
if len(data) > 255 {
return errors.Errorf("data too long")
}
}
}
// validate NS addresses
if typ == NS {
if !IsDomain(r.Address, true) {
return errors.Errorf("invalid ns name: %s", r.Address)
}
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package newdns
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRecordValidate(t *testing.T) {
table := []struct {
typ Type
rec Record
err string
}{
{
typ: A,
rec: Record{Address: "foo"},
err: "invalid IPv4 address: foo",
},
{
typ: AAAA,
rec: Record{Address: "foo"},
err: "invalid IPv6 address: foo",
},
{
typ: A,
rec: Record{Address: "1:2:3:4::"},
err: "invalid IPv4 address: 1:2:3:4::",
},
{
typ: A,
rec: Record{Address: "1.2.3.4"},
},
{
typ: AAAA,
rec: Record{Address: "1:2:3:4::"},
},
{
typ: CNAME,
rec: Record{Address: "---"},
err: "invalid domain name: ---",
},
{
typ: CNAME,
rec: Record{Address: "foo.com"},
err: "invalid domain name: foo.com",
},
{
typ: CNAME,
rec: Record{Address: "foo.com."},
},
{
typ: MX,
rec: Record{Address: "foo.com"},
err: "invalid domain name: foo.com",
},
{
typ: MX,
rec: Record{Address: "foo.com."},
},
{
typ: TXT,
rec: Record{Data: nil},
err: "missing data",
},
{
typ: TXT,
rec: Record{Data: []string{"z4e6ycRMp6MP3WvWQMxIAOXglxANbj3oB0xD8BffktO4eo3VCR0s6TyGHKixvarOFJU0fqNkXeFOeI7sTXH5X0iXZukfLgnGTxLXNC7KkVFwtVFsh1P0IUNXtNBlOVWrVbxkS62ezbLpENNkiBwbkCvcTjwF2kyI0curAt9JhhJFb3AAq0q1iHWlJLn1KSrev9PIsY3alndDKjYTPxAojxzGKdK3A7rWLJ8Uzb3Z5OhLwP7jTKqbWVUocJRFLYpL"}},
err: "data too long",
},
{
typ: TXT,
rec: Record{Data: []string{"foo"}},
},
{
typ: NS,
rec: Record{Address: "foo.com"},
err: "invalid ns name: foo.com",
},
{
typ: NS,
rec: Record{Address: "foo.com."},
},
}
for i, item := range table {
err := item.rec.Validate(item.typ)
if err != nil {
assert.Equal(t, item.err, err.Error(), i)
} else {
assert.Equal(t, item.err, "", item)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package newdns
import (
"net"
"github.com/miekg/dns"
)
var fakeAddr = &net.TCPAddr{
IP: net.IP{0, 0, 0, 0},
Port: 0,
}
type responseWriter struct {
msg *dns.Msg
}
func (w *responseWriter) LocalAddr() net.Addr {
return fakeAddr
}
func (w *responseWriter) RemoteAddr() net.Addr {
return fakeAddr
}
func (w *responseWriter) WriteMsg(msg *dns.Msg) error {
// check message
if w.msg != nil {
panic("message already set")
}
// set message
w.msg = msg
return nil
}
func (w *responseWriter) Write([]byte) (int, error) {
panic("not implemented")
}
func (w *responseWriter) Close() error {
return nil
}
func (w *responseWriter) TsigStatus() error {
panic("not implemented")
}
func (w *responseWriter) TsigTimersOnly(bool) {
panic("not implemented")
}
func (w *responseWriter) Hijack() {
panic("not implemented")
}
// Resolver returns a very primitive recursive resolver that uses the provided
// handler to resolve all names.
func Resolver(handler dns.Handler) dns.Handler {
return dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
// forward query if no recursion is desired
if !req.RecursionDesired {
handler.ServeDNS(w, req)
return
}
// prepare response
res := new(dns.Msg)
res.SetReply(req)
res.RecursionAvailable = true
// query handler
var wr responseWriter
handler.ServeDNS(&wr, req)
// check response
if wr.msg == nil {
_ = w.WriteMsg(res)
return
}
// add resolved answers
res.Answer = append(res.Answer, resolve(handler, wr.msg.Answer)...)
// write response
err := w.WriteMsg(res)
if err != nil {
_ = w.Close()
}
})
}
func resolve(handler dns.Handler, records []dns.RR) []dns.RR {
// prepare result
var res []dns.RR
res = append(res, records...)
// handle records
for _, record := range records {
if cname, ok := record.(*dns.CNAME); ok {
// query handler
var wr responseWriter
handler.ServeDNS(&wr, &dns.Msg{
Question: []dns.Question{
{
Name: cname.Target,
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
},
},
})
// add resolved answers
res = append(res, resolve(handler, wr.msg.Answer)...)
}
}
return res
}
+92
View File
@@ -0,0 +1,92 @@
package newdns
import (
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
func TestResolver(t *testing.T) {
ret, err := Query("tcp", "1.1.1.1:53", "example.newdns.256dpi.com.", "A", func(msg *dns.Msg) {
msg.RecursionDesired = true
})
assert.NoError(t, err)
equalJSON(t, &dns.Msg{
MsgHdr: dns.MsgHdr{
Response: true,
RecursionDesired: true,
RecursionAvailable: true,
},
Question: []dns.Question{
{Name: "example.newdns.256dpi.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET},
},
Answer: []dns.RR{
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "example.newdns.256dpi.com.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
Ttl: ret.Answer[0].(*dns.CNAME).Hdr.Ttl,
Rdlength: 10,
},
Target: "example.com.",
},
&dns.A{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: ret.Answer[1].(*dns.A).Hdr.Ttl,
Rdlength: 4,
},
A: ret.Answer[1].(*dns.A).A,
},
},
}, ret)
addr := "0.0.0.0:53002"
mux := dns.NewServeMux()
mux.Handle("newdns.256dpi.com", Proxy(awsNS[0]+":53", nil))
mux.Handle("example.com", Proxy("a.iana-servers.net:53", nil))
handler := Resolver(mux)
serve(handler, addr, func() {
ret, err := Query("udp", addr, "example.newdns.256dpi.com.", "A", func(msg *dns.Msg) {
msg.RecursionDesired = true
})
assert.NoError(t, err)
equalJSON(t, &dns.Msg{
MsgHdr: dns.MsgHdr{
Response: true,
RecursionDesired: true,
RecursionAvailable: true,
},
Question: []dns.Question{
{Name: "example.newdns.256dpi.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET},
},
Answer: []dns.RR{
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "example.newdns.256dpi.com.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
Ttl: ret.Answer[0].(*dns.CNAME).Hdr.Ttl,
Rdlength: 13,
},
Target: "example.com.",
},
&dns.A{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: ret.Answer[1].(*dns.A).Hdr.Ttl,
Rdlength: 4,
},
A: ret.Answer[1].(*dns.A).A,
},
},
}, ret)
})
}
+67
View File
@@ -0,0 +1,67 @@
package newdns
import (
"fmt"
"github.com/miekg/dns"
)
// Accept will return a dns.MsgAcceptFunc that only accepts normal queries.
func Accept(logger Logger) dns.MsgAcceptFunc {
return func(dh dns.Header) dns.MsgAcceptAction {
// check if request
if dh.Bits&(1<<15) != 0 {
log(logger, Ignored, nil, nil, fmt.Sprintf("not a request"))
return dns.MsgIgnore
}
// check opcode
if int(dh.Bits>>11)&0xF != dns.OpcodeQuery {
log(logger, Ignored, nil, nil, fmt.Sprintf("not a query"))
return dns.MsgIgnore
}
// check question count
if dh.Qdcount != 1 {
log(logger, Ignored, nil, nil, fmt.Sprintf("invalid question count: %d", dh.Qdcount))
return dns.MsgIgnore
}
return dns.MsgAccept
}
}
// Run will start a UDP and TCP listener to serve the specified handler with the
// specified accept function until the provided close channel is closed. It will
// return the first error of a listener.
func Run(addr string, handler dns.Handler, accept dns.MsgAcceptFunc, close <-chan struct{}) error {
// prepare servers
udp := &dns.Server{Addr: addr, Net: "udp", Handler: handler, MsgAcceptFunc: accept}
tcp := &dns.Server{Addr: addr, Net: "tcp", Handler: handler, MsgAcceptFunc: accept}
// prepare errors
errs := make(chan error, 2)
// run udp server
go func() {
errs <- udp.ListenAndServe()
}()
// run tcp server
go func() {
errs <- tcp.ListenAndServe()
}()
// await first error
var err error
select {
case err = <-errs:
case <-close:
}
// shutdown servers
_ = udp.Shutdown()
_ = tcp.Shutdown()
return err
}
+445
View File
@@ -0,0 +1,445 @@
package newdns
import (
"fmt"
"net"
"github.com/miekg/dns"
"github.com/pkg/errors"
)
// Config provides configuration for a DNS server.
type Config struct {
// The buffer size used if EDNS is enabled by a client.
//
// Default: 1220.
BufferSize int
// The list of zones handled by this server.
//
// Default: ["."].
Zones []string
// Handler is the callback that returns a zone for the specified name.
// The returned zone must not be altered going forward.
Handler func(name string) (*Zone, error)
// The fallback DNS server to be used if the zones is not matched. Exact
// zones must be provided above for this to work.
Fallback string
// Reporter is the callback called with request errors.
Logger Logger
}
// Server is a DNS server.
type Server struct {
config Config
close chan struct{}
}
// NewServer creates and returns a new DNS server.
func NewServer(config Config) *Server {
// set default buffer size
if config.BufferSize <= 0 {
config.BufferSize = 1220
}
// set default zone
if len(config.Zones) == 0 {
config.Zones = []string{"."}
}
// check zones if fallback
if config.Fallback != "" {
for _, zone := range config.Zones {
if zone == "." {
panic(`fallback conflicts with the match all pattern "." (default)`)
}
}
}
return &Server{
config: config,
close: make(chan struct{}),
}
}
// Run will run a udp and tcp server on the specified address. It will return
// on the first accept error and close all servers.
func (s *Server) Run(addr string) error {
// prepare mux
mux := dns.NewServeMux()
// register handler
for _, zone := range s.config.Zones {
mux.Handle(zone, s)
}
// add fallback if available
if s.config.Fallback != "" {
mux.Handle(".", Proxy(s.config.Fallback, s.config.Logger))
}
// run server
err := Run(addr, mux, Accept(s.config.Logger), s.close)
if err != nil {
return err
}
return nil
}
// ServeDNS implements the dns.Handler interface.
func (s *Server) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
// get question
question := req.Question[0]
// check class
if question.Qclass != dns.ClassINET {
log(s.config.Logger, Ignored, nil, nil, fmt.Sprintf("unsupported class: %s", dns.ClassToString[question.Qclass]))
return
}
// log request and finish
log(s.config.Logger, Request, req, nil, "")
defer log(s.config.Logger, Finish, nil, nil, "")
// prepare response
res := new(dns.Msg)
res.SetReply(req)
// always compress responses
res.Compress = true
// set flag
res.Authoritative = true
// check edns
if req.IsEdns0() != nil {
// use edns in reply
res.SetEdns0(uint16(s.config.BufferSize), false)
// check version
if req.IsEdns0().Version() != 0 {
log(s.config.Logger, Refused, nil, nil, fmt.Sprintf("unsupported EDNS version: %d", req.IsEdns0().Version()))
s.writeError(w, req, res, nil, dns.RcodeBadVers)
return
}
}
// check any type
if question.Qtype == dns.TypeANY {
log(s.config.Logger, Refused, nil, nil, "unsupported type: ANY")
s.writeError(w, req, res, nil, dns.RcodeNotImplemented)
return
}
// get name
name := NormalizeDomain(question.Name, true, false, false)
// get zone
zone, err := s.config.Handler(name)
if err != nil {
err = errors.Wrap(err, "server handler error")
log(s.config.Logger, BackendError, nil, err, "")
s.writeError(w, req, res, nil, dns.RcodeServerFailure)
return
}
// check zone
if zone == nil {
log(s.config.Logger, Refused, nil, nil, "no zone")
res.Authoritative = false
s.writeError(w, req, res, nil, dns.RcodeRefused)
return
}
// validate zone
err = zone.Validate()
if err != nil {
log(s.config.Logger, BackendError, nil, err, "")
s.writeError(w, req, res, nil, dns.RcodeServerFailure)
return
}
// answer SOA directly
if question.Qtype == dns.TypeSOA && name == zone.Name {
s.writeSOAResponse(w, req, res, zone)
return
}
// answer NS directly
if question.Qtype == dns.TypeNS && name == zone.Name {
s.writeNSResponse(w, req, res, zone)
return
}
// check type
typ := Type(question.Qtype)
// return error if type is not supported
if !typ.valid() {
log(s.config.Logger, Refused, nil, nil, fmt.Sprintf("unsupported type: %s", dns.TypeToString[question.Qtype]))
s.writeError(w, req, res, zone, dns.RcodeNameError)
return
}
// lookup main answer
answer, exists, err := zone.Lookup(name, w.RemoteAddr().String(), typ)
if err != nil {
log(s.config.Logger, BackendError, nil, err, "")
s.writeError(w, req, res, nil, dns.RcodeServerFailure)
return
}
// check result
if len(answer) == 0 {
// write SOA with success code to indicate existence of other sets
if exists {
s.writeError(w, req, res, zone, dns.RcodeSuccess)
return
}
// otherwise return name error
s.writeError(w, req, res, zone, dns.RcodeNameError)
return
}
// prepare extra set
var extra []Set
// TODO: Lookup glue records for NS records?
// lookup extra sets
for _, set := range answer {
for _, record := range set.Records {
switch set.Type {
case MX:
// lookup internal MX target A and AAAA records
if InZone(zone.Name, record.Address) {
ret, _, err := zone.Lookup(record.Address, w.RemoteAddr().String(), A, AAAA)
if err != nil {
log(s.config.Logger, BackendError, nil, err, "")
s.writeError(w, req, res, nil, dns.RcodeServerFailure)
return
}
// add to extra
extra = append(extra, ret...)
}
}
}
}
// set answer
for _, set := range answer {
res.Answer = append(res.Answer, s.convert(question.Name, zone, set)...)
}
// set extra
for _, set := range extra {
res.Extra = append(res.Extra, s.convert(question.Name, zone, set)...)
}
// add ns records
for _, ns := range zone.AllNameServers {
res.Ns = append(res.Ns, &dns.NS{
Hdr: dns.RR_Header{
Name: TransferCase(question.Name, zone.Name),
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
Ttl: toSeconds(zone.NSTTL),
},
Ns: ns,
})
}
// check if NS query
if typ == NS {
// move answers
res.Ns = res.Answer
res.Answer = nil
// no authoritative response for other zone in NS queries
res.Authoritative = false
}
// write message
s.writeMessage(w, req, res)
}
// Close will close the server.
func (s *Server) Close() {
defer func() { recover() }()
close(s.close)
}
func (s *Server) writeSOAResponse(w dns.ResponseWriter, rq, rs *dns.Msg, zone *Zone) {
// add soa record
rs.Answer = append(rs.Answer, &dns.SOA{
Hdr: dns.RR_Header{
Name: zone.Name,
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
Ttl: toSeconds(zone.SOATTL),
},
Ns: zone.MasterNameServer,
Mbox: emailToDomain(zone.AdminEmail),
Serial: 1,
Refresh: toSeconds(zone.Refresh),
Retry: toSeconds(zone.Retry),
Expire: toSeconds(zone.Expire),
Minttl: toSeconds(zone.MinTTL),
})
// add ns records
for _, ns := range zone.AllNameServers {
rs.Ns = append(rs.Ns, &dns.NS{
Hdr: dns.RR_Header{
Name: zone.Name,
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
Ttl: toSeconds(zone.NSTTL),
},
Ns: ns,
})
}
// write message
s.writeMessage(w, rq, rs)
}
func (s *Server) writeNSResponse(w dns.ResponseWriter, rq, rs *dns.Msg, zone *Zone) {
// add ns records
for _, ns := range zone.AllNameServers {
rs.Answer = append(rs.Answer, &dns.NS{
Hdr: dns.RR_Header{
Name: zone.Name,
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
Ttl: toSeconds(zone.NSTTL),
},
Ns: ns,
})
}
// write message
s.writeMessage(w, rq, rs)
}
func (s *Server) writeError(w dns.ResponseWriter, rq, rs *dns.Msg, zone *Zone, code int) {
// set code
rs.Rcode = code
// add soa record
if zone != nil {
rs.Ns = append(rs.Ns, &dns.SOA{
Hdr: dns.RR_Header{
Name: zone.Name,
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
Ttl: toSeconds(zone.SOATTL),
},
Ns: zone.MasterNameServer,
Mbox: emailToDomain(zone.AdminEmail),
Serial: 1,
Refresh: toSeconds(zone.Refresh),
Retry: toSeconds(zone.Retry),
Expire: toSeconds(zone.Expire),
Minttl: toSeconds(zone.MinTTL),
})
}
// write message
s.writeMessage(w, rq, rs)
}
func (s *Server) writeMessage(w dns.ResponseWriter, rq, rs *dns.Msg) {
// get buffer size
var buffer = 512
if rq.IsEdns0() != nil {
buffer = int(rq.IsEdns0().UDPSize())
}
// determine if client is using UDP
isUDP := w.RemoteAddr().Network() == "udp"
// truncate message if client is using UDP and message is too long
if isUDP && rs.Len() > buffer {
rs.Truncated = true
rs.Answer = nil
rs.Ns = nil
rs.Extra = nil
}
// write message
err := w.WriteMsg(rs)
if err != nil {
log(s.config.Logger, NetworkError, nil, err, "")
_ = w.Close()
return
}
// log response
log(s.config.Logger, Response, rs, nil, "")
}
func (s *Server) convert(query string, zone *Zone, set Set) []dns.RR {
// prepare header
header := dns.RR_Header{
Name: TransferCase(query, set.Name),
Rrtype: uint16(set.Type),
Class: dns.ClassINET,
Ttl: toSeconds(set.TTL),
}
// ensure zone min TTL
if set.TTL < zone.MinTTL {
header.Ttl = toSeconds(zone.MinTTL)
}
// prepare list
var list []dns.RR
// add records
for _, record := range set.Records {
// construct record
switch set.Type {
case A:
list = append(list, &dns.A{
Hdr: header,
A: net.ParseIP(record.Address),
})
case AAAA:
list = append(list, &dns.AAAA{
Hdr: header,
AAAA: net.ParseIP(record.Address),
})
case CNAME:
list = append(list, &dns.CNAME{
Hdr: header,
Target: dns.Fqdn(record.Address),
})
case MX:
list = append(list, &dns.MX{
Hdr: header,
Preference: uint16(record.Priority),
Mx: dns.Fqdn(record.Address),
})
case TXT:
list = append(list, &dns.TXT{
Hdr: header,
Txt: record.Data,
})
case NS:
list = append(list, &dns.NS{
Hdr: header,
Ns: dns.Fqdn(record.Address),
})
}
}
return list
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
package newdns
import (
"time"
"github.com/pkg/errors"
)
// Set is a set of records.
type Set struct {
// The FQDN of the set.
Name string
// The type of the record.
Type Type
// The records in the set.
Records []Record
// The TTL of the set.
//
// Default: 5m.
TTL time.Duration
}
// Validate will validate the set and ensure defaults.
func (s *Set) Validate() error {
// check name
if !IsDomain(s.Name, true) {
return errors.Errorf("invalid name: %s", s.Name)
}
// check type
if !s.Type.valid() {
return errors.Errorf("invalid type: %d", s.Type)
}
// check records
if len(s.Records) == 0 {
return errors.Errorf("missing records")
}
// check CNAME records
if s.Type == CNAME && len(s.Records) > 1 {
return errors.Errorf("multiple CNAME records")
}
// validate records
for _, record := range s.Records {
err := record.Validate(s.Type)
if err != nil {
return errors.Wrap(err, "invalid record")
}
}
// check for duplicate addresses if not TXT
if len(s.Records) > 1 && s.Type != TXT {
for i := 0; i < len(s.Records)-1; i++ {
if s.Records[i].Address == s.Records[i+1].Address {
return errors.Errorf("duplicate address: %s", s.Records[i].Address)
}
}
}
// set default ttl
if s.TTL == 0 {
s.TTL = 5 * time.Minute
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package newdns
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSetValidate(t *testing.T) {
table := []struct {
set Set
err string
}{
{
set: Set{
Name: "foo",
},
err: "invalid name: foo",
},
{
set: Set{
Name: "example.com.",
},
err: "invalid type: 0",
},
{
set: Set{
Name: "example.com.",
Type: A,
},
err: "missing records",
},
{
set: Set{
Name: "example.com.",
Type: A,
Records: []Record{
{Address: "foo"},
},
},
err: "invalid record: invalid IPv4 address: foo",
},
{
set: Set{
Name: "example.com.",
Type: TXT,
Records: []Record{
{},
},
},
err: "invalid record: missing data",
},
{
set: Set{
Name: "example.com.",
Type: CNAME,
Records: []Record{
{},
{},
},
},
err: "multiple CNAME records",
},
{
set: Set{
Name: "example.com.",
Type: A,
Records: []Record{
{Address: "1.2.3.4"},
{Address: "1.2.3.4"},
},
},
err: "duplicate address: 1.2.3.4",
},
}
for i, item := range table {
err := item.set.Validate()
if err != nil {
assert.Equal(t, item.err, err.Error(), i)
} else {
assert.Equal(t, item.err, "", item)
}
}
}
+136
View File
@@ -0,0 +1,136 @@
package newdns
import (
"math"
"strings"
"time"
"github.com/miekg/dns"
)
// IsDomain returns whether the name is a valid domain and if requested also
// fully qualified.
func IsDomain(name string, fqdn bool) bool {
_, ok := dns.IsDomainName(name)
return ok && (!fqdn || fqdn && dns.IsFqdn(name))
}
// InZone returns whether the provided name is part of the provided zone. Will
// always return false if the provided domains are not valid.
func InZone(zone, name string) bool {
// check domains
if !IsDomain(zone, false) || !IsDomain(name, false) {
return false
}
return dns.IsSubDomain(zone, name)
}
// TrimZone will remove the zone from the specified name.
func TrimZone(zone, name string) string {
// return immediately if not in zone
if !InZone(zone, name) {
return name
}
// count zone labels
count := dns.CountLabel(zone)
// get segments
labels := dns.SplitDomainName(name)
// get new labels
newLabels := labels[0 : len(labels)-count]
// join name
newName := strings.Join(newLabels, ".")
return newName
}
// NormalizeDomain will normalize the provided domain name by removing space
// around the name and lowercase it if request.
func NormalizeDomain(name string, lower, makeFQDN, removeFQDN bool) string {
// remove spaces
name = strings.TrimSpace(name)
// lowercase if requested
if lower {
name = strings.ToLower(name)
}
// make FQDN if requested
if makeFQDN {
name = dns.Fqdn(name)
}
// remove FQDN if requested
if removeFQDN && dns.IsFqdn(name) {
name = name[:len(name)-1]
}
return name
}
// SplitDomain will split the provided domain either in separate labels or
// hierarchical labels. The later allows walking a domain up to the root.
func SplitDomain(name string, hierarchical bool) []string {
// normalize name
name = NormalizeDomain(name, false, false, true)
// return nil if empty
if name == "" {
return nil
}
// split in labels
if !hierarchical {
return dns.SplitDomainName(name)
}
// prepare list
var list []string
// walk domain
for off, end := 0, false; !end; off, end = dns.NextLabel(name, off) {
list = append(list, name[off:])
}
return list
}
// TransferCase will transfer the case from the source name to the destination.
// For the source "foo.AAA.com." and destination "aaa.com" the function will
// return "AAA.com". The source must be either a child or the same as the
// destination.
func TransferCase(source, destination string) string {
// get lower variants
lowSource := strings.ToLower(source)
lowDestination := strings.ToLower(destination)
// get index of destination in source
index := strings.Index(lowSource, lowDestination)
if index < 0 {
return destination
}
// take shared part from source
return source[index:]
}
func emailToDomain(email string) string {
// split on at
parts := strings.Split(email, "@")
// replace dots in username
parts[0] = strings.ReplaceAll(parts[0], ".", "\\.")
// join domain
name := parts[0] + "." + parts[1]
return dns.Fqdn(name)
}
func toSeconds(d time.Duration) uint32 {
return uint32(math.Ceil(d.Seconds()))
}
+95
View File
@@ -0,0 +1,95 @@
package newdns
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsDomain(t *testing.T) {
assert.True(t, IsDomain("example.com", false))
assert.False(t, IsDomain("example.com", true))
assert.True(t, IsDomain("example.com.", true))
assert.True(t, IsDomain(" example.com.", true))
assert.False(t, IsDomain("", false))
assert.True(t, IsDomain("x", false))
assert.True(t, IsDomain(".", false))
}
func TestInZone(t *testing.T) {
assert.True(t, InZone("example.com.", "foo.example.com."))
assert.True(t, InZone("example.com", "foo.example.com"))
assert.True(t, InZone("example.com", "example.com"))
assert.True(t, InZone(".", "com"))
assert.True(t, InZone(".", "."))
assert.False(t, InZone("", "."))
assert.False(t, InZone("", ""))
assert.False(t, InZone("foo.example.com", "example.com"))
}
func TestTrimZone(t *testing.T) {
assert.Equal(t, "foo", TrimZone("example.com.", "foo.example.com."))
assert.Equal(t, "foo", TrimZone("example.com", "foo.example.com"))
assert.Equal(t, "", TrimZone("example.com", "example.com"))
assert.Equal(t, "example.com", TrimZone("foo.example.com", "example.com"))
}
func TestNormalizeDomain(t *testing.T) {
assert.Equal(t, "", NormalizeDomain("", false, false, false))
assert.Equal(t, ".", NormalizeDomain("", false, true, false))
assert.Equal(t, "foo", NormalizeDomain(" foo", false, false, false))
assert.Equal(t, "foo", NormalizeDomain("foo ", false, false, false))
assert.Equal(t, "foo", NormalizeDomain(" fOO ", true, false, false))
assert.Equal(t, "foo.", NormalizeDomain(" fOO ", true, true, false))
assert.Equal(t, "foo", NormalizeDomain(" fOO. ", true, false, true))
}
func TestSplitDomain(t *testing.T) {
assert.Equal(t, []string(nil), SplitDomain("", false))
assert.Equal(t, []string(nil), SplitDomain(".", false))
assert.Equal(t, []string{"foo"}, SplitDomain("foo", false))
assert.Equal(t, []string{"foo", "bar"}, SplitDomain("foo.bar", false))
assert.Equal(t, []string(nil), SplitDomain("", true))
assert.Equal(t, []string(nil), SplitDomain(".", true))
assert.Equal(t, []string{"foo"}, SplitDomain("foo", true))
assert.Equal(t, []string{"foo.bar", "bar"}, SplitDomain("foo.bar", true))
}
func TestTransferCase(t *testing.T) {
table := []struct {
src string
dst string
out string
}{
{
src: "example.com",
dst: "example.com",
out: "example.com",
},
{
src: "EXAmple.com",
dst: "example.com",
out: "EXAmple.com",
},
{
src: "FOO.com",
dst: "bar.com",
out: "bar.com",
},
{
src: "foo.EXAmple.com",
dst: "example.com",
out: "EXAmple.com",
},
{
src: "foo.EXAmple.com",
dst: "bar.example.com",
out: "bar.example.com",
},
}
for i, item := range table {
assert.Equal(t, item.out, TransferCase(item.src, item.dst), i)
}
}
+49
View File
@@ -0,0 +1,49 @@
package newdns
import "github.com/miekg/dns"
// Type denotes the DNS record type.
type Type uint16
const (
// A records return IPV4 addresses.
A = Type(dns.TypeA)
// AAAA records return IPV6 addresses.
AAAA = Type(dns.TypeAAAA)
// CNAME records return other DNS names.
CNAME = Type(dns.TypeCNAME)
// MX records return mails servers with their priorities. The target mail
// servers must itself be returned with an A or AAAA record.
MX = Type(dns.TypeMX)
// TXT records return arbitrary text data.
TXT = Type(dns.TypeTXT)
// NS records delegate names to other name servers.
NS = Type(dns.TypeNS)
// REBINDING records return different ip when the same client requests twice.
REBINDING = Type(99)
)
func (t Type) valid() bool {
switch t {
case A, AAAA, CNAME, MX, TXT, NS, REBINDING:
return true
default:
return false
}
}
func typeInList(list []Type, needle Type) bool {
for _, t := range list {
if t == needle {
return true
}
}
return false
}
+101
View File
@@ -0,0 +1,101 @@
package newdns
import (
"bytes"
"encoding/json"
"sort"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)
func run(s *Server, addr string, fn func()) {
defer s.Close()
go func() {
err := s.Run(addr)
if err != nil {
panic(err)
}
}()
time.Sleep(100 * time.Millisecond)
fn()
}
func serve(handler dns.Handler, addr string, fn func()) {
closer := make(chan struct{})
defer close(closer)
go func() {
err := Run(addr, handler, Accept(nil), closer)
if err != nil {
panic(err)
}
}()
time.Sleep(100 * time.Millisecond)
fn()
}
func equalJSON(t *testing.T, a, b interface{}) {
buf := new(bytes.Buffer)
e := json.NewEncoder(buf)
e.SetIndent("", " ")
_ = e.Encode(a)
aa := buf.String()
buf.Reset()
_ = e.Encode(b)
bb := buf.String()
assert.JSONEq(t, aa, bb)
}
func order(rrs []dns.RR) []dns.RR {
cpy := make([]dns.RR, len(rrs))
copy(cpy, rrs)
sort.Slice(cpy, func(i, j int) bool {
var ai string
switch rr := cpy[i].(type) {
case *dns.NS:
ai = rr.Ns
rr.Hdr.Rdlength = 0
}
var aj string
switch rr := cpy[j].(type) {
case *dns.NS:
aj = rr.Ns
rr.Hdr.Rdlength = 0
}
return ai < aj
})
return cpy
}
func isIOError(err error) bool {
if err == nil {
return false
}
if strings.Contains(err.Error(), "i/o timeout") {
return true
}
if strings.Contains(err.Error(), "connection reset by peer") {
return true
}
return false
}
+263
View File
@@ -0,0 +1,263 @@
package newdns
import (
"fmt"
"time"
"github.com/pkg/errors"
)
// Zone describes a single authoritative DNS zone.
type Zone struct {
// The FQDN of the zone e.g. "example.com.".
Name string
// The FQDN of the master mame server responsible for this zone. The FQDN
// must be returned as A and AAAA record by the parent zone.
MasterNameServer string
// A list of FQDNs to all authoritative name servers for this zone. The
// FQDNs must be returned as A and AAAA records by the parent zone. It is
// required to announce at least two distinct name servers per zone.
AllNameServers []string
// The email address of the administrator e.g. "[email protected]".
//
// Default: "hostmaster@NAME".
AdminEmail string
// The refresh interval.
//
// Default: 6h.
Refresh time.Duration
// The retry interval for the zone.
//
// Default: 1h.
Retry time.Duration
// The expiration interval of the zone.
//
// Default: 72h.
Expire time.Duration
// The TTL for the SOA record.
//
// Default: 15m.
SOATTL time.Duration
// The TTL for NS records.
//
// Default: 48h.
NSTTL time.Duration
// The minimum TTL for all records. Either this value, or the SOATTL if lower,
// is used to determine the "negative caching TTL" which is the duration
// caches are allowed to cache missing records (NXDOMAIN).
//
// Default: 5min.
MinTTL time.Duration
// The handler that responds to requests for this zone. The returned sets
// must not be altered going forward.
Handler func(name, remoteAddr string) ([]Set, error)
}
// Validate will validate the zone and ensure the documented defaults.
func (z *Zone) Validate() error {
// check name
if !IsDomain(z.Name, true) {
return errors.Errorf("name not fully qualified: %s", z.Name)
}
// check master name server
if !IsDomain(z.MasterNameServer, true) {
return errors.Errorf("master server not full qualified: %s", z.MasterNameServer)
}
// check name server count
if len(z.AllNameServers) < 1 {
return errors.Errorf("missing name servers")
}
// check name servers
var includesMaster bool
for _, ns := range z.AllNameServers {
if !IsDomain(ns, true) {
return errors.Errorf("name server not fully qualified: %s", ns)
}
if ns == z.MasterNameServer {
includesMaster = true
}
}
// check master inclusion
if !includesMaster {
return errors.Errorf("master name server not listed as name server: %s", z.MasterNameServer)
}
// set default admin email
if z.AdminEmail == "" {
z.AdminEmail = fmt.Sprintf("hostmaster@%s", z.Name)
}
// check admin email
if !IsDomain(emailToDomain(z.AdminEmail), true) {
return errors.Errorf("admin email cannot be converted to a domain name: %s", z.AdminEmail)
}
// set default refresh
if z.Refresh == 0 {
z.Refresh = 6 * time.Hour
}
// set default retry
if z.Retry == 0 {
z.Retry = time.Hour
}
// set default expire
if z.Expire == 0 {
z.Expire = 72 * time.Hour
}
// set default SOA TTL
if z.SOATTL == 0 {
z.SOATTL = 15 * time.Minute
}
// set default NS TTL
if z.NSTTL == 0 {
z.NSTTL = 48 * time.Hour
}
// set default min TTL
if z.MinTTL == 0 {
z.MinTTL = 5 * time.Minute
}
// check retry
if z.Retry >= z.Refresh {
return errors.Errorf("retry must be less than refresh: %d", z.Retry)
}
// check expire
if z.Expire < z.Refresh+z.Retry {
return errors.Errorf("expire must be bigger than the sum of refresh and retry: %d", z.Expire)
}
return nil
}
// Lookup will lookup the specified name in the zone and return results for the
// specified record types. If no results are returned, the second return value
// indicates if there are other results for the specified name.
func (z *Zone) Lookup(name, remoteAddr string, needle ...Type) ([]Set, bool, error) {
// check name
if !IsDomain(name, true) {
return nil, false, errors.Errorf("invalid name: %s", name)
}
// normalize name
name = NormalizeDomain(name, true, false, false)
// check name
if !InZone(z.Name, name) {
return nil, false, errors.Errorf("name does not belong to zone: %s", name)
}
// prepare result
var result []Set
for i := 0; ; i++ {
// get sets
sets, err := z.Handler(TrimZone(z.Name, name),remoteAddr)
if err != nil {
return nil, false, errors.Wrap(err, "zone handler error")
}
// return immediately if initial set is empty
if i == 0 && len(sets) == 0 {
return nil, false, nil
}
// prepare counters
counters := map[Type]int{
A: 0,
AAAA: 0,
CNAME: 0,
MX: 0,
TXT: 0,
}
// validate sets
for _, set := range sets {
// validate set
err = set.Validate()
if err != nil {
return nil, false, errors.Wrap(err, "invalid set")
}
// check relationship
if !InZone(z.Name, set.Name) {
return nil, false, errors.Errorf("set does not belong to zone: %s", set.Name)
}
// increment counter
counters[set.Type]++
}
// check counters
for _, counter := range counters {
if counter > 1 {
return nil, false, errors.New("multiple sets for same type")
}
}
// check apex CNAME
if counters[CNAME] > 0 && name == z.Name {
return nil, false, errors.Errorf("invalid CNAME set at apex: %s", name)
}
// check CNAME is stand-alone
if counters[CNAME] > 0 && (len(sets) > 1) {
return nil, false, errors.Errorf("other sets with CNAME set: %s", name)
}
// check if CNAME and query is not CNAME
if counters[CNAME] > 0 && !typeInList(needle, CNAME) {
// add set to result
result = append(result, sets[0])
// get normalized address
address := NormalizeDomain(sets[0].Records[0].Address, true, false, false)
// continue lookup with CNAME address if address is in zone
if InZone(z.Name, address) {
name = address
continue
}
return result, false, nil
}
// add matching set
for _, set := range sets {
if typeInList(needle, set.Type) {
// add set to result
result = append(result, set)
break
}
}
// return if there are not matches, but indicate that there are sets
// available for other types
if len(result) == 0 {
return nil, true, nil
}
return result, false, nil
}
}
+207
View File
@@ -0,0 +1,207 @@
package newdns
import (
"io"
"testing"
"github.com/stretchr/testify/assert"
)
func TestZoneValidate(t *testing.T) {
table := []struct {
zne Zone
err string
}{
{
zne: Zone{
Name: "foo",
},
err: "name not fully qualified: foo",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "foo",
},
err: "master server not full qualified: foo",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n1.example.com.",
},
err: "missing name servers",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n1.example.com.",
AllNameServers: []string{
"foo",
},
},
err: "name server not fully qualified: foo",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n2.example.com.",
AllNameServers: []string{
"n1.example.com.",
},
},
err: "master name server not listed as name server: n2.example.com.",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n1.example.com.",
AllNameServers: []string{
"n1.example.com.",
},
},
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n1.example.com.",
AllNameServers: []string{
"n1.example.com.",
},
AdminEmail: "[email protected]",
},
err: "admin email cannot be converted to a domain name: [email protected]",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n1.example.com.",
AllNameServers: []string{
"n1.example.com.",
},
Refresh: 1,
Retry: 2,
},
err: "retry must be less than refresh: 2",
},
{
zne: Zone{
Name: "example.com.",
MasterNameServer: "n1.example.com.",
AllNameServers: []string{
"n1.example.com.",
},
Expire: 1,
Retry: 2,
},
err: "expire must be bigger than the sum of refresh and retry: 1",
},
}
for i, item := range table {
err := item.zne.Validate()
if err != nil {
assert.EqualValues(t, item.err, err.Error(), i)
} else {
assert.Equal(t, item.err, "", item)
}
}
}
func TestZoneLookup(t *testing.T) {
zone := Zone{
Name: "example.com.",
MasterNameServer: "ns1.example.com.",
AllNameServers: []string{
"ns1.example.com.",
"ns2.example.com.",
},
Handler: func(name,remoteAddr string) ([]Set, error) {
if name == "error" {
return nil, io.EOF
}
if name == "invalid1" {
return []Set{
{Name: "foo"},
}, nil
}
if name == "invalid2" {
return []Set{
{Name: "foo.", Type: A, Records: []Record{{Address: "1.2.3.4"}}},
}, nil
}
if name == "multiple" {
return []Set{
{Name: "foo.example.com.", Type: A, Records: []Record{{Address: "1.2.3.4"}}},
{Name: "foo.example.com.", Type: A, Records: []Record{{Address: "1.2.3.4"}}},
}, nil
}
if name == "" {
return []Set{
{Name: "example.com.", Type: CNAME, Records: []Record{{Address: "cool.com."}}},
}, nil
}
if name == "cname" {
return []Set{
{Name: "cname.example.com.", Type: A, Records: []Record{{Address: "1.2.3.4"}}},
{Name: "cname.example.com.", Type: CNAME, Records: []Record{{Address: "cool.com."}}},
}, nil
}
return nil, nil
},
}
err := zone.Validate()
assert.NoError(t, err)
table := []struct {
name string
err string
}{
{
name: "foo",
err: "invalid name: foo",
},
{
name: "foo.",
err: "name does not belong to zone: foo.",
},
{
name: "error.example.com.",
err: "zone handler error: EOF",
},
{
name: "invalid1.example.com.",
err: "invalid set: invalid name: foo",
},
{
name: "invalid2.example.com.",
err: "set does not belong to zone: foo.",
},
{
name: "multiple.example.com.",
err: "multiple sets for same type",
},
{
name: "example.com.",
err: "invalid CNAME set at apex: example.com.",
},
{
name: "cname.example.com.",
err: "other sets with CNAME set: cname.example.com.",
},
}
for i, item := range table {
res, exists, err := zone.Lookup(item.name, "127.0.0.1", A)
assert.Equal(t, item.err, err.Error(), i)
assert.False(t, exists, i)
assert.Nil(t, res, i)
}
}
+47
View File
@@ -0,0 +1,47 @@
package notice
import (
"sync"
"github.com/li4n0/revsuit/internal/record"
log "unknwon.dev/clog/v2"
)
var (
lock sync.RWMutex
announcer *Announcer
)
// Bot is used to send notice
type Bot interface {
notice(record.Record) error
buildPayload(record.Record) string
}
// Announcer is used for storage and schedule bots
type Announcer struct {
Bots []Bot
}
// New initializes a new Announcer
func New() *Announcer {
announcer = &Announcer{Bots: make([]Bot, 0)}
return announcer
}
// AddBot add a new bot to Announcer
func (a *Announcer) AddBot(b Bot) *Announcer {
lock.RLock()
a.Bots = append(a.Bots, b)
lock.RUnlock()
return a
}
// Notice let bots to send notice
func Notice(r record.Record) {
for _, bot := range announcer.Bots {
if err := bot.notice(r); err != nil {
log.Error(err.Error())
}
}
}
+71
View File
@@ -0,0 +1,71 @@
package notice
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/li4n0/revsuit/internal/record"
)
var _ Bot = (*DingTalk)(nil)
type DingTalk struct {
URL string
}
type dingAt struct {
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
}
type dingMarkdown struct {
Title string `json:"title"`
Text string `json:"text"`
}
type dingPayload struct {
MsgType string `json:"msgtype"`
Markdown dingMarkdown `json:"markdown"`
At []dingAt `json:"at"`
}
func (d *DingTalk) buildPayload(r record.Record) string {
payload := dingPayload{
MsgType: "markdown",
Markdown: dingMarkdown{
Title: "New Connection",
Text: "**<font color=\"#e96900\" face=\"Fira Code\" size=\"3\">New Connection</font>**\n" +
formatRecordField(r, "> **<font color=\"#e96900\" face=\"Fira Code\">%s: </font>**<font color=\"#e96900\" face=\"Fira Code\">%v</font>\n"),
},
At: []dingAt{
{
IsAtAll: true,
},
},
}
p, err := json.Marshal(&payload)
if err != nil {
return ""
}
return string(p)
}
func (d *DingTalk) notice(r record.Record) error {
resp, err := http.DefaultClient.Post(d.URL, "application/json", strings.NewReader(d.buildPayload(r)))
if err != nil {
return fmt.Errorf("HTTP request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read HTTP response body: %v", err)
}
return fmt.Errorf("non-success response status code %d with body: %s", resp.StatusCode, data)
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package notice
import (
"fmt"
"reflect"
"strings"
"github.com/li4n0/revsuit/internal/record"
)
func formatRecordField(r record.Record,fieldFormat string) (content string) {
structType := reflect.ValueOf(r)
for i := 0; i < structType.NumField(); i++ {
structField := structType.Type().Field(i)
fieldName := structField.Name
tag := structField.Tag
label := tag.Get("notice")
if label == "-" {
continue
} else if label != "" {
fieldName = label
}
value := structType.Field(i).Interface()
if value, ok := value.(record.Record); ok {
content += formatRecordField(value, fieldFormat)
continue
}
content += fmt.Sprintf(fieldFormat+"\n", strings.ToUpper(fieldName), value)
}
strings.TrimSuffix(content, "\n")
return
}
+86
View File
@@ -0,0 +1,86 @@
package notice
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/li4n0/revsuit/internal/record"
)
var _ Bot = (*Lark)(nil)
type Lark struct {
URL string
}
type larkText struct {
Content string `json:"content"`
Tag string `json:"tag"`
}
type larkElement struct {
Tag string `json:"tag"`
Text larkText `json:"text"`
}
type larkCard struct {
Header larkHeader `json:"header"`
Elements []larkElement `json:"elements"`
}
type larkHeader struct {
Title larkText `json:"title"`
}
type larkPayload struct {
MsgType string `json:"msg_type"`
Card larkCard `json:"card"`
}
func (d *Lark) buildPayload(r record.Record) string {
payload := larkPayload{
MsgType: "interactive",
Card: larkCard{
Header:larkHeader{
Title: larkText{
Tag: "plain_text",
Content: "New Connection",
},
},
Elements: []larkElement{
{
Tag: "div",
Text: larkText{
Tag: "lark_md",
Content: formatRecordField(r,"**%s**: %v"),
},
},
},
},
}
p, err := json.Marshal(&payload)
if err != nil {
return ""
}
return string(p)
}
func (d *Lark) notice(r record.Record) error {
resp, err := http.DefaultClient.Post(d.URL, "application/json", strings.NewReader(d.buildPayload(r)))
if err != nil {
return fmt.Errorf("HTTP request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read HTTP response body: %v", err)
}
return fmt.Errorf("non-success response status code %d with body: %s", resp.StatusCode, data)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package notice
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/li4n0/revsuit/internal/record"
)
var _ Bot = (*Slack)(nil)
type Slack struct {
URL string
}
type slackText struct {
Type string `json:"type"`
Text string `json:"text"`
}
type slackBlock struct {
Type string `json:"type"`
Text slackText `json:"text"`
}
type slackAttachments struct {
Color string `json:"color"`
Blocks []slackBlock `json:"blocks"`
}
type slackPayload struct {
Attachments []slackAttachments `json:"attachments"`
}
func (d *Slack) buildPayload(r record.Record) string {
payload := slackPayload{
Attachments: []slackAttachments{
{
Color: "#f2c744",
Blocks: []slackBlock{
{
Type: "header",
Text: slackText{
Type: "plain_text",
Text: "New Connection",
},
},
{
Type: "section",
Text: slackText{
Type: "mrkdwn",
Text: formatRecordField(r, "- `%s: %v`"),
},
},
},
},
},
}
p, err := json.Marshal(&payload)
if err != nil {
return ""
}
return string(p)
}
func (d *Slack) notice(r record.Record) error {
resp, err := http.DefaultClient.Post(d.URL, "application/json", strings.NewReader(d.buildPayload(r)))
if err != nil {
return fmt.Errorf("HTTP request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read HTTP response body: %v", err)
}
return fmt.Errorf("non-success response status code %d with body: %s", resp.StatusCode, data)
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
package notice
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/li4n0/revsuit/internal/record"
)
var _ Bot = (*Weixin)(nil)
type Weixin struct {
URL string
}
type weixinMarkdown struct {
Content string `json:"content"`
}
type weixinPayload struct {
ToUser string `json:"touser"`
MsgType string `json:"msgtype"`
Markdown weixinMarkdown `json:"markdown"`
}
func (w *Weixin) buildPayload(r record.Record) string {
payload := weixinPayload{
ToUser: "@all",
MsgType: "markdown",
Markdown: weixinMarkdown{
Content: "<font color=\"#e96900\" face=\"Fira Code\" size=3>New Connection</font>\n" +
formatRecordField(r, `> **<font color="#e96900" face="Fira Code">%s: </font>**<font color="#e96900" face="Fira Code">%v</font>`),
},
}
p, err := json.Marshal(&payload)
if err != nil {
return ""
}
return string(p)
}
func (w *Weixin) notice(r record.Record) error {
resp, err := http.DefaultClient.Post(w.URL, "application/json", strings.NewReader(w.buildPayload(r)))
if err != nil {
return fmt.Errorf("HTTP request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read HTTP response body: %v", err)
}
return fmt.Errorf("non-success response status code %d with body: %s", resp.StatusCode, data)
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package qqwry
import (
"bytes"
"compress/zlib"
"crypto/tls"
"encoding/binary"
"errors"
"io/ioutil"
"net/http"
"sync"
"time"
log "unknwon.dev/clog/v2"
)
const (
COPY_WRITE_URL = "https://qqwry.mirror.noc.one/copywrite.rar"
QQWRY_URL = "https://qqwry.mirror.noc.one/qqwry.rar"
)
func get(url string) (b []byte, err error) {
client := http.Client{
Timeout: 90 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // disable verify
}}
request, _ := http.NewRequest("GET", url, nil)
request.Header.Add("User-Agent", "Nali/2.1.2 (Nali CLI, https://nali.skk.moe)")
resp, err := client.Do(request)
if err != nil {
return
}
defer resp.Body.Close()
b, err = ioutil.ReadAll(resp.Body)
return
}
func getKey(b []byte) (key uint32, err error) {
if len(b) != 280 {
return 0, errors.New("copywrite.rar is corrupt")
}
key = binary.LittleEndian.Uint32(b[20:])
return
}
func decrypt(b []byte, key uint32) (_ []byte, err error) {
for i := 0; i < 0x200; i++ {
key *= uint32(0x805)
key++
key &= uint32(0xff)
b[i] = b[i] ^ byte(key)
}
rc, err := zlib.NewReader(bytes.NewBuffer(b))
if err != nil {
return
}
defer rc.Close()
return ioutil.ReadAll(rc)
}
func download() (err error) {
var (
copyWriteData, qqwryData []byte
wg sync.WaitGroup
)
log.Info("Downloading qqwry.dat...")
wg.Add(2)
go func() {
defer wg.Done()
if copyWriteData, err = get(COPY_WRITE_URL); err != nil {
return
}
}()
go func() {
defer wg.Done()
if qqwryData, err = get(QQWRY_URL); err != nil {
return
}
}()
wg.Wait()
if err != nil {
return err
}
var key uint32
if key, err = getKey(copyWriteData); err != nil {
return
}
b, err := decrypt(qqwryData, key)
if err != nil {
return err
}
_ = ioutil.WriteFile("qqwry.dat", b, 0644)
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package qqwry
// Area return IpArea according to ip
func Area(ip string) string {
ipData := GetQQWry().SearchByIPv4(ip)
if GetQQWry() == nil {
return ""
}
if ipData.Area == " CZ88.NET" {
return ipData.Country
} else {
return ipData.Country + " " + ipData.Area
}
}
+47
View File
@@ -0,0 +1,47 @@
package qqwry
import (
"os"
"sync"
"time"
"github.com/sinlov/qqwry-golang/qqwry"
log "unknwon.dev/clog/v2"
)
var wry *qqwry.QQwry
var once sync.Once
func init() {
info, err := os.Stat("qqwry.dat")
if err != nil {
if os.IsNotExist(err) {
err := download()
if err != nil {
log.Error("Download qqwry.dat failed, caused by:%v, recommend to download it by yourself otherwise the `IpArea` will be null", err.Error())
}
}
} else if info.ModTime().Sub(time.Now()) > 5*24*time.Hour {
log.Info("Updating qqwry.dat...")
err := download()
if err != nil {
log.Warn("Update qqwry.dat failed, please download qqwry.dat by yourself")
}
}
}
func GetQQWry() *qqwry.QQwry {
once.Do(func() {
qqwry.DatData.FilePath = "qqwry.dat"
init := qqwry.DatData.InitDatFile()
if v, ok := init.(error); ok {
if v != nil {
log.Error("qqwry init failed")
wry = nil
}
}
wry = qqwry.NewQQwry()
})
return wry
}
+38
View File
@@ -0,0 +1,38 @@
package record
import (
"time"
)
var recordChan = make(chan BaseRecord, 100)
type Record interface {
GetFlag() string
PushToClient()
Notice()
}
type BaseRecord struct {
Record `gorm:"-" json:"-"`
ID uint `gorm:"primarykey" form:"id" json:"id" notice:"-"`
RuleName string `gorm:"index" form:"rule_name" json:"rule_name" notice:"rule"`
Flag string `gorm:"index" form:"flag" json:"flag" `
RemoteIP string `gorm:"index" form:"remote_ip" json:"remote_ip" notice:"remote_ip"`
IpArea string `form:"ip_area" json:"ip_area" notice:"ip_area"`
RequestTime time.Time `gorm:"index" form:"request_time" json:"request_time" notice:"-"`
}
func (b BaseRecord) GetFlag() string {
return b.Flag
}
func (b BaseRecord) PushToClient() {
recordChan <- b
}
func (b BaseRecord) Notice() {
}
func Channel() chan BaseRecord {
return recordChan
}
+65
View File
@@ -0,0 +1,65 @@
package rule
import (
"regexp"
"time"
log "unknwon.dev/clog/v2"
)
type Rule interface {
CreateOrUpdate() error
Delete() error
}
type BaseRule struct {
Rule `gorm:"-" json:"-"`
ID uint `gorm:"primarykey" form:"id" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `gorm:"index;unique;not null;" form:"name" json:"name"`
FlagFormat string `gorm:"unique;not null;" form:"flag_format" json:"flag_format"`
flagCatcher *regexp.Regexp `gorm:"-" json:"-"`
Rank int `gorm:"default:0" json:"rank" form:"rank"`
PushToClient bool `gorm:"default:false;not null;" form:"push_to_client" json:"push_to_client"`
Notice bool `gorm:"default:false;not null;" form:"notice" json:"notice"`
}
func compileCatcher(flagFormat string) (reg *regexp.Regexp, err error) {
if reg, err := regexp.Compile(flagFormat); err == nil {
return reg, nil
} else {
// * meaning record all connections.
if flagFormat != "*" {
return nil, err
} else {
return nil, nil
}
}
}
func (br BaseRule) Match(s string) (flag, flagGroup string) {
if br.flagCatcher == nil && br.FlagFormat != "*" {
// compile rule flags
catcher, err := compileCatcher(br.FlagFormat)
if err != nil {
log.Error("%s(rule:%s)", err.Error(), br.Name)
}
br.flagCatcher = catcher
}
if br.flagCatcher == nil {
// capture all connection.
flag = "*"
} else {
matched := br.flagCatcher.FindStringSubmatch(s)
if len(matched) == 0 {
return
}
flag = matched[0]
if len(matched) > 1 {
flagGroup = matched[1]
}
}
return
}