1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

migrate code for github

This commit is contained in:
Craig Peterson
2016-08-22 18:31:50 -06:00
commit ef0bbf53af
359 changed files with 157476 additions and 0 deletions

251
js/helpers.js Normal file
View File

@ -0,0 +1,251 @@
"use strict";
var conf = {
registrars: [],
dns_service_providers: [],
domains: []
};
var defaultDsps = [];
function initialize(){
conf = {
registrars: [],
dns_service_providers: [],
domains: []
};
defaultDsps = [];
}
function NewRegistrar(name,type,meta) {
if (type) {
type == "MANUAL";
}
var reg = {name: name, type: type, meta: meta};
conf.registrars.push(reg);
return name;
}
function NewDSP(name, type, meta) {
if ((typeof meta === 'object') && ('ip_conversions' in meta)) {
meta.ip_conversions = format_tt(meta.ip_conversions)
}
var dsp = {name: name, type: type, meta: meta};
conf.dns_service_providers.push(dsp);
return name;
}
function newDomain(name,registrar) {
return {name: name, registrar: registrar, meta:{}, records:[], dsps: [], defaultTTL: 0, nameservers:[]};
}
function processDargs(m, domain) {
// for each modifier, if it is a...
// function: call it with domain
// array: process recursively
// object: merge it into metadata
// string: assume it is a dsp
if (_.isFunction(m)) {
m(domain);
} else if (_.isArray(m)) {
for (var j in m) {
processDargs(m[j], domain)
}
} else if (_.isObject(m)) {
_.extend(domain.meta,m);
} else if (_.isString(m)) {
domain.dsps.push(m);
} else {
console.log("WARNING: domain modifier type unsupported: ", typeof m, " Domain: ", domain)
}
}
// D(name,registrar): Create a DNS Domain. Use the parameters as records and mods.
function D(name,registrar) {
var domain = newDomain(name,registrar);
for (var i = 2; i<arguments.length; i++) {
var m = arguments[i];
processDargs(m, domain)
}
var toAdd = _(defaultDsps).difference(domain.dsps);
_(toAdd).each(function(x) { domain.dsps.push(x)});
conf.domains.push(domain)
}
// TTL(v): Set the TTL for a DNS record.
function TTL(v) {
return function(r) {
r.ttl = v;
}
}
// DefaultTTL(v): Set the default TTL for the domain.
function DefaultTTL(v) {
return function(d) {
d.defaultTTL = v;
}
}
// A(name,ip, recordModifiers...)
function A(name, ip) {
var mods = getModifiers(arguments,2)
return function(d) {
addRecord(d,"A",name,ip,mods)
}
}
// AAAA(name,ip, recordModifiers...)
function AAAA(name, ip) {
var mods = getModifiers(arguments,2)
return function(d) {
addRecord(d,"AAAA",name,ip,mods)
}
}
// CNAME(name,target, recordModifiers...)
function CNAME(name, target) {
var mods = getModifiers(arguments,2)
return function(d) {
addRecord(d,"CNAME",name,target,mods)
}
}
// TXT(name,target, recordModifiers...)
function TXT(name, target) {
var mods = getModifiers(arguments,2)
return function(d) {
addRecord(d,"TXT",name,target,mods)
}
}
// MX(name,priority,target, recordModifiers...)
function MX(name, priority, target) {
var mods = getModifiers(arguments,3)
return function(d) {
mods.push(priority);
addRecord(d, "MX", name, target, mods)
}
}
// NS(name,target, recordModifiers...)
function NS(name, target) {
var mods = getModifiers(arguments,2)
return function(d) {
addRecord(d,"NS",name,target,mods)
}
}
// NAMESERVER(name,target)
function NAMESERVER(name, target) {
return function(d) {
d.nameservers.push({name: name, target: target})
}
}
function format_tt(transform_table) {
// Turn [[low: 1, high: 2, newBase: 3], [low: 4, high: 5, newIP: 6]]
// into "1 ~ 2 ~ 3 ~; 4 ~ 5 ~ ~ 6"
var lines = []
for (var i=0; i < transform_table.length; i++) {
var ip = transform_table[i];
var newIP = ip.newIP;
if (newIP){
if(_.isArray(newIP)){
newIP = _.map(newIP,function(i){return num2dot(i)}).join(",")
}else{
newIP = num2dot(newIP);
}
}
var row = [
num2dot(ip.low),
num2dot(ip.high),
num2dot(ip.newBase),
newIP
]
lines.push(row.join(" ~ "))
}
return lines.join(" ; ")
}
// IMPORT_TRANSFORM(translation_table, domain)
function IMPORT_TRANSFORM(translation_table, domain) {
return function(d) {
addRecord(d, "IMPORT_TRANSFORM", "@", domain, [
{'transform_table': format_tt(translation_table)}])
}
}
// PURGE()
function PURGE(d) {
d.KeepUnknown = false
}
// NO_PURGE()
function NO_PURGE(d) {
d.KeepUnknown = true
}
function getModifiers(args,start) {
var mods = [];
for (var i = start;i<args.length; i++) {
mods.push(args[i])
}
return mods;
}
function addRecord(d,type,name,target,mods) {
// if target is number, assume ip address. convert it.
if (_.isNumber(target)) {
target = num2dot(target);
}
var rec = {type: type, name: name, target: target, ttl:d.defaultTTL, priority: 0, meta:{}};
// for each modifier, decide based on type:
// - Function: call is with the record as the argument
// - Object: merge it into the metadata
// - Number: IF MX record assume it is priority
if (mods) {
for (var i = 0; i< mods.length; i++) {
var m = mods[i]
if (_.isFunction(m)) {
m(rec);
} else if (_.isObject(m)) {
//convert transforms to strings
if (m.transform && _.isArray(m.transform)){
m.transform = format_tt(m.transform)
}
_.extend(rec.meta,m);
_.extend(rec.meta,m);
} else if (_.isNumber(m) && type == "MX") {
rec.priority = m;
} else {
console.log("WARNING: Modifier type unsupported:", typeof m, "(Skipping!)");
}
}
}
d.records.push(rec);
}
//ip conversion functions from http://stackoverflow.com/a/8105740/121660
// via http://javascript.about.com/library/blipconvert.htm
function IP(dot)
{
var d = dot.split('.');
return ((((((+d[0])*256)+(+d[1]))*256)+(+d[2]))*256)+(+d[3]);
}
function num2dot(num)
{
if(num === undefined){
return "";
}
if (_.isString(num)){
return num
}
var d = num%256;
for (var i = 3; i > 0; i--)
{
num = Math.floor(num/256);
d = num%256 + '.' + d;
}
return d;
}

46
js/js.go Normal file
View File

@ -0,0 +1,46 @@
package js
import (
"encoding/json"
"github.com/StackExchange/dnscontrol/models"
"github.com/robertkrimen/otto"
//load underscore js into vm by default
_ "github.com/robertkrimen/otto/underscore"
)
//ExecuteJavascript accepts a javascript string and runs it, returning the resulting dnsConfig.
func ExecuteJavascript(script string, devMode bool) (*models.DNSConfig, error) {
vm := otto.New()
helperJs := GetHelpers(devMode)
// run helper script to prime vm and initialize variables
if _, err := vm.Run(string(helperJs)); err != nil {
return nil, err
}
// run user script
if _, err := vm.Run(script); err != nil {
return nil, err
}
// export conf as string and unmarshal
value, err := vm.Run(`JSON.stringify(conf)`)
if err != nil {
return nil, err
}
str, err := value.ToString()
if err != nil {
return nil, err
}
conf := &models.DNSConfig{}
if err = json.Unmarshal([]byte(str), conf); err != nil {
return nil, err
}
return conf, nil
}
func GetHelpers(devMode bool) string {
return FSMustString(devMode, "/helpers.js")
}

58
js/js_test.go Normal file
View File

@ -0,0 +1,58 @@
package js
import (
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/StackExchange/dnscontrol/models"
)
const testDir = "js/parse_tests"
func TestParsedFiles(t *testing.T) {
os.Chdir("..") // go up a directory so we helpers.js is in a consistent place.
files, err := ioutil.ReadDir(testDir)
if err != nil {
t.Fatal(err)
}
for _, f := range files {
if filepath.Ext(f.Name()) != ".js" {
continue
}
t.Log(f.Name(), "------")
content, err := ioutil.ReadFile(filepath.Join(testDir, f.Name()))
if err != nil {
t.Fatal(err)
}
conf, err := ExecuteJavascript(string(content), true)
if err != nil {
t.Fatal(err)
}
actualJson, err := json.MarshalIndent(conf, "", " ")
if err != nil {
t.Fatal(err)
}
expectedFile := filepath.Join(testDir, f.Name()[:len(f.Name())-3]+".json")
expectedData, err := ioutil.ReadFile(expectedFile)
if err != nil {
t.Fatal(err)
}
conf = &models.DNSConfig{}
err = json.Unmarshal(expectedData, conf)
if err != nil {
t.Fatal(err)
}
expectedJson, err := json.MarshalIndent(conf, "", " ")
if err != nil {
t.Fatal(err)
}
if string(expectedJson) != string(actualJson) {
t.Error("Expected and actual json don't match")
t.Log("Expected:", string(expectedJson))
t.Log("Actual:", string(actualJson))
}
}
}

View File

@ -0,0 +1,6 @@
var REG = NewRegistrar("Third-Party","NONE");
var CF = NewDSP("Cloudflare", "CLOUDFLAREAPI")
D("foo.com",REG,CF,
A("@","1.2.3.4")
);

View File

@ -0,0 +1,30 @@
{
"registrars": [
{
"name": "Third-Party",
"type": "NONE"
}
],
"dns_service_providers": [
{
"name": "Cloudflare",
"type": "CLOUDFLAREAPI"
}
],
"domains": [
{
"name": "foo.com",
"registrar": "Third-Party",
"dsps": [
"Cloudflare"
],
"records": [
{
"type": "A",
"name": "@",
"target": "1.2.3.4"
}
]
}
]
}

View File

@ -0,0 +1,5 @@
var REG = NewRegistrar("Third-Party","NONE");
var CF = NewDSP("Cloudflare", "CLOUDFLAREAPI")
D("foo.com",REG,CF,
A("@","1.2.3.4",TTL(42))
);

View File

@ -0,0 +1,31 @@
{
"registrars": [
{
"name": "Third-Party",
"type": "NONE"
}
],
"dns_service_providers": [
{
"name": "Cloudflare",
"type": "CLOUDFLAREAPI"
}
],
"domains": [
{
"name": "foo.com",
"registrar": "Third-Party",
"dsps": [
"Cloudflare"
],
"records": [
{
"type": "A",
"name": "@",
"target": "1.2.3.4",
"ttl": 42
}
]
}
]
}

View File

@ -0,0 +1,5 @@
var CLOUDFLARE = NewRegistrar("Cloudflare","CLOUDFLAREAPI");
D("foo.com",CLOUDFLARE,
A("@","1.2.3.4",{"cloudflare_proxy":"ON"})
);

View File

@ -0,0 +1,26 @@
{
"registrars": [
{
"name": "Cloudflare",
"type": "CLOUDFLAREAPI"
}
],
"dns_service_providers": [],
"domains": [
{
"name": "foo.com",
"registrar": "Cloudflare",
"dsps": [],
"records": [
{
"type": "A",
"name": "@",
"target": "1.2.3.4",
"meta": {
"cloudflare_proxy": "ON"
}
}
]
}
]
}

10
js/parse_tests/004-ips.js Normal file
View File

@ -0,0 +1,10 @@
var REG = NewRegistrar("Third-Party","NONE");
var CF = NewDSP("Cloudflare", "CLOUDFLAREAPI")
var BASE = IP("1.2.3.4")
D("foo.com",REG,CF,
A("@",BASE),
A("p1",BASE+1),
A("p255", BASE+255)
);

View File

@ -0,0 +1,28 @@
{
"registrars": [
{
"name": "Third-Party",
"type": "NONE"
}
],
"dns_service_providers": [
{
"name": "Cloudflare",
"type": "CLOUDFLAREAPI"
}
],
"domains": [
{
"name": "foo.com",
"registrar": "Third-Party",
"dsps": [
"Cloudflare"
],
"records": [
{ "type": "A","name": "@","target": "1.2.3.4"},
{ "type": "A","name": "p1","target": "1.2.3.5"},
{ "type": "A","name": "p255","target": "1.2.4.3"}
]
}
]
}

View File

@ -0,0 +1,7 @@
var REG = NewRegistrar("Third-Party","NONE");
var CF = NewDSP("Cloudflare", "CLOUDFLAREAPI")
D("foo.com",REG,CF,
A("@","1.2.3.4")
);
D("foo.com",REG);

View File

@ -0,0 +1,35 @@
{ "registrars": [
{
"name": "Third-Party",
"type": "NONE"
}
],
"dns_service_providers": [
{
"name": "Cloudflare",
"type": "CLOUDFLAREAPI"
}
],
"domains": [
{
"name": "foo.com",
"registrar": "Third-Party",
"dsps": [
"Cloudflare"
],
"records": [
{
"type": "A",
"name": "@",
"target": "1.2.3.4"
}
]
},
{
"name": "foo.com",
"registrar": "Third-Party",
"dsps": [],
"records": []
}
]
}

253
js/static.go Normal file
View File

@ -0,0 +1,253 @@
package js
import (
"bytes"
"compress/gzip"
"encoding/base64"
"io/ioutil"
"net/http"
"os"
"path"
"sync"
"time"
)
type _escLocalFS struct{}
var _escLocal _escLocalFS
type _escStaticFS struct{}
var _escStatic _escStaticFS
type _escDirectory struct {
fs http.FileSystem
name string
}
type _escFile struct {
compressed string
size int64
modtime int64
local string
isDir bool
once sync.Once
data []byte
name string
}
func (_escLocalFS) Open(name string) (http.File, error) {
f, present := _escData[path.Clean(name)]
if !present {
return nil, os.ErrNotExist
}
return os.Open(f.local)
}
func (_escStaticFS) prepare(name string) (*_escFile, error) {
f, present := _escData[path.Clean(name)]
if !present {
return nil, os.ErrNotExist
}
var err error
f.once.Do(func() {
f.name = path.Base(name)
if f.size == 0 {
return
}
var gr *gzip.Reader
b64 := base64.NewDecoder(base64.StdEncoding, bytes.NewBufferString(f.compressed))
gr, err = gzip.NewReader(b64)
if err != nil {
return
}
f.data, err = ioutil.ReadAll(gr)
})
if err != nil {
return nil, err
}
return f, nil
}
func (fs _escStaticFS) Open(name string) (http.File, error) {
f, err := fs.prepare(name)
if err != nil {
return nil, err
}
return f.File()
}
func (dir _escDirectory) Open(name string) (http.File, error) {
return dir.fs.Open(dir.name + name)
}
func (f *_escFile) File() (http.File, error) {
type httpFile struct {
*bytes.Reader
*_escFile
}
return &httpFile{
Reader: bytes.NewReader(f.data),
_escFile: f,
}, nil
}
func (f *_escFile) Close() error {
return nil
}
func (f *_escFile) Readdir(count int) ([]os.FileInfo, error) {
return nil, nil
}
func (f *_escFile) Stat() (os.FileInfo, error) {
return f, nil
}
func (f *_escFile) Name() string {
return f.name
}
func (f *_escFile) Size() int64 {
return f.size
}
func (f *_escFile) Mode() os.FileMode {
return 0
}
func (f *_escFile) ModTime() time.Time {
return time.Unix(f.modtime, 0)
}
func (f *_escFile) IsDir() bool {
return f.isDir
}
func (f *_escFile) Sys() interface{} {
return f
}
// FS returns a http.Filesystem for the embedded assets. If useLocal is true,
// the filesystem's contents are instead used.
func FS(useLocal bool) http.FileSystem {
if useLocal {
return _escLocal
}
return _escStatic
}
// Dir returns a http.Filesystem for the embedded assets on a given prefix dir.
// If useLocal is true, the filesystem's contents are instead used.
func Dir(useLocal bool, name string) http.FileSystem {
if useLocal {
return _escDirectory{fs: _escLocal, name: name}
}
return _escDirectory{fs: _escStatic, name: name}
}
// FSByte returns the named file from the embedded assets. If useLocal is
// true, the filesystem's contents are instead used.
func FSByte(useLocal bool, name string) ([]byte, error) {
if useLocal {
f, err := _escLocal.Open(name)
if err != nil {
return nil, err
}
b, err := ioutil.ReadAll(f)
f.Close()
return b, err
}
f, err := _escStatic.prepare(name)
if err != nil {
return nil, err
}
return f.data, nil
}
// FSMustByte is the same as FSByte, but panics if name is not present.
func FSMustByte(useLocal bool, name string) []byte {
b, err := FSByte(useLocal, name)
if err != nil {
panic(err)
}
return b
}
// FSString is the string version of FSByte.
func FSString(useLocal bool, name string) (string, error) {
b, err := FSByte(useLocal, name)
return string(b), err
}
// FSMustString is the string version of FSMustByte.
func FSMustString(useLocal bool, name string) string {
return string(FSMustByte(useLocal, name))
}
var _escData = map[string]*_escFile{
"/helpers.js": {
local: "js/helpers.js",
size: 6427,
modtime: 0,
compressed: `
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
`,
},
"/tester.html": {
local: "js/tester.html",
size: 953,
modtime: 0,
compressed: `
H4sIAAAJbogA/2yST2/bPAzG7/4UhN73YCOr1TrrOjSWDwM6YD10w1AM6IocHFmJ6SliJsnZsiLffZD/
NAmykynqx4cPKee1X+siymtVVkUEAJA7aXHjwVkpWO39xt1yLqlSafOzVXaXSlrzPrzI0iydpms0aeMY
oPFqZdHvBHN1mV2/uyiz6erqwb+t/N3T5+Zm8XWyfWxvJo/vv325mtbt+vvHD/cPn57o7p4EA2nJObK4
QiNYacjs1tQ6VuS8t1REOe995guqdoPdelpUxkky3pKGxoFXziub83o6EF799qVVJWAlGBoGln45wa4v
GUjSTrDs8jJ0GblR2PIhWrTekwEyUqP8IVjocO/ihBWPyvmc9/dnZRVuu5ZWuVb7bpAKt0UUna34lvPW
VMo6SVY1LiW7OkpcDAs+XsSJgtIbZd2/kSJatkZ6JAOj7ZfOHhr0WGr8o+Jk1mW2pYXw0CDg/5j9h4Yl
6bbU47Uk40irVNMqDtiQVgE5Op9iZjmkg+K4iCQNf13cODJZF3VcMov2gTwYPgAhSuDl1Sa+Aas8CGCs
lw+niQCWt7oYUkuyMSBgrzNWn8AaCzbBCbuFoaZbzDIGv9soWnaFzzgHIQQwWjRKepaM5afunnGeHDSU
duqYe8b57Lw9D/37/P50Cn4YwyrfWhM+s2gfvb7v3wAAAP//jV4zrLkDAAA=
`,
},
"/": {
isDir: true,
local: "js",
},
}