Merge branch 'ddns'

# Conflicts:
#	README.md
#	etc/goldilocks/goldilocks.example.yml
This commit is contained in:
tigerbot 2017-10-25 18:35:07 -06:00
當前提交 e504c4b717
共有 23 個檔案被更改,包括 2220 行新增1322 行删除

查看文件

@ -13,4 +13,5 @@
, "latedef": true
, "curly": true
, "trailing": true
, "esversion": 6
}

118
API.md
查看文件

@ -2,30 +2,112 @@
The API system is intended for use with Desktop and Mobile clients.
It must be accessed using one of the following domains as the Host header:
```
admin.invalid
localhost.admin.daplie.me
```
* localhost.alpha.daplie.me
* localhost.admin.daplie.me
* alpha.localhost.daplie.me
* admin.localhost.daplie.me
* localhost.daplie.invalid
All requests require an OAuth3 token in the request headers.
## Tunnel
## Config
### Check Status
* **URL** `/api/goldilocks@daplie.com/tunnel`
### Get All Settings
* **URL** `/api/goldilocks@daplie.com/config`
* **Method** `GET`
* **Reponse**: The JSON representation of the current config. See the [README.md](/README.md)
for the structure of the config.
### Get Group Setting
* **URL** `/api/goldilocks@daplie.com/config/:group`
* **Method** `GET`
* **Reponse**: The sub-object of the config relevant to the group specified in
the url (ie http, tls, tcp, etc.)
### Get Group Module List
* **URL** `/api/goldilocks@daplie.com/config/:group/modules`
* **Method** `GET`
* **Reponse**: The list of modules relevant to the group specified in the url
(ie http, tls, tcp, etc.)
### Get Specific Module
* **URL** `/api/goldilocks@daplie.com/config/:group/modules/:modId`
* **Method** `GET`
* **Reponse**: The module with the specified module ID.
### Get Domain Group
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId`
* **Method** `GET`
* **Reponse**: The domains specification with the specified domains ID.
### Get Domain Group Modules
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId/modules`
* **Method** `GET`
* **Reponse**: An object containing all of the relevant modules for the group
of domains.
### Get Domain Group Module Category
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId/modules/:group`
* **Method** `GET`
* **Reponse**: A list of the specific category of modules for the group of domains.
### Get Specific Domain Group Module
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId/modules/:group/:modId`
* **Method** `GET`
* **Reponse**: The module with the specified module ID.
### Change Settings
* **URL** `/api/goldilocks@daplie.com/config`
* **URL** `/api/goldilocks@daplie.com/config/:group`
* **Method** `POST`
* **Reponse**: An object whose keys are the URLs for the tunnels, and whose
properties are arrays of the tunnel tokens.
* **Body**: The changes to be applied on top of the current config. See the
[README.md](/README.md) for the settings. If modules or domains are specified
they are added to the current list.
* **Reponse**: The current config. If the group is specified in the URL it will
only be the config relevant to that group.
This route with return only the sessions started by the same user who is
checking the status.
### Start Tunnel
* **URL** `/api/goldilocks@daplie.com/tunnel`
### Add Module
* **URL** `/api/goldilocks@daplie.com/config/:group/modules`
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId/modules/:group`
* **Method** `POST`
* **Body**: The module to be added. Can also be provided an array of modules
to add multiple modules in the same request.
* **Reponse**: The current list of modules.
### Add Domain Group
* **URL** `/api/goldilocks@daplie.com/config/domains`
* **Method** `POST`
* **Body**: The domains names and modules for the new domain group(s).
* **Reponse**: The current list of domain groups.
### Edit Module
* **URL** `/api/goldilocks@daplie.com/config/:group/modules/:modId`
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId/modules/:group/:modId`
* **Method** `PUT`
* **Body**: The new parameters for the module.
* **Reponse**: The editted module.
### Edit Domain Group
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId`
* **Method** `PUT`
* **Body**: The new domains names for the domains group. The module list cannot
be editted through this route.
* **Reponse**: The editted domain group.
### Remove Module
* **URL** `/api/goldilocks@daplie.com/config/:group/modules/:modId`
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId/modules/:group/:modId`
* **Method** `DELETE`
* **Reponse**: The list of modules.
### Remove Domain Group
* **URL** `/api/goldilocks@daplie.com/config/domains/:domId`
* **Method** `DELETE`
* **Reponse**: The list of domain groups.
This route will use the stored token for the user matching the request
header to request a tunnel token from the audience of the stored token.
## Socks5 Proxy
@ -39,9 +121,9 @@ All requests require an OAuth3 token in the request headers.
### Start Proxy
* **URL** `/api/goldilocks@daplie.com/socks5`
* **Method** `POST`
* **Response**: Same response as for the `GET` resquest
* **Response**: Same response as for the `GET` request
### Stop Proxy
* **URL** `/api/goldilocks@daplie.com/socks5`
* **Method** `DELETE`
* **Response**: Same response as for the `GET` resquest
* **Response**: Same response as for the `GET` request

420
README.md
查看文件

@ -53,50 +53,91 @@ curl https://git.daplie.com/Daplie/goldilocks.js/raw/master/install.sh | bash
Modules & Configuration
-----
Goldilocks has several core systems, which all have their own configuration and some of which have modules:
Goldilocks has several core systems, which all have their own configuration and
some of which have modules:
```
* http
- static
- redirect
- proxy (reverse proxy)
* tls
- acme
- proxy (reverse proxy)
* tcp
- forward
* tunnel_server
* tunnel_client
* mdns
* [http](#http)
- [proxy (reverse proxy)](#httpproxy-how-to-reverse-proxy-ruby-python-etc)
- [static](#httpstatic-how-to-serve-a-web-page)
- [redirect](#httpredirect-how-to-redirect-urls)
* [tls](#tls)
- [proxy (reverse proxy)](#tlsproxy)
- [acme](#tlsacme)
* [tcp](#tcp)
- [forward](#tcpforward)
* [udp](#udp)
- [forward](#udpforward)
* [domains](#domains)
* [tunnel_server](#tunnel_server)
* [tunnel_client](#tunnel)
* [mdns](#mdns)
* [socks5](#socks5)
* api
```
All modules require a `type` and an `id`, and any modules not defined inside the
`domains` system also require a `domains` field (with the exception of the `forward`
modules that require the `ports` field).
### http
The HTTP system handles plain http (TLS / SSL is handled by the tls system)
Example config:
```yml
http:
trust_proxy: true # allow localhost, 192.x, 10.x, 172.x, etc to set headers
allow_insecure: false # allow non-https even without proxy https headers
primary_domain: example.com # attempts to access via IP address will redirect here
# modules can be nested in domains
domains:
- names:
- example.com
modules:
- name: static
root: /srv/www/:hostname
# The configuration above could also be represented as follows:
# An array of modules that define how to handle incoming HTTP requests
modules:
- name: static
- type: static
domains:
- example.com
root: /srv/www/:hostname
```
### http.proxy - how to reverse proxy (ruby, python, etc)
The proxy module is for reverse proxying, typically to an application on the same machine.
(Though it can also reverse proxy to other devices on the local network.)
It has the following options:
```
address The DNS-resolvable hostname (or IP address) and port connected by `:` to proxy the request to.
Takes priority over host and port if they are also specified.
ex: locahost:3000
ex: 192.168.1.100:80
host The DNS-resolvable hostname (or IP address) of the system to which the request will be proxied.
Defaults to localhost if only the port is specified.
ex: localhost
ex: 192.168.1.100
port The port on said system to which the request will be proxied
ex: 3000
ex: 80
```
Example config:
```yml
http:
modules:
- type: proxy
domains:
- api.example.com
host: 192.168.1.100
port: 80
- type: proxy
domains:
- www.example.com
address: 192.168.1.16:80
- type: proxy
domains:
- '*'
port: 3000
```
### http.static - how to serve a web page
The static module is for serving static web pages and assets and has the following options:
@ -121,50 +162,20 @@ indexes An array of directories which should be have indexes served rather t
```
Example config:
```yml
http:
modules:
- name: static
- type: static
domains:
- example.com
root: /srv/www/:hostname
```
### http.proxy - how to reverse proxy (ruby, python, etc)
The proxy module is for reverse proxying, typically to an application on the same machine.
It has the following options:
```
host The DNS-resolvable hostname (or IP address) of the system to which the request will be proxied
ex: localhost
ex: 192.168.1.100
port The port on said system to which the request will be proxied
ex: 3000
ex: 80
```
Example config:
```yml
http:
modules:
- name: proxy
domains:
- example.com
host: localhost
port: 3000
```
### http.redirect - how to redirect URLs
The redirect module is for, you guessed it, redirecting URLs.
It has the following options:
```
status The HTTP status code to issue (301 is usual permanent redirect, 302 is temporary)
ex: 301
@ -182,11 +193,10 @@ to The new URL path which should be used.
```
Example config:
```yml
http:
modules:
- name: proxy
- type: proxy
domains:
- example.com
status: 301
@ -197,41 +207,14 @@ http:
### tls
The tls system handles encrypted connections, including fetching certificates,
and uses ServerName Indication (SNI) to determine if the connection should be handled
by the http system, a tls system module, or rejected.
It has the following options:
```
acme.email The default email address for ACME certificate issuance
ex: john.doe@example.com
acme.server The default ACME server to use
ex: https://acme-v01.api.letsencrypt.org/directory
ex: https://acme-staging.api.letsencrypt.org/directory
acme.challenge_type The default ACME challenge to request
ex: http-01, dns-01, tls-01
acme.approved_domains The domains for which to request certificates
ex: example.com
```
and uses ServerName Indication (SNI) to determine if the connection should be
handled by the http system, a tls system module, or rejected.
Example config:
```yml
tls:
acme:
email: 'joe.shmoe@example.com'
# IMPORTANT: Switch to in production 'https://acme-v01.api.letsencrypt.org/directory'
server: 'https://acme-staging.api.letsencrypt.org/directory'
challenge_type: 'http-01'
approved_domains:
- example.com
- example.net
modules:
- name: proxy
- type: proxy
domains:
- example.com
- example.net
@ -240,17 +223,44 @@ tls:
Certificates are saved to `~/acme`, which may be `/var/www/acme` if Goldilocks is run as the www-data user.
### tls.acme
### tls.proxy
The acme module overrides the acme defaults of the tls system and uses the same options except that `approved_domains`
(in favor of the domains in the scope of the module).
The proxy module routes the traffic based on the ServerName Indication (SNI) **without decrypting** it.
It has the same options as the [HTTP proxy module](#httpproxy-how-to-reverse-proxy-ruby-python-etc).
Example config:
```yml
tls:
modules:
- name: acme
- type: proxy
domains:
- example.com
address: '127.0.0.1:5443'
```
### tls.acme
The acme module defines the setting used when getting new certificates.
It has the following options:
```
email The email address for ACME certificate issuance
ex: john.doe@example.com
server The ACME server to use
ex: https://acme-v01.api.letsencrypt.org/directory
ex: https://acme-staging.api.letsencrypt.org/directory
challenge_type The ACME challenge to request
ex: http-01, dns-01, tls-01
```
Example config:
```yml
tls:
modules:
- type: acme
domains:
- example.com
- example.net
@ -259,41 +269,18 @@ tls:
challenge_type: 'http-01'
```
### tls.proxy
The proxy module routes the traffic based on the ServerName Indication (SNI) **without decrypting** it.
It has the following options:
```
address The hostname (or IP) and port of the system or application that should receive the traffic
```
Example config:
```yml
tls:
modules:
- name: proxy
domains:
- example.com
address: '127.0.0.1:5443'
```
### tcp
The tcp system handles all tcp network traffic **before decryption** and may use port numbers
or traffic sniffing to determine how the connection should be handled.
It has the following options:
```
bind An array of numeric ports on which to bind
ex: 80
```
Example Config
Example Config:
```yml
tcp:
bind:
@ -301,7 +288,7 @@ tcp:
- 80
- 443
modules:
- name: forward
- type: forward
ports:
- 22
address: '127.0.0.1:2222'
@ -311,18 +298,15 @@ tcp:
The forward module routes traffic based on port number **without decrypting** it.
It has the following options:
In addition to the same options as the [HTTP proxy module](#httpproxy-how-to-reverse-proxy-ruby-python-etc),
the TCP forward modules also has the following options:
```
ports A numeric array of source ports
ex: 22
address The destination hostname and port
ex: 127.0.0.1:2222
```
Example Config
Example Config:
```yml
tcp:
bind:
@ -330,10 +314,79 @@ tcp:
- 80
- 443
modules:
- name: forward
- type: forward
ports:
- 22
address: '127.0.0.1:2222'
port: 2222
```
### udp
The udp system handles all udp network traffic. It currently only supports
forwarding the messages without any examination.
It has the following options:
```
bind An array of numeric ports on which to bind
ex: 53
```
Example Config:
```yml
udp:
bind:
- 53
modules:
- type: forward
ports:
- 53
address: '127.0.0.1:8053'
```
### udp.forward
The forward module routes traffic based on port number **without decrypting** it.
It has the same options as the [TCP forward module](#tcpforward).
Example Config:
```yml
udp:
bind:
- 53
modules:
- type: forward
ports:
- 53
address: '127.0.0.1:8053'
```
### domains
To reduce repetition defining multiple modules that operate on the same domain
name the `domains` field can define multiple modules of multiple types for a
single list of names. The modules defined this way do not need to have their
own `domains` field.
Example Config
```yml
domains:
names:
- example.com
- www.example.com
- api.example.com
modules:
tls:
- type: acme
email: joe.schmoe@example.com
challenge_type: 'http-01'
http:
- type: redirect
from: /deprecated/path
to: /new/path
- type: proxy
port: 3000
```
@ -363,66 +416,50 @@ tunnel_server:
- 'api.tunnel.example.com'
```
### tunnel
### DDNS
The tunnel client is meant to be run from behind a firewalls, carrier-grade NAT,
or otherwise inaccessible devices to allow them to be accessed publicly on the
internet.
The DDNS module watches the network environment of the unit and makes sure the
device is always accessible on the internet using the domains listed in the
config. If the device has a public address or if it can automatically set up
port forwarding the device will periodically check its public address to ensure
the DNS records always point to it. Otherwise it will to connect to a tunnel
server and set the DNS records to point to that server.
It has no options per se, but is rather a list of tokens that can be used to
connect to tunnel servers. If the token does not have an `aud` field it must be
provided in an object with the token provided in the `jwt` field and the tunnel
server url provided in the `tunnelUrl` field.
The `loopback` setting specifies how the unit will check its public IP address
and whether connections can reach it. Currently only `tunnel@oauth3.org` is
supported. If the loopback setting is not defined it will default to using
`oauth3.org`.
Example config:
The `tunnel` setting can be used to specify how to connect to the tunnel.
Currently only `tunnel@oauth3.org` is supported. The token specified in the
`tunnel` setting will be used to acquire the tokens that are used directly with
the tunnel server. If the tunnel setting is not defined it will default to try
using the tokens in the modules for the relevant domains.
```yml
tunnel:
- 'some.jwt_encoded.token'
- jwt: 'other.jwt_encoded.token'
tunnelUrl: 'wss://api.tunnel.example.com/'
If a particular DDNS module has been disabled the device will still try to set
up port forwarding (and connect to a tunnel if that doesn't work), but the DNS
records will not be updated to point to the device. This is to allow a setup to
be tested before transitioning services between devices.
```yaml
ddns:
disabled: false
loopback:
type: 'tunnel@oauth3.org'
domain: oauth3.org
tunnel:
type: 'tunnel@oauth3.org'
token_id: user_token_id
modules:
- type: 'dns@oauth3.org'
token_id: user_token_id
domains:
- www.example.com
- api.example.com
- test.example.com
```
**NOTE**: The more common way to use the tunnel with goldilocks is to use the
API to have goldilocks get a token from `oauth3.org`. In order to do this you
will need to have initialized goldilocks with a token that has the `dns` and
`domains` scopes. This is probably easiest to do with the `daplie-desktop-app`,
which will also get the first tunnel token for you.
**If you want to add more domains** to handle on your device while using the tunnel
you will need to manually get a new token that will tell the tunnel server to
deliver the requests to the new domain(s) to your device. The first step in this
is to attach the new domains to your device. To get the name of the device you
can use the `config` API, but it's probably easiest to `ssh` onto the device and
get the hostname. You can also use the daplie cli tool to see what device name
your other domains are routed to.
```bash
# for every new domain you want to route attach the domain to your device
daplie devices:attach -n $new_domain -d $device_name
```
After that step you will need to use the API to get goldilocks to get a new token
that includes the new domains you attached. It is also recommended but not
required to remove the older token with the incomplete list of domains. Run the
following commands from the unit.
```bash
# remove the old token
rm /opt/goldilocks/lib/node_modules/goldilocks/var/tokens.json
# set the "refresh_token" to a bash variable `token`
TOKEN=$(python -mjson.tool /opt/goldilocks/lib/node_modules/goldilocks/var/owners.json | sed -n 's|\s*"refresh_token": "\(.*\)",|\1|p')
# tell goldilocks to get a new tunnel token
curl -H "authorization: bearer $TOKEN" -X POST https://localhost.admin.daplie.me/api/goldilocks@daplie.com/tunnel
```
### ddns
TODO
### mdns
### mDNS
enabled by default
@ -465,15 +502,14 @@ See [API.md](/API.md)
TODO
----
* http - nowww module
* http - Allow match styles of `www.*`, `*`, and `*.example.com` equally
* http - redirect based on domain name (not just path)
* tcp - bind should be able to specify localhost, uniquelocal, private, or ip
* tcp - if destination host is omitted default to localhost, if dst port is missing, default to src
* sys - handle SIGHUP
* sys - `curl https://daplie.me/goldilocks | bash -s example.com`
* oauth3 - `example.com/.well-known/domains@oauth3.org/directives.json`
* oauth3 - commandline questionnaire
* modules - use consistent conventions (i.e. address vs host + port)
* tls - tls.acme vs tls.modules.acme
* tls - forward should be able to match on source port to reach different destination ports
* [ ] http - nowww module
* [ ] http - Allow match styles of `www.*`, `*`, and `*.example.com` equally
* [ ] http - redirect based on domain name (not just path)
* [ ] tcp - bind should be able to specify localhost, uniquelocal, private, or ip
* [ ] tcp - if destination host is omitted default to localhost, if dst port is missing, default to src
* [ ] sys - `curl https://daplie.me/goldilocks | bash -s example.com`
* [ ] oauth3 - `example.com/.well-known/domains@oauth3.org/directives.json`
* [ ] oauth3 - commandline questionnaire
* [x] modules - use consistent conventions (i.e. address vs host + port)
* [x] tls - tls.acme vs tls.modules.acme
* [ ] tls - forward should be able to match on source port to reach different destination ports

查看文件

@ -8,6 +8,7 @@ if (!cluster.isMaster) {
return;
}
var crypto = require('crypto');
var PromiseA = require('bluebird');
var fs = PromiseA.promisifyAll(require('fs'));
var configStorage;
@ -25,7 +26,158 @@ function mergeSettings(orig, changes) {
}
});
}
function createStorage(filename, filetype) {
function fixRawConfig(config) {
var updated = false;
// First converge all of the `bind` properties for protocols that are on top
// of TCP to `tcp.bind`.
if (config.tcp && config.tcp.bind && !Array.isArray(config.tcp.bind)) {
config.tcp.bind = [ config.tcp.bind ];
updated = true;
}
if (config.http && config.http.bind) {
config.tcp = config.tcp || { bind: [] };
config.tcp.bind = (config.tcp.bind || []).concat(config.http.bind);
delete config.http.bind;
updated = true;
}
if (config.tls && config.tls.bind) {
config.tcp = config.tcp || { bind: [] };
config.tcp.bind = (config.tcp.bind || []).concat(config.tls.bind);
delete config.tls.bind;
updated = true;
}
// Then we rename dns to udp since the only thing we currently do with those
// modules is proxy the packets without inspecting them at all.
if (config.dns) {
config.udp = config.dns;
delete config.dns;
updated = true;
}
// Convert all 'proxy' UDP modules to 'forward' modules that specify which
// incoming ports are relevant. Primarily to make 'proxy' modules consistent
// in needing relevant domain names.
if (config.udp && !Array.isArray(config.udp.bind)) {
config.udp.bind = [].concat(config.udp.bind || []);
updated = true;
}
if (config.udp && config.udp.modules) {
if (!config.udp.bind.length || !Array.isArray(config.udp.modules)) {
delete config.udp.modules;
updated = true;
} else {
config.udp.modules.forEach(function (mod) {
if (mod.type === 'proxy') {
mod.type = 'forward';
mod.ports = config.udp.bind.slice();
updated = true;
}
});
}
}
// This we take the old way of defining ACME options and put them into a tls module.
if (config.tls) {
var oldPropMap = {
email: 'email'
, acme_directory_url: 'server'
, challenge_type: 'challenge_type'
, servernames: 'approved_domains'
};
if (Object.keys(oldPropMap).some(config.tls.hasOwnProperty, config.tls)) {
updated = true;
if (config.tls.acme) {
console.warn('TLS config has `acme` field and old style definitions');
} else {
config.tls.acme = {};
Object.keys(oldPropMap).forEach(function (oldKey) {
if (config.tls[oldKey]) {
config.tls.acme[oldPropMap[oldKey]] = config.tls[oldKey];
}
});
}
}
if (config.tls.acme) {
updated = true;
config.tls.acme.domains = config.tls.acme.approved_domains;
delete config.tls.acme.approved_domains;
config.tls.modules = config.tls.modules || [];
config.tls.modules.push(Object.assign({}, config.tls.acme, {type: 'acme'}));
delete config.tls.acme;
}
}
// Then we make sure all modules have an ID and type, and makes sure all domains
// are in the right spot and also have an ID.
function updateModules(list) {
if (!Array.isArray(list)) {
return;
}
list.forEach(function (mod) {
if (!mod.id) {
mod.id = crypto.randomBytes(4).toString('hex');
updated = true;
}
if (mod.name) {
mod.type = mod.type || mod.name;
delete mod.name;
updated = true;
}
});
}
function moveDomains(name) {
if (!config[name].domains) {
return;
}
updated = true;
var domList = config[name].domains;
delete config[name].domains;
if (!Array.isArray(domList)) {
return;
}
if (!Array.isArray(config.domains)) {
config.domains = [];
}
domList.forEach(function (dom) {
updateModules(dom.modules);
var strDoms = dom.names.slice().sort().join(',');
var added = config.domains.some(function (existing) {
if (strDoms !== existing.names.slice().sort().join(',')) {
return;
}
existing.modules = existing.modules || {};
existing.modules[name] = (existing.modules[name] || []).concat(dom.modules);
return true;
});
if (added) {
return;
}
var newDom = {
id: crypto.randomBytes(4).toString('hex')
, names: dom.names
, modules: {}
};
newDom.modules[name] = dom.modules;
config.domains.push(newDom);
});
}
[ 'udp', 'tcp', 'http', 'tls' ].forEach(function (key) {
if (!config[key]) {
return;
}
updateModules(config[key].modules);
moveDomains(key);
});
return updated;
}
async function createStorage(filename, filetype) {
var recase = require('recase').create({});
var snakeCopy = recase.snakeCopy.bind(recase);
var camelCopy = recase.camelCopy.bind(recase);
@ -40,13 +192,25 @@ function createStorage(filename, filetype) {
dump = yaml.safeDump;
}
function read() {
return fs.readFileAsync(filename).then(parse).catch(function (err) {
async function read() {
var text;
try {
text = await fs.readFileAsync(filename);
} catch (err) {
if (err.code === 'ENOENT') {
return {};
}
return PromiseA.reject(err);
});
throw err;
}
var rawConfig = parse(text);
if (fixRawConfig(rawConfig)) {
await fs.writeFileAsync(filename, dump(rawConfig));
text = await fs.readFileAsync(filename);
rawConfig = parse(text);
}
return rawConfig;
}
var result = {
@ -76,145 +240,108 @@ function createStorage(filename, filetype) {
};
return result;
}
function checkConfigLocation(cwd, configFile) {
async function checkConfigLocation(cwd, configFile) {
cwd = cwd || process.cwd();
var path = require('path');
var filename;
var filename, text;
var prom;
if (configFile) {
filename = path.resolve(cwd, configFile);
prom = fs.readFileAsync(filename)
.catch(function (err) {
if (err.code !== 'ENOENT') {
return PromiseA.reject(err);
}
if (path.extname(filename) === '.json') {
return '{}';
}
return '';
})
;
try {
text = await fs.readFileAsync(filename);
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
if (path.extname(filename) === '.json') {
return { name: filename, type: 'json' };
} else {
return { name: filename, type: 'yaml' };
}
}
} else {
prom = PromiseA.reject('blah')
.catch(function () {
filename = path.resolve(cwd, 'goldilocks.yml');
return fs.readFileAsync(filename);
})
.catch(function () {
filename = path.resolve(cwd, 'goldilocks.json');
return fs.readFileAsync(filename);
})
.catch(function () {
filename = path.resolve(cwd, 'etc/goldilocks/goldilocks.yml');
return fs.readFileAsync(filename);
})
.catch(function () {
filename = '/etc/goldilocks/goldilocks.yml';
return fs.readFileAsync(filename);
})
.catch(function () {
filename = path.resolve(cwd, 'goldilocks.yml');
return '';
})
;
// Note that `path.resolve` can handle both relative and absolute paths.
var defLocations = [
path.resolve(cwd, 'goldilocks.yml')
, path.resolve(cwd, 'goldilocks.json')
, path.resolve(cwd, 'etc/goldilocks/goldilocks.yml')
, '/etc/goldilocks/goldilocks.yml'
];
var ind;
for (ind = 0; ind < defLocations.length; ind += 1) {
try {
text = await fs.readFileAsync(defLocations[ind]);
filename = defLocations[ind];
break;
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
}
if (!filename) {
filename = defLocations[0];
text = '';
}
}
return prom.then(function (text) {
try {
JSON.parse(text);
return { name: filename, type: 'json' };
} catch (err) {}
try {
JSON.parse(text);
return { name: filename, type: 'json' };
} catch (err) {}
try {
require('js-yaml').safeLoad(text);
return { name: filename, type: 'yaml' };
} catch (err) {}
try {
require('js-yaml').safeLoad(text);
return { name: filename, type: 'yaml' };
} catch (err) {}
throw new Error('Could not load "' + filename + '" as JSON nor YAML');
});
throw new Error('Could not load "' + filename + '" as JSON nor YAML');
}
function createConfigStorage(args) {
return checkConfigLocation(args.cwd, args.config)
.then(function (result) {
console.log('config file', result.name, 'is of type', result.type);
configStorage = createStorage(result.name, result.type);
return configStorage.read();
})
;
async function createConfigStorage(args) {
var result = await checkConfigLocation(args.cwd, args.config);
console.log('config file', result.name, 'is of type', result.type);
configStorage = await createStorage(result.name, result.type);
return configStorage.read();
}
var tcpProm;
function fillConfig(config, args) {
config.debug = config.debug || args.debug;
if (!config.dns) {
config.dns = { bind: [ 53 ], modules: [{ name: 'proxy', port: 3053 }] };
}
if (!config.ddns) {
config.ddns = { enabled: false };
}
// Use Object.assign to add any properties needed but not defined in the mdns config.
// It will first copy the defaults into an empty object, then copy any real config over that.
var mdnsDefaults = { port: 5353, broadcast: '224.0.0.251', ttl: 300 };
config.mdns = Object.assign({}, mdnsDefaults, config.mdns);
config.socks5 = config.socks5 || { enabled: false };
if (!config.tcp) {
config.tcp = {};
}
if (!config.http) {
config.http = { modules: [{ name: 'proxy', domains: ['*'], port: 3000 }] };
}
if (!config.tls) {
config.tls = {};
}
if (!config.tls.acme && (args.email || args.agreeTos)) {
config.tls.acme = {};
}
if (typeof args.agreeTos === 'string') {
config.tls.acme.approvedDomains = args.agreeTos.split(',');
}
if (args.email) {
config.email = args.email;
config.tls.acme.email = args.email;
// Use Object.assign to copy any real config values over the default values so we can
// easily make sure all the fields we need exist .
var mdnsDefaults = { disabled: false, port: 5353, broadcast: '224.0.0.251', ttl: 300 };
config.mdns = Object.assign(mdnsDefaults, config.mdns);
if (!Array.isArray(config.domains)) {
config.domains = [];
}
// maybe this should not go in config... but be ephemeral in some way?
config.cwd = args.cwd || config.cwd || process.cwd();
var ipaddr = require('ipaddr.js');
var addresses = [];
var ifaces = require('../lib/local-ip.js').find();
Object.keys(ifaces).forEach(function (ifacename) {
var iface = ifaces[ifacename];
iface.ipv4.forEach(function (ip) {
addresses.push(ip);
});
iface.ipv6.forEach(function (ip) {
addresses.push(ip);
});
});
addresses.sort(function (a, b) {
if (a.family !== b.family) {
return 'IPv4' === a.family ? 1 : -1;
function fillComponent(name, fillBind) {
if (!config[name]) {
config[name] = {};
}
if (!Array.isArray(config[name].modules)) {
config[name].modules = [];
}
return a.address > b.address ? 1 : -1;
});
if (fillBind && !Array.isArray(config[name].bind)) {
config[name].bind = [];
}
}
fillComponent('udp', true);
fillComponent('tcp', true);
fillComponent('http', false);
fillComponent('tls', false);
fillComponent('ddns', false);
addresses.forEach(function (addr) {
addr.range = ipaddr.parse(addr.address).range();
});
// TODO maybe move to config.state.addresses (?)
config.addresses = addresses;
config.device = { hostname: require('os').hostname() };
config.tunnel = args.tunnel || config.tunnel;
if (Array.isArray(config.tcp.bind)) {
if (Array.isArray(config.tcp.bind) && config.tcp.bind.length) {
return PromiseA.resolve(config);
}
@ -297,6 +424,9 @@ function run(args) {
// TODO spin up multiple workers
// TODO use greenlock-cluster
cluster.fork();
}).catch(function (err) {
console.error(err);
process.exit(1);
})
;
}
@ -310,9 +440,7 @@ function readEnv(args) {
} catch (err) {}
var env = {
tunnel: process.env.GOLDILOCKS_TUNNEL_TOKEN || process.env.GOLDILOCKS_TUNNEL && true
, email: process.env.GOLDILOCKS_EMAIL
, cwd: process.env.GOLDILOCKS_HOME || process.cwd()
cwd: process.env.GOLDILOCKS_HOME || process.cwd()
, debug: process.env.GOLDILOCKS_DEBUG && true
};
@ -323,10 +451,7 @@ var program = require('commander');
program
.version(require('../package.json').version)
.option('--agree-tos [url1,url2]', "agree to all Terms of Service for Daplie, Let's Encrypt, etc (or specific URLs only)")
.option('-c --config <file>', 'Path to config file (Goldilocks.json or Goldilocks.yml) example: --config /etc/goldilocks/Goldilocks.json')
.option('--tunnel [token]', 'Turn tunnel on. This will enter interactive mode for login if no token is specified.')
.option('--email <email>', "(Re)set default email to use for Daplie, Let's Encrypt, ACME, etc.")
.option('--debug', "Enable debug output")
.parse(process.argv);

查看文件

@ -4,90 +4,103 @@ tcp:
- 80
- 443
modules:
- name: forward
- type: forward
ports:
- 22
address: '127.0.0.1:8022'
# tunnel: jwt
# tunnel:
# - jwt1
# - jwt2
udp:
bind:
- 53
modules:
- type: forward
ports:
- 53
port: 5353
# default host is localhost
tunnel_server:
secret: abc123
servernames:
- 'tunnel.localhost.com'
tls:
acme:
email: 'joe.shmoe@example.com'
server: 'https://acme-staging.api.letsencrypt.org/directory'
challenge_type: 'http-01'
approved_domains:
- localhost.baz.daplie.me
- localhost.beta.daplie.me
domains:
- names:
- localhost.gamma.daplie.me
modules:
- name: proxy
address: '127.0.0.1:6443'
- names:
- beta.localhost.daplie.me
- baz.localhost.daplie.me
modules:
- name: acme
email: 'owner@example.com'
challenge_type: 'tls-sni-01'
# default server is 'https://acme-v01.api.letsencrypt.org/directory'
modules:
- name: proxy
- type: proxy
domains:
- localhost.bar.daplie.me
- localhost.foo.daplie.me
address: '127.0.0.1:5443'
- name: acme
- type: acme
domains:
- '*.localhost.daplie.me'
email: 'guest@example.com'
challenge_type: 'http-01'
domains:
- foo.localhost.daplie.me
- gamma.localhost.daplie.me
http:
trust_proxy: true
allow_insecure: false
primary_domain: localhost.foo.daplie.me
domains:
- names:
- localhost.baz.daplie.me
modules:
- name: redirect
from: /nowhere/in/particular
to: /just/an/example
- name: proxy
port: 3001
primary_domain: localhost.daplie.me
modules:
- name: redirect
- type: redirect
domains:
- localhost.beta.daplie.me
status: 301
from: /old/path/*/other/*
to: https://example.com/path/new/:2/something/:1
- name: proxy
- type: proxy
domains:
- localhost.daplie.me
host: localhost
port: 4000
- name: static
- type: static
domains:
- '*.localhost.daplie.me'
root: '/srv/www/:hostname'
domains:
- names:
- localhost.gamma.daplie.me
modules:
tls:
- type: proxy
port: 6443
- names:
- beta.localhost.daplie.me
- baz.localhost.daplie.me
modules:
tls:
- type: acme
email: 'owner@example.com'
challenge_type: 'tls-sni-01'
# default server is 'https://acme-v01.api.letsencrypt.org/directory'
http:
- type: redirect
from: /nowhere/in/particular
to: /just/an/example
- type: proxy
address: '127.0.0.1:3001'
mdns:
disabled: false
port: 5353
broadcast: '224.0.0.251'
ttl: 300
tunnel_server:
secret: abc123
servernames:
- 'tunnel.localhost.com'
ddns:
loopback:
type: 'tunnel@oauth3.org'
domain: oauth3.org
tunnel:
type: 'tunnel@oauth3.org'
token: user_token_id
modules:
- type: 'dns@oauth3.org'
token: user_token_id
domains:
- www.example.com
- api.example.com
- test.example.com

查看文件

@ -21,6 +21,7 @@ module.exports.create = function (deps, conf) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
res.setHeader('Access-Control-Allow-Methods', methods.join(', '));
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
if (req.method.toUpperCase() === 'OPTIONS') {
res.setHeader('Allow', methods.join(', '));
@ -38,6 +39,26 @@ module.exports.create = function (deps, conf) {
return true;
}
}
function makeCorsHandler(methods) {
return function corsHandler(req, res, next) {
if (!handleCors(req, res, methods)) {
next();
}
};
}
function handlePromise(req, res, prom) {
prom.then(function (result) {
res.send(deps.recase.snakeCopy(result));
}).catch(function (err) {
if (conf.debug) {
console.log(err);
}
res.statusCode = err.statusCode || 500;
err.message = err.message || err.toString();
res.end(JSON.stringify({error: {message: err.message, code: err.code}}));
});
}
function isAuthorized(req, res, fn) {
var auth = jwt.decode((req.headers.authorization||'').replace(/^bearer\s+/i, ''));
@ -139,7 +160,10 @@ module.exports.create = function (deps, conf) {
;
}
return {
// This object contains all of the API endpoints written before we changed how
// the API routing is handled. Eventually it will hopefully disappear, but for
// now we're focusing on the things that need changing more.
var oldEndPoints = {
init: function (req, res) {
if (handleCors(req, res, ['GET', 'POST'])) {
return;
@ -235,55 +259,6 @@ module.exports.create = function (deps, conf) {
});
}
, tunnel: function (req, res) {
if (handleCors(req, res)) {
return;
}
isAuthorized(req, res, function () {
if ('POST' !== req.method) {
res.setHeader('Content-Type', 'application/json');
return deps.tunnelClients.get(req.userId).then(function (result) {
res.end(JSON.stringify(result));
}, function (err) {
res.statusCode = 500;
res.end(JSON.stringify({ error: { message: err.message, code: err.code, uri: err.uri } }));
});
}
return deps.storage.owners.get(req.userId).then(function (session) {
return deps.tunnelClients.start(session).then(function () {
res.setHeader('Content-Type', 'application/json;');
res.end(JSON.stringify({ success: true }));
}, function (err) {
res.setHeader('Content-Type', 'application/json;');
res.statusCode = 500;
res.end(JSON.stringify({ error: { message: err.message, code: err.code, uri: err.uri } }));
});
});
});
}
, config: function (req, res) {
if (handleCors(req, res)) {
return;
}
isAuthorized(req, res, function () {
if ('POST' !== req.method) {
res.setHeader('Content-Type', 'application/json;');
res.end(JSON.stringify(deps.recase.snakeCopy(conf)));
return;
}
jsonParser(req, res, function () {
console.log('config POST body', req.body);
// Since we are sending the changes to another process we don't really
// have a good way of seeing if it worked, so always report success
deps.storage.config.save(req.body);
res.setHeader('Content-Type', 'application/json;');
res.end('{"success":true}');
});
});
}
, request: function (req, res) {
if (handleCors(req, res, '*')) {
return;
@ -315,29 +290,6 @@ module.exports.create = function (deps, conf) {
});
});
}
, loopback: function (req, res) {
if (handleCors(req, res, 'GET')) {
return;
}
isAuthorized(req, res, function () {
var prom;
var query = require('querystring').parse(require('url').parse(req.url).query);
if (query.provider) {
prom = deps.loopback(query.provider);
} else {
prom = deps.storage.owners.get(req.userId).then(function (session) {
return deps.loopback(session.token.aud);
});
}
res.setHeader('Content-Type', 'application/json');
prom.then(function (result) {
res.end(JSON.stringify(result));
}, function (err) {
res.end(JSON.stringify({error: {message: err.message, code: err.code}}));
});
});
}
, paywall_check: function (req, res) {
if (handleCors(req, res, 'GET')) {
return;
@ -381,4 +333,253 @@ module.exports.create = function (deps, conf) {
});
}
};
function handleOldApis(req, res, next) {
if (typeof oldEndPoints[req.params.name] === 'function') {
oldEndPoints[req.params.name](req, res);
} else {
next();
}
}
var config = { restful: {} };
config.restful.readConfig = function (req, res, next) {
var part = new (require('./config').ConfigChanger)(conf);
if (req.params.group) {
part = part[req.params.group];
}
if (part && req.params.domId) {
part = part.domains.findId(req.params.domId);
}
if (part && req.params.mod) {
part = part[req.params.mod];
}
if (part && req.params.modGrp) {
part = part[req.params.modGrp];
}
if (part && req.params.modId) {
part = part.findId(req.params.modId);
}
if (part) {
res.send(deps.recase.snakeCopy(part));
} else {
next();
}
};
config.save = function (changer) {
var errors = changer.validate();
if (errors.length) {
throw Object.assign(new Error(), errors[0], {statusCode: 400});
}
return deps.storage.config.save(changer);
};
config.restful.saveBaseConfig = function (req, res, next) {
console.log('config POST body', JSON.stringify(req.body));
if (req.params.group === 'domains') {
next();
return;
}
var promise = deps.PromiseA.resolve().then(function () {
var update;
if (req.params.group) {
update = {};
update[req.params.group] = req.body;
} else {
update = req.body;
}
var changer = new (require('./config').ConfigChanger)(conf);
changer.update(update);
return config.save(changer);
}).then(function (newConf) {
if (req.params.group) {
return newConf[req.params.group];
}
return newConf;
});
handlePromise(req, res, promise);
};
config.extractModList = function (changer, params) {
var err;
if (params.domId) {
var dom = changer.domains.find(function (dom) {
return dom.id === params.domId;
});
if (!dom) {
err = new Error("no domain with ID '"+params.domId+"'");
} else if (!dom.modules[params.group]) {
err = new Error("domains don't contain '"+params.group+"' modules");
} else {
return dom.modules[params.group];
}
} else {
if (!changer[params.group] || !changer[params.group].modules) {
err = new Error("'"+params.group+"' is not a valid settings group or doesn't support modules");
} else {
return changer[params.group].modules;
}
}
err.statusCode = 404;
throw err;
};
config.restful.createModule = function (req, res, next) {
if (req.params.group === 'domains') {
next();
return;
}
var promise = deps.PromiseA.resolve().then(function () {
var changer = new (require('./config').ConfigChanger)(conf);
var modList = config.extractModList(changer, req.params);
var update = req.body;
if (!Array.isArray(update)) {
update = [ update ];
}
update.forEach(modList.add, modList);
return config.save(changer);
}).then(function (newConf) {
return config.extractModList(newConf, req.params);
});
handlePromise(req, res, promise);
};
config.restful.updateModule = function (req, res, next) {
if (req.params.group === 'domains') {
next();
return;
}
var promise = deps.PromiseA.resolve().then(function () {
var changer = new (require('./config').ConfigChanger)(conf);
var modList = config.extractModList(changer, req.params);
modList.update(req.params.modId, req.body);
return config.save(changer);
}).then(function (newConf) {
return config.extractModule(newConf, req.params).find(function (mod) {
return mod.id === req.params.modId;
});
});
handlePromise(req, res, promise);
};
config.restful.removeModule = function (req, res, next) {
if (req.params.group === 'domains') {
next();
return;
}
var promise = deps.PromiseA.resolve().then(function () {
var changer = new (require('./config').ConfigChanger)(conf);
var modList = config.extractModList(changer, req.params);
modList.remove(req.params.modId);
return config.save(changer);
}).then(function (newConf) {
return config.extractModList(newConf, req.params);
});
handlePromise(req, res, promise);
};
config.restful.createDomain = function (req, res) {
var promise = deps.PromiseA.resolve().then(function () {
var changer = new (require('./config').ConfigChanger)(conf);
var update = req.body;
if (!Array.isArray(update)) {
update = [ update ];
}
update.forEach(changer.domains.add, changer.domains);
return config.save(changer);
}).then(function (newConf) {
return newConf.domains;
});
handlePromise(req, res, promise);
};
config.restful.updateDomain = function (req, res) {
var promise = deps.PromiseA.resolve().then(function () {
if (req.body.modules) {
throw Object.assign(new Error('do not add modules with this route'), {statusCode: 400});
}
var changer = new (require('./config').ConfigChanger)(conf);
changer.domains.update(req.params.domId, req.body);
return config.save(changer);
}).then(function (newConf) {
return newConf.domains.find(function (dom) {
return dom.id === req.params.domId;
});
});
handlePromise(req, res, promise);
};
config.restful.removeDomain = function (req, res) {
var promise = deps.PromiseA.resolve().then(function () {
var changer = new (require('./config').ConfigChanger)(conf);
changer.domains.remove(req.params.domId);
return config.save(changer);
}).then(function (newConf) {
return newConf.domains;
});
handlePromise(req, res, promise);
};
var tokens = { restful: {} };
tokens.restful.getAll = function (req, res) {
handlePromise(req, res, deps.storage.tokens.all());
};
tokens.restful.getOne = function (req, res) {
handlePromise(req, res, deps.storage.tokens.get(req.params.id));
};
tokens.restful.save = function (req, res) {
handlePromise(req, res, deps.storage.tokens.save(req.body));
};
tokens.restful.revoke = function (req, res) {
var promise = deps.storage.tokens.remove(req.params.id).then(function (success) {
return {success: success};
});
handlePromise(req, res, promise);
};
var app = require('express')();
// Handle all of the API endpoints using the old definition style, and then we can
// add middleware without worrying too much about the consequences to older code.
app.use('/:name', handleOldApis);
// Not all routes support all of these methods, but not worth making this more specific
app.use('/', makeCorsHandler(['GET', 'POST', 'PUT', 'DELETE']), isAuthorized, jsonParser);
app.get( '/config', config.restful.readConfig);
app.get( '/config/:group', config.restful.readConfig);
app.get( '/config/:group/:mod(modules)/:modId?', config.restful.readConfig);
app.get( '/config/domains/:domId/:mod(modules)?', config.restful.readConfig);
app.get( '/config/domains/:domId/:mod(modules)/:modGrp/:modId?', config.restful.readConfig);
app.post( '/config', config.restful.saveBaseConfig);
app.post( '/config/:group', config.restful.saveBaseConfig);
app.post( '/config/:group/modules', config.restful.createModule);
app.put( '/config/:group/modules/:modId', config.restful.updateModule);
app.delete('/config/:group/modules/:modId', config.restful.removeModule);
app.post( '/config/domains/:domId/modules/:group', config.restful.createModule);
app.put( '/config/domains/:domId/modules/:group/:modId', config.restful.updateModule);
app.delete('/config/domains/:domId/modules/:group/:modId', config.restful.removeModule);
app.post( '/config/domains', config.restful.createDomain);
app.put( '/config/domains/:domId', config.restful.updateDomain);
app.delete('/config/domains/:domId', config.restful.removeDomain);
app.get( '/tokens', tokens.restful.getAll);
app.get( '/tokens/:id', tokens.restful.getOne);
app.post( '/tokens', tokens.restful.save);
app.delete('/tokens/:id', tokens.restful.revoke);
return app;
};

379
lib/admin/config.js Normal file
查看文件

@ -0,0 +1,379 @@
'use strict';
var validator = new (require('jsonschema').Validator)();
var recase = require('recase').create({});
var portSchema = { type: 'number', minimum: 1, maximum: 65535 };
var moduleSchemas = {
// the proxy module is common to basically all categories.
proxy: {
type: 'object'
, oneOf: [
{ required: [ 'address' ] }
, { required: [ 'port' ] }
]
, properties: {
address: { type: 'string' }
, host: { type: 'string' }
, port: portSchema
}
}
// redirect and static modules are for HTTP
, redirect: {
type: 'object'
, required: [ 'to', 'from' ]
, properties: {
to: { type: 'string'}
, from: { type: 'string'}
, status: { type: 'integer', minimum: 1, maximum: 999 }
, }
}
, static: {
type: 'object'
, required: [ 'root' ]
, properties: {
root: { type: 'string' }
}
}
// the acme module is for TLS
, acme: {
type: 'object'
, required: [ 'email' ]
, properties: {
email: { type: 'string' }
, server: { type: 'string' }
, challenge_type: { type: 'string' }
}
}
// the dns control modules for DDNS
, 'dns@oauth3.org': {
type: 'object'
, required: [ 'token_id' ]
, properties: {
token_id: { type: 'string' }
}
}
};
// forward is basically the same as proxy, but specifies the relevant incoming port(s).
// only allows for the raw transport layers (TCP/UDP)
moduleSchemas.forward = JSON.parse(JSON.stringify(moduleSchemas.proxy));
moduleSchemas.forward.required = [ 'ports' ];
moduleSchemas.forward.properties.ports = { type: 'array', items: portSchema };
Object.keys(moduleSchemas).forEach(function (name) {
var schema = moduleSchemas[name];
schema.id = '/modules/'+name;
schema.required = ['id', 'type'].concat(schema.required || []);
schema.properties.id = { type: 'string' };
schema.properties.type = { type: 'string', const: name };
validator.addSchema(schema, schema.id);
});
function toSchemaRef(name) {
return { '$ref': '/modules/'+name };
}
var moduleRefs = {
http: [ 'proxy', 'static', 'redirect' ].map(toSchemaRef)
, tls: [ 'proxy', 'acme' ].map(toSchemaRef)
, tcp: [ 'forward' ].map(toSchemaRef)
, udp: [ 'forward' ].map(toSchemaRef)
, ddns: [ 'dns@oauth3.org' ].map(toSchemaRef)
};
function addDomainRequirement(itemSchema) {
itemSchema.required = (itemSchema.required || []).concat('domains');
itemSchema.properties = itemSchema.properties || {};
itemSchema.properties.domains = { type: 'array', items: { type: 'string' }, minLength: 1};
return itemSchema;
}
var domainSchema = {
type: 'array'
, items: {
type: 'object'
, properties: {
id: { type: 'string' }
, names: { type: 'array', items: { type: 'string' }, minLength: 1}
, modules: {
type: 'object'
, properties: {
tls: { type: 'array', items: { oneOf: moduleRefs.tls }}
, http: { type: 'array', items: { oneOf: moduleRefs.http }}
, ddns: { type: 'array', items: { oneOf: moduleRefs.ddns }}
}
, additionalProperties: false
}
}
}
};
var httpSchema = {
type: 'object'
, properties: {
modules: { type: 'array', items: addDomainRequirement({ oneOf: moduleRefs.http }) }
// These properties should be snake_case to match the API and config format
, primary_domain: { type: 'string' }
, allow_insecure: { type: 'boolean' }
, trust_proxy: { type: 'boolean' }
// these are forbidden deprecated settings.
, bind: { not: {} }
, domains: { not: {} }
}
};
var tlsSchema = {
type: 'object'
, properties: {
modules: { type: 'array', items: addDomainRequirement({ oneOf: moduleRefs.tls }) }
// these are forbidden deprecated settings.
, acme: { not: {} }
, bind: { not: {} }
, domains: { not: {} }
}
};
var tcpSchema = {
type: 'object'
, required: [ 'bind' ]
, properties: {
bind: { type: 'array', items: portSchema, minLength: 1 }
, modules: { type: 'array', items: { oneOf: moduleRefs.tcp }}
}
};
var udpSchema = {
type: 'object'
, properties: {
bind: { type: 'array', items: portSchema }
, modules: { type: 'array', items: { oneOf: moduleRefs.udp }}
}
};
var mdnsSchema = {
type: 'object'
, required: [ 'port', 'broadcast', 'ttl' ]
, properties: {
port: portSchema
, broadcast: { type: 'string' }
, ttl: { type: 'integer', minimum: 0, maximum: 2147483647 }
}
};
var ddnsSchema = {
type: 'object'
, properties: {
loopback: {
type: 'object'
, required: [ 'type', 'domain' ]
, properties: {
type: { type: 'string', const: 'tunnel@oauth3.org' }
, domain: { type: 'string'}
}
}
, tunnel: {
type: 'object'
, required: [ 'type', 'token_id' ]
, properties: {
type: { type: 'string', const: 'tunnel@oauth3.org' }
, token_id: { type: 'string'}
}
}
, modules: { type: 'array', items: { oneOf: moduleRefs.ddns }}
}
};
var socks5Schema = {
type: 'object'
, properties: {
enabled: { type: 'boolean' }
, port: portSchema
}
};
var deviceSchema = {
type: 'object'
, properties: {
hostname: { type: 'string' }
}
};
var mainSchema = {
type: 'object'
, required: [ 'domains', 'http', 'tls', 'tcp', 'udp', 'mdns', 'ddns' ]
, properties: {
domains:domainSchema
, http: httpSchema
, tls: tlsSchema
, tcp: tcpSchema
, udp: udpSchema
, mdns: mdnsSchema
, ddns: ddnsSchema
, socks5: socks5Schema
, device: deviceSchema
}
, additionalProperties: false
};
function validate(config) {
return validator.validate(recase.snakeCopy(config), mainSchema).errors;
}
module.exports.validate = validate;
class IdList extends Array {
constructor(rawList) {
super();
if (Array.isArray(rawList)) {
Object.assign(this, JSON.parse(JSON.stringify(rawList)));
}
this._itemName = 'item';
}
findId(id) {
return Array.prototype.find.call(this, function (dom) {
return dom.id === id;
});
}
add(item) {
item.id = require('crypto').randomBytes(4).toString('hex');
this.push(item);
}
update(id, update) {
var item = this.findId(id);
if (!item) {
var error = new Error("no "+this._itemName+" with ID '"+id+"'");
error.statusCode = 404;
throw error;
}
Object.assign(this.findId(id), update);
}
remove(id) {
var index = this.findIndex(function (dom) {
return dom.id === id;
});
if (index < 0) {
var error = new Error("no "+this._itemName+" with ID '"+id+"'");
error.statusCode = 404;
throw error;
}
this.splice(index, 1);
}
}
class ModuleList extends IdList {
constructor(rawList) {
super(rawList);
this._itemName = 'module';
}
add(mod) {
if (!mod.type) {
throw new Error("module must have a 'type' defined");
}
if (!moduleSchemas[mod.type]) {
throw new Error("invalid module type '"+mod.type+"'");
}
mod.id = require('crypto').randomBytes(4).toString('hex');
this.push(mod);
}
}
class DomainList extends IdList {
constructor(rawList) {
super(rawList);
this._itemName = 'domain';
this.forEach(function (dom) {
dom.modules = {
http: new ModuleList((dom.modules || {}).http)
, tls: new ModuleList((dom.modules || {}).tls)
, ddns: new ModuleList((dom.modules || {}).ddns)
};
});
}
add(dom) {
if (!Array.isArray(dom.names) || !dom.names.length) {
throw new Error("domains must have a non-empty array for 'names'");
}
if (dom.names.some(function (name) { return typeof name !== 'string'; })) {
throw new Error("all domain names must be strings");
}
var modLists = {
http: new ModuleList()
, tls: new ModuleList()
, ddns: new ModuleList()
};
// We add these after instead of in the constructor to run the validation and manipulation
// in the ModList add function since these are all new modules.
if (dom.modules) {
Object.keys(modLists).forEach(function (key) {
if (Array.isArray(dom.modules[key])) {
dom.modules[key].forEach(modLists[key].add, modLists[key]);
}
});
}
dom.id = require('crypto').randomBytes(4).toString('hex');
dom.modules = modLists;
this.push(dom);
}
}
class ConfigChanger {
constructor(start) {
Object.assign(this, JSON.parse(JSON.stringify(start)));
delete this.device;
delete this.debug;
this.domains = new DomainList(this.domains);
this.http.modules = new ModuleList(this.http.modules);
this.tls.modules = new ModuleList(this.tls.modules);
this.tcp.modules = new ModuleList(this.tcp.modules);
this.udp.modules = new ModuleList(this.udp.modules);
this.ddns.modules = new ModuleList(this.ddns.modules);
}
update(update) {
var self = this;
if (update.domains) {
update.domains.forEach(self.domains.add, self.domains);
}
[ 'http', 'tls', 'tcp', 'udp', 'ddns' ].forEach(function (name) {
if (update[name] && update[name].modules) {
update[name].modules.forEach(self[name].modules.add, self[name].modules);
delete update[name].modules;
}
});
function mergeSettings(orig, changes) {
Object.keys(changes).forEach(function (key) {
// TODO: use an API that can properly handle updating arrays.
if (!changes[key] || (typeof changes[key] !== 'object') || Array.isArray(changes[key])) {
orig[key] = changes[key];
}
else if (!orig[key] || typeof orig[key] !== 'object') {
orig[key] = changes[key];
}
else {
mergeSettings(orig[key], changes[key]);
}
});
}
mergeSettings(this, update);
return validate(this);
}
validate() {
return validate(this);
}
}
module.exports.ConfigChanger = ConfigChanger;

31
lib/admin/index.js Normal file
查看文件

@ -0,0 +1,31 @@
var adminDomains = [
'localhost.alpha.daplie.me'
, 'localhost.admin.daplie.me'
, 'alpha.localhost.daplie.me'
, 'admin.localhost.daplie.me'
, 'localhost.daplie.invalid'
];
module.exports.adminDomains = adminDomains;
module.exports.create = function (deps, conf) {
'use strict';
var path = require('path');
var express = require('express');
var app = express();
var apis = require('./apis').create(deps, conf);
app.use('/api/goldilocks@daplie.com', apis);
app.use('/api/com.daplie.goldilocks', apis);
// Serve the static assets for the UI (even though it probably won't be used very
// often since it only works on localhost domains). Note that we are using the default
// .well-known directory from the oauth3 library even though it indicates we have
// capabilities we don't support because it's simpler and it's unlikely anything will
// actually use it to determine our API (it is needed to log into the web page).
app.use('/.well-known', express.static(path.join(__dirname, '../../packages/assets/well-known')));
app.use('/assets', express.static(path.join(__dirname, '../../packages/assets')));
app.use('/', express.static(path.join(__dirname, '../../admin/public')));
return require('http').createServer(app);
};

查看文件

@ -1,325 +0,0 @@
'use strict';
module.exports = function (myDeps, conf, overrideHttp) {
var express = require('express');
//var finalhandler = require('finalhandler');
var serveStatic = require('serve-static');
var serveIndex = require('serve-index');
//var assetServer = serveStatic(opts.assetsPath);
var path = require('path');
//var wellKnownServer = serveStatic(path.join(opts.assetsPath, 'well-known'));
var serveStaticMap = {};
var serveIndexMap = {};
var content = conf.content;
//var server;
var goldilocksApis;
var app;
var request;
function createGoldilocksApis() {
var PromiseA = require('bluebird');
var OAUTH3 = require('../packages/assets/org.oauth3');
require('../packages/assets/org.oauth3/oauth3.domains.js');
require('../packages/assets/org.oauth3/oauth3.dns.js');
require('../packages/assets/org.oauth3/oauth3.tunnel.js');
OAUTH3._hooks = require('../packages/assets/org.oauth3/oauth3.node.storage.js');
request = request || PromiseA.promisify(require('request'));
myDeps.PromiseA = PromiseA;
myDeps.OAUTH3 = OAUTH3;
myDeps.recase = require('recase').create({});
myDeps.request = request;
return require('../packages/apis/com.daplie.goldilocks').create(myDeps, conf);
}
app = express();
var Sites = {
add: function (sitesMap, site) {
if (!sitesMap[site.$id]) {
sitesMap[site.$id] = site;
}
if (!site.paths) {
site.paths = [];
}
if (!site.paths._map) {
site.paths._map = {};
}
site.paths.forEach(function (path) {
site.paths._map[path.$id] = path;
if (!path.modules) {
path.modules = [];
}
if (!path.modules._map) {
path.modules._map = {};
}
path.modules.forEach(function (module) {
path.modules._map[module.$id] = module;
});
});
}
};
var opts = overrideHttp || conf.http;
if (!opts.defaults) {
opts.defaults = {};
}
if (!opts.global) {
opts.global = {};
}
if (!opts.sites) {
opts.sites = [];
}
opts.sites._map = {};
opts.sites.forEach(function (site) {
Sites.add(opts.sites._map, site);
});
function mapMap(el, i, arr) {
arr._map[el.$id] = el;
}
opts.global.modules._map = {};
opts.global.modules.forEach(mapMap);
opts.global.paths._map = {};
opts.global.paths.forEach(function (path, i, arr) {
mapMap(path, i, arr);
//opts.global.paths._map[path.$id] = path;
path.modules._map = {};
path.modules.forEach(mapMap);
});
opts.sites.forEach(function (site) {
site.paths._map = {};
site.paths.forEach(function (path, i, arr) {
mapMap(path, i, arr);
//site.paths._map[path.$id] = path;
path.modules._map = {};
path.modules.forEach(mapMap);
});
});
opts.defaults.modules._map = {};
opts.defaults.modules.forEach(mapMap);
opts.defaults.paths._map = {};
opts.defaults.paths.forEach(function (path, i, arr) {
mapMap(path, i, arr);
//opts.global.paths._map[path.$id] = path;
path.modules._map = {};
path.modules.forEach(mapMap);
});
function _goldApis(req, res, next) {
if (!goldilocksApis) {
goldilocksApis = createGoldilocksApis();
}
if (typeof goldilocksApis[req.params.name] === 'function') {
goldilocksApis[req.params.name](req, res);
} else {
next();
}
}
return app
.use('/api/com.daplie.goldilocks/:name', _goldApis)
.use('/api/goldilocks@daplie.com/:name', _goldApis)
.use('/', function (req, res, next) {
if (!req.headers.host) {
next(new Error('missing HTTP Host header'));
return;
}
if (content && '/' === req.url) {
// res.setHeader('Content-Type', 'application/octet-stream');
res.end(content);
return;
}
//var done = finalhandler(req, res);
var host = req.headers.host;
var hostname = (host||'').split(':')[0].toLowerCase();
console.log('opts.global', opts.global);
var sites = [ opts.global || null, opts.sites._map[hostname] || null, opts.defaults || null ];
var loadables = {
serve: function (config, hostname, pathname, req, res, next) {
var originalUrl = req.url;
var dirpaths = config.paths.slice(0);
function nextServe() {
var dirname = dirpaths.pop();
if (!dirname) {
req.url = originalUrl;
next();
return;
}
console.log('[serve]', req.url, hostname, pathname, dirname);
dirname = path.resolve(conf.cwd, dirname.replace(/:hostname/, hostname));
if (!serveStaticMap[dirname]) {
serveStaticMap[dirname] = serveStatic(dirname);
}
serveStaticMap[dirname](req, res, nextServe);
}
req.url = req.url.substr(pathname.length - 1);
nextServe();
}
, indexes: function (config, hostname, pathname, req, res, next) {
var originalUrl = req.url;
var dirpaths = config.paths.slice(0);
function nextIndex() {
var dirname = dirpaths.pop();
if (!dirname) {
req.url = originalUrl;
next();
return;
}
console.log('[indexes]', req.url, hostname, pathname, dirname);
dirname = path.resolve(conf.cwd, dirname.replace(/:hostname/, hostname));
if (!serveStaticMap[dirname]) {
serveIndexMap[dirname] = serveIndex(dirname);
}
serveIndexMap[dirname](req, res, nextIndex);
}
req.url = req.url.substr(pathname.length - 1);
nextIndex();
}
, app: function (config, hostname, pathname, req, res, next) {
//var appfile = path.resolve(/*process.cwd(), */config.path.replace(/:hostname/, hostname));
var appfile = config.path.replace(/:hostname/, hostname);
try {
var app = require(appfile);
app(req, res, next);
} catch (err) {
next();
}
}
};
function runModule(module, hostname, pathname, modulename, req, res, next) {
if (!loadables[modulename]) {
next(new Error("no module '" + modulename + "' found"));
return;
}
loadables[modulename](module, hostname, pathname, req, res, next);
}
function iterModules(modules, hostname, pathname, req, res, next) {
console.log('modules');
console.log(modules);
var modulenames = Object.keys(modules._map);
function nextModule() {
var modulename = modulenames.pop();
if (!modulename) {
next();
return;
}
console.log('modules', modules);
runModule(modules._map[modulename], hostname, pathname, modulename, req, res, nextModule);
}
nextModule();
}
function iterPaths(site, hostname, req, res, next) {
console.log('site', hostname);
console.log(site);
var pathnames = Object.keys(site.paths._map);
console.log('pathnames', pathnames);
pathnames = pathnames.filter(function (pathname) {
// TODO ensure that pathname has trailing /
return (0 === req.url.indexOf(pathname));
//return req.url.match(pathname);
});
pathnames.sort(function (a, b) {
return b.length - a.length;
});
console.log('pathnames', pathnames);
function nextPath() {
var pathname = pathnames.shift();
if (!pathname) {
next();
return;
}
console.log('iterPaths', hostname, pathname, req.url);
iterModules(site.paths._map[pathname].modules, hostname, pathname, req, res, nextPath);
}
nextPath();
}
function nextSite() {
console.log('hostname', hostname, sites);
var site;
if (!sites.length) {
next(); // 404
return;
}
site = sites.shift();
if (!site) {
nextSite();
return;
}
iterPaths(site, hostname, req, res, nextSite);
}
nextSite();
/*
function serveStaticly(server) {
function serveTheStatic() {
server.serve(req, res, function (err) {
if (err) { return done(err); }
server.index(req, res, function (err) {
if (err) { return done(err); }
req.url = req.url.replace(/\/assets/, '');
assetServer(req, res, function () {
if (err) { return done(err); }
req.url = req.url.replace(/\/\.well-known/, '');
wellKnownServer(req, res, done);
});
});
});
}
if (server.expressApp) {
server.expressApp(req, res, serveTheStatic);
return;
}
serveTheStatic();
}
if (opts.livereload) {
res.__my_livereload = '<script src="//'
+ (host || opts.sites[0].name).split(':')[0]
+ ':35729/livereload.js?snipver=1"></script>';
res.__my_addLen = res.__my_livereload.length;
// TODO modify prototype instead of each instance?
res.__write = res.write;
res.write = _reloadWrite;
}
console.log('hostname:', hostname, opts.sites[0].paths);
addServer(hostname);
server = hostsMap[hostname] || hostsMap[opts.sites[0].name];
serveStaticly(server);
*/
});
};

查看文件

@ -1,149 +0,0 @@
'use strict';
module.exports.create = function (deps, conf) {
var PromiseA = deps.PromiseA;
var request = PromiseA.promisify(require('request'));
var OAUTH3 = require('../packages/assets/org.oauth3');
require('../packages/assets/org.oauth3/oauth3.dns.js');
OAUTH3._hooks = require('../packages/assets/org.oauth3/oauth3.node.storage.js');
function dnsType(addr) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(addr)) {
return 'A';
}
if (-1 !== addr.indexOf(':') && /^[a-f:\.\d]+$/i.test(addr)) {
return 'AAAA';
}
}
async function getSession() {
var sessions = await deps.storage.owners.all();
var session = sessions.filter(function (sess) {
return sess.token.scp.indexOf('dns') >= 0;
})[0];
if (!session) {
throw new Error('no sessions with DNS grants');
}
// The OAUTH3 library stores some things on the root session object that we usually
// just leave inside the token, but we need to pull those out before we use it here
session.provider_uri = session.provider_uri || session.token.provider_uri || session.token.iss;
session.client_uri = session.client_uri || session.token.azp;
session.scope = session.scope || session.token.scp;
return session;
}
async function setDeviceAddress(addr) {
var session = await getSession();
var directives = await OAUTH3.discover(session.token.aud);
// Set the address of the device to our public address.
await request({
url: directives.api+'/api/com.daplie.domains/acl/devices/' + conf.device.hostname
, method: 'POST'
, headers: {
'Authorization': 'Bearer ' + session.refresh_token
, 'Accept': 'application/json; charset=utf-8'
}
, json: {
addresses: [
{ value: addr, type: dnsType(addr) }
]
}
});
// Then update all of the records attached to our hostname, first removing the old records
// to remove the reference to the old address, then creating new records for the same domains
// using our new address.
var allDns = OAUTH3.api(directives.api, {session: session, api: 'dns.list'});
var ourDomains = allDns.filter(function (record) {
return record.device === conf.device.hostname;
}).map(function (record) {
var zoneSplit = record.zone.split('.');
return {
tld: zoneSplit.slice(1).join('.')
, sld: zoneSplit[0]
, sub: record.host.slice(0, -(record.zone.length + 1))
};
});
var common = {
api: 'devices.detach'
, session: session
, device: conf.device.hostname
};
await PromiseA.all(ourDomains.map(function (record) {
return OAUTH3.api(directives.api, Object.assign({}, common, record));
}));
common = {
api: 'devices.attach'
, session: session
, device: conf.device.hostname
, ip: addr
, ttl: 300
};
await PromiseA.all(ourDomains.map(function (record) {
return OAUTH3.api(directives.api, Object.assign({}, common, record));
}));
}
async function getDeviceAddresses() {
var session = await getSession();
var directives = await OAUTH3.discover(session.token.aud);
var result = await request({
url: directives.api+'/api/org.oauth3.dns/acl/devices'
, method: 'GET'
, headers: {
'Authorization': 'Bearer ' + session.refresh_token
, 'Accept': 'application/json; charset=utf-8'
}
, json: true
});
if (!result.body) {
throw new Error('No response body in request for device addresses');
}
if (result.body.error) {
throw Object.assign(new Error('error getting device list'), result.body.error);
}
var dev = result.body.devices.filter(function (dev) {
return dev.name === conf.device.hostname;
})[0];
return (dev || {}).addresses || [];
}
var publicAddress;
async function recheckPubAddr() {
if (!conf.ddns.enabled) {
return;
}
var session = await getSession();
var directives = await OAUTH3.discover(session.token.aud);
var addr = await deps.loopback.checkPublicAddr(directives.api);
if (publicAddress === addr) {
return;
}
if (conf.debug) {
console.log('previous public address',publicAddress, 'does not match current public address', addr);
}
await setDeviceAddress(addr);
publicAddress = addr;
}
recheckPubAddr();
setInterval(recheckPubAddr, 5*60*1000);
return {
setDeviceAddress: setDeviceAddress
, getDeviceAddresses: getDeviceAddresses
, recheckPubAddr: recheckPubAddr
};
};

182
lib/ddns/dns-ctrl.js Normal file
查看文件

@ -0,0 +1,182 @@
'use strict';
module.exports.create = function (deps, conf) {
function dnsType(addr) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(addr)) {
return 'A';
}
if (-1 !== addr.indexOf(':') && /^[a-f:\.\d]+$/i.test(addr)) {
return 'AAAA';
}
}
var tldCache = {};
async function getTlds(provider) {
async function updateCache() {
var reqObj = {
url: deps.OAUTH3.url.normalize(provider)+'/api/com.daplie.domains/prices'
, method: 'GET'
, json: true
};
var resp = await deps.OAUTH3.request(reqObj);
var tldObj = {};
resp.data.forEach(function (tldInfo) {
if (tldInfo.enabled) {
tldObj[tldInfo.tld] = true;
}
});
tldCache[provider] = {
time: Date.now()
, tlds: tldObj
};
return tldObj;
}
// If we've never cached the results we need to return the promise that will fetch the recult,
// otherwise we can return the cached value. If the cached value has "expired", we can still
// return the cached value we just want to update the cache in parellel (making sure we only
// update once).
if (!tldCache[provider]) {
return updateCache();
}
if (!tldCache[provider].updating && Date.now() - tldCache[provider].time > 24*60*60*1000) {
tldCache[provider].updating = true;
updateCache();
}
return tldCache[provider].tlds;
}
async function splitDomains(provider, domains) {
var tlds = await getTlds(provider);
return domains.map(function (domain) {
var split = domain.split('.');
var tldSegCnt = tlds[split.slice(-2).join('.')] ? 2 : 1;
// Currently assuming that the sld can't contain dots, and that the tld can have at
// most one dot. Not 100% sure this is a valid assumption, but exceptions should be
// rare even if the assumption isn't valid.
return {
tld: split.slice(-tldSegCnt).join('.')
, sld: split.slice(-tldSegCnt-1, -tldSegCnt).join('.')
, sub: split.slice(0, -tldSegCnt-1).join('.')
};
});
}
async function setDeviceAddress(session, addr, domains) {
var directives = await deps.OAUTH3.discover(session.token.aud);
// Set the address of the device to our public address.
await deps.request({
url: deps.OAUTH3.url.normalize(directives.api)+'/api/com.daplie.domains/acl/devices/' + conf.device.hostname
, method: 'POST'
, headers: {
'Authorization': 'Bearer ' + session.refresh_token
, 'Accept': 'application/json; charset=utf-8'
}
, json: {
addresses: [
{ value: addr, type: dnsType(addr) }
]
}
});
// Then update all of the records attached to our hostname, first removing the old records
// to remove the reference to the old address, then creating new records for the same domains
// using our new address.
var allDns = await deps.OAUTH3.api(directives.api, {session: session, api: 'dns.list'});
var ourDns = allDns.filter(function (record) {
if (record.device !== conf.device.hostname) {
return false;
}
if ([ 'A', 'AAAA' ].indexOf(record.type) < 0) {
return false;
}
return domains.indexOf(record.host) !== -1;
});
// Of all the DNS records referring to our device and the current list of domains determine
// which domains have records with outdated address, and which ones we can just leave be
// without updating them.
var badAddrDomains = ourDns.filter(function (record) {
return record.value !== addr;
}).map(record => record.host);
var goodAddrDomains = ourDns.filter(function (record) {
return record.value === addr && badAddrDomains.indexOf(record.host) < 0;
}).map(record => record.host);
var requiredUpdates = domains.filter(function (domain) {
return goodAddrDomains.indexOf(domain) !== -1;
});
var oldDns = await splitDomains(directives.api, badAddrDomains);
var common = {
api: 'devices.detach'
, session: session
, device: conf.device.hostname
};
await deps.PromiseA.all(oldDns.map(function (record) {
return deps.OAUTH3.api(directives.api, Object.assign({}, common, record));
}));
var newDns = await splitDomains(directives.api, requiredUpdates);
common = {
api: 'devices.attach'
, session: session
, device: conf.device.hostname
, ip: addr
, ttl: 300
};
await deps.PromiseA.all(newDns.map(function (record) {
return deps.OAUTH3.api(directives.api, Object.assign({}, common, record));
}));
}
async function getDeviceAddresses(session) {
var directives = await deps.OAUTH3.discover(session.token.aud);
var result = await deps.request({
url: deps.OAUTH3.url.normalize(directives.api)+'/api/org.oauth3.dns/acl/devices'
, method: 'GET'
, headers: {
'Authorization': 'Bearer ' + session.refresh_token
, 'Accept': 'application/json; charset=utf-8'
}
, json: true
});
if (!result.body) {
throw new Error('No response body in request for device addresses');
}
if (result.body.error) {
throw Object.assign(new Error('error getting device list'), result.body.error);
}
var dev = result.body.devices.filter(function (dev) {
return dev.name === conf.device.hostname;
})[0];
return (dev || {}).addresses || [];
}
async function removeDomains(session, domains) {
var directives = await deps.OAUTH3.discover(session.token.aud);
var oldDns = await splitDomains(directives.api, domains);
var common = {
api: 'devices.detach'
, session: session
, device: conf.device.hostname
};
await deps.PromiseA.all(oldDns.map(function (record) {
return deps.OAUTH3.api(directives.api, Object.assign({}, common, record));
}));
}
return {
getDeviceAddresses
, setDeviceAddress
, removeDomains
};
};

329
lib/ddns/index.js Normal file
查看文件

@ -0,0 +1,329 @@
'use strict';
module.exports.create = function (deps, conf) {
var dns = deps.PromiseA.promisifyAll(require('dns'));
var network = deps.PromiseA.promisifyAll(deps.recase.camelCopy(require('network')));
var loopback = require('./loopback').create(deps, conf);
var dnsCtrl = require('./dns-ctrl').create(deps, conf);
var equal = require('deep-equal');
var loopbackDomain;
function iterateAllModules(action, curConf) {
curConf = curConf || conf;
var promises = curConf.ddns.modules.map(function (mod) {
return action(mod, mod.domains);
});
curConf.domains.forEach(function (dom) {
if (!dom.modules || !Array.isArray(dom.modules.ddns) || !dom.modules.ddns.length) {
return null;
}
// For the time being all of our things should only be tried once (regardless if it succeeded)
// TODO: revisit this behavior when we support multiple ways of setting records, and/or
// if we want to allow later modules to run if early modules fail.
promises.push(dom.modules.ddns.reduce(function (prom, mod) {
if (prom) { return prom; }
return action(mod, dom.names);
}, null));
});
return deps.PromiseA.all(promises.filter(Boolean));
}
async function getSession(id) {
var session = await deps.storage.tokens.get(id);
if (!session) {
throw new Error('no user token with ID "'+id+'"');
}
return session;
}
var tunnelActive = false;
async function startTunnel(tunnelSession, mod, domainList) {
try {
var dnsSession = await getSession(mod.tokenId);
var tunnelDomain = await deps.tunnelClients.start(tunnelSession || dnsSession, domainList);
var addrList;
try {
addrList = await dns.resolve4Async(tunnelDomain);
} catch (e) {}
if (!addrList || !addrList.length) {
try {
addrList = await dns.resolve6Async(tunnelDomain);
} catch (e) {}
}
if (!addrList || !addrList.length || !addrList[0]) {
throw new Error('failed to lookup IP for tunnel domain "' + tunnelDomain + '"');
}
await dnsCtrl.setDeviceAddress(dnsSession, addrList[0], domainList);
} catch (err) {
console.log('error starting tunnel for', domainList.join(', '));
console.log(err);
}
}
async function connectAllTunnels() {
var tunnelSession;
if (conf.ddns.tunnel) {
// In the case of a non-existant token, I'm not sure if we want to throw here and prevent
// any tunnel connections, or if we want to ignore it and fall back to the DNS tokens
tunnelSession = await deps.storage.tokens.get(conf.ddns.tunnel.tokenId);
}
await iterateAllModules(function (mod, domainList) {
if (mod.type !== 'dns@oauth3.org') { return null; }
return startTunnel(tunnelSession, mod, domainList);
});
tunnelActive = true;
}
async function disconnectTunnels() {
deps.tunnelClients.disconnect();
tunnelActive = false;
await Promise.resolve();
}
async function checkTunnelTokens() {
var oldTokens = deps.tunnelClients.current();
var newTokens = await iterateAllModules(function checkTokens(mod, domainList) {
if (mod.type !== 'dns@oauth3.org') { return null; }
var domainStr = domainList.slice().sort().join(',');
// If there is already a token handling exactly the domains this modules
// needs handled remove it from the list of tokens to be removed. Otherwise
// return the module and domain list so we can get new tokens.
if (oldTokens[domainStr]) {
delete oldTokens[domainStr];
} else {
return Promise.resolve({ mod, domainList });
}
});
await Promise.all(Object.values(oldTokens).map(deps.tunnelClients.remove));
if (!newTokens.length) { return; }
var tunnelSession;
if (conf.ddns.tunnel) {
// In the case of a non-existant token, I'm not sure if we want to throw here and prevent
// any tunnel connections, or if we want to ignore it and fall back to the DNS tokens
tunnelSession = await deps.storage.tokens.get(conf.ddns.tunnel.tokenId);
}
await Promise.all(newTokens.map(function ({mod, domainList}) {
return startTunnel(tunnelSession, mod, domainList);
}));
}
var localAddr, gateway;
async function checkNetworkEnv() {
// Since we can't detect the OS level events when a user plugs in an ethernet cable to recheck
// what network environment we are in we check our local network address and the gateway to
// determine if we need to run the loopback check and router configuration again.
var gw = await network.getGatewayIpAsync();
var addr = await network.getPrivateIpAsync();
if (localAddr === addr && gateway === gw) {
return;
}
localAddr = addr;
gateway = gw;
var loopResult = await loopback(loopbackDomain);
var notLooped = Object.keys(loopResult.ports).filter(function (port) {
return !loopResult.ports[port];
});
// if (notLooped.length) {
// // TODO: try to automatically configure router to forward ports to us.
// }
// If we are on a public address or all ports we are listening on are forwarded to us then
// we don't need the tunnel and we can set the DNS records for all our domains to our public
// address. Otherwise we need to use the tunnel to accept traffic.
if (!notLooped.length) {
if (tunnelActive) {
await disconnectTunnels();
}
} else {
if (!tunnelActive) {
await connectAllTunnels();
}
}
}
var publicAddress;
async function recheckPubAddr() {
await checkNetworkEnv();
if (tunnelActive) {
return;
}
var addr = await loopback.checkPublicAddr(loopbackDomain);
if (publicAddress === addr) {
return;
}
if (conf.debug) {
console.log('previous public address',publicAddress, 'does not match current public address', addr);
}
publicAddress = addr;
await iterateAllModules(function setModuleDNS(mod, domainList) {
if (mod.type !== 'dns@oauth3.org' || mod.disabled) { return null; }
return getSession(mod.tokenId).then(function (session) {
return dnsCtrl.setDeviceAddress(session, addr, domainList);
}).catch(function (err) {
console.log('error setting DNS records for', domainList.join(', '));
console.log(err);
});
});
}
function getModuleDiffs(prevConf) {
var prevMods = {};
var curMods = {};
// this returns a Promise, but since the functions we use are synchronous
// and change our enclosed variables we don't need to wait for the return.
iterateAllModules(function (mod, domainList) {
if (mod.type !== 'dns@oauth3.org') { return; }
prevMods[mod.id] = { mod, domainList };
return true;
}, prevConf);
iterateAllModules(function (mod, domainList) {
if (mod.type !== 'dns@oauth3.org') { return; }
curMods[mod.id] = { mod, domainList };
return true;
});
// Filter out all of the modules that are exactly the same including domainList
// since there is no required action to transition.
Object.keys(prevMods).map(function (id) {
if (equal(prevMods[id], curMods[id])) {
delete prevMods[id];
delete curMods[id];
}
});
return {prevMods, curMods};
}
async function cleanOldDns(prevConf) {
var {prevMods, curMods} = getModuleDiffs(prevConf);
// Then remove DNS records for the domains that we are no longer responsible for.
await Promise.all(Object.values(prevMods).map(function ({mod, domainList}) {
var oldDomains;
if (!curMods[mod.id] || mod.tokenId !== curMods[mod.id].mod.tokenId) {
oldDomains = domainList.slice();
} else {
oldDomains = domainList.filter(function (domain) {
return curMods[mod.id].domainList.indexOf(domain) < 0;
});
}
if (conf.debug) {
console.log('removing old domains for module', mod.id, oldDomains.join(', '));
}
if (!oldDomains.length) {
return;
}
return getSession(mod.tokenId).then(function (session) {
return dnsCtrl.removeDomains(session, oldDomains);
});
}).filter(Boolean));
}
async function setNewDns(prevConf) {
var {prevMods, curMods} = getModuleDiffs(prevConf);
// And add DNS records for any newly added domains.
await Promise.all(Object.values(curMods).map(function ({mod, domainList}) {
var newDomains;
if (!prevMods[mod.id] || mod.tokenId !== prevMods[mod.id].mod.tokenId) {
newDomains = domainList.slice();
} else {
newDomains = domainList.filter(function (domain) {
return prevMods[mod.id].domainList.indexOf(domain) < 0;
});
}
if (conf.debug) {
console.log('adding new domains for module', mod.id, newDomains.join(', '));
}
if (!newDomains.length) {
return;
}
return getSession(mod.tokenId).then(function (session) {
return dnsCtrl.setDeviceAddress(session, publicAddress, newDomains);
});
}).filter(Boolean));
}
function check() {
recheckPubAddr().catch(function (err) {
console.error('failed to handle all actions needed for DDNS');
console.error(err);
});
}
check();
setInterval(check, 5*60*1000);
var curConf;
function updateConf() {
if (curConf && equal(curConf.ddns, conf.ddns) && equal(curConf.domains, conf.domains)) {
// We could update curConf, but since everything we care about is the same...
return;
}
if (!curConf || !equal(curConf.ddns.loopback, conf.ddns.loopback)) {
loopbackDomain = 'oauth3.org';
if (conf.ddns && conf.ddns.loopback) {
if (conf.ddns.loopback.type === 'tunnel@oauth3.org' && conf.ddns.loopback.domain) {
loopbackDomain = conf.ddns.loopback.domain;
} else {
console.error('invalid loopback configuration: bad type or missing domain');
}
}
}
if (!curConf) {
// We need to make a deep copy of the config so we can use it next time to
// compare and see what setup/cleanup is needed to adapt to the changes.
curConf = JSON.parse(JSON.stringify(conf));
return;
}
cleanOldDns(curConf).then(function () {
if (!tunnelActive) {
return setNewDns(curConf);
}
if (equal(curConf.ddns.tunnel, conf.ddns.tunnel)) {
return checkTunnelTokens();
} else {
return disconnectTunnels().then(connectAllTunnels);
}
}).catch(function (err) {
console.error('error transitioning DNS between configurations');
console.error(err);
}).then(function () {
// We need to make a deep copy of the config so we can use it next time to
// compare and see what setup/cleanup is needed to adapt to the changes.
curConf = JSON.parse(JSON.stringify(conf));
});
}
updateConf();
return {
loopbackServer: loopback.server
, setDeviceAddress: dnsCtrl.setDeviceAddress
, getDeviceAddresses: dnsCtrl.getDeviceAddresses
, recheckPubAddr: recheckPubAddr
, updateConf: updateConf
};
};

查看文件

@ -1,14 +1,12 @@
'use strict';
module.exports.create = function (deps, conf) {
var PromiseA = require('bluebird');
var request = PromiseA.promisify(require('request'));
var pending = {};
async function checkPublicAddr(host) {
var result = await request({
async function _checkPublicAddr(host) {
var result = await deps.request({
method: 'GET'
, url: host+'/api/org.oauth3.tunnel/checkip'
, url: deps.OAUTH3.url.normalize(host)+'/api/org.oauth3.tunnel/checkip'
, json: true
});
@ -21,6 +19,10 @@ module.exports.create = function (deps, conf) {
}
return result.body.address;
}
async function checkPublicAddr(provider) {
var directives = await deps.OAUTH3.discover(provider);
return _checkPublicAddr(directives.api);
}
async function checkSinglePort(host, address, port) {
var crypto = require('crypto');
@ -30,7 +32,7 @@ module.exports.create = function (deps, conf) {
var reqObj = {
method: 'POST'
, url: host+'/api/org.oauth3.tunnel/loopback'
, url: deps.OAUTH3.url.normalize(host)+'/api/org.oauth3.tunnel/loopback'
, json: {
address: address
, port: port
@ -42,7 +44,7 @@ module.exports.create = function (deps, conf) {
var result;
try {
result = await request(reqObj);
result = await deps.request(reqObj);
} catch (err) {
delete pending[token];
throw err;
@ -63,11 +65,13 @@ module.exports.create = function (deps, conf) {
async function loopback(provider) {
var directives = await deps.OAUTH3.discover(provider);
var address = await checkPublicAddr(directives.api);
console.log('checking to see if', address, 'gets back to us');
var address = await _checkPublicAddr(directives.api);
if (conf.debug) {
console.log('checking to see if', address, 'gets back to us');
}
var ports = require('./servers').listeners.tcp.list();
var values = await PromiseA.all(ports.map(function (port) {
var ports = require('../servers').listeners.tcp.list();
var values = await deps.PromiseA.all(ports.map(function (port) {
return checkSinglePort(directives.api, address, port);
}));
@ -75,11 +79,13 @@ module.exports.create = function (deps, conf) {
console.log('remaining loopback tokens', pending);
}
var result = {error: null, address: address};
ports.forEach(function (port, ind) {
result[port] = values[ind];
});
return result;
return {
address: address
, ports: ports.reduce(function (obj, port, ind) {
obj[port] = values[ind];
return obj;
}, {})
};
}
loopback.checkPublicAddr = checkPublicAddr;

查看文件

@ -95,16 +95,20 @@ module.exports.create = function (deps, config) {
});
}
function dnsListener(msg) {
if (!Array.isArray(config.dns.modules)) {
function dnsListener(port, msg) {
if (!Array.isArray(config.udp.modules)) {
return;
}
var socket = require('dgram').createSocket('udp4');
config.dns.modules.forEach(function (mod) {
if (mod.name !== 'proxy') {
config.udp.modules.forEach(function (mod) {
if (mod.type !== 'forward') {
console.warn('found bad DNS module', mod);
return;
}
if (mod.ports.indexOf(port) < 0) {
return;
}
var dest = require('./domain-utils').separatePort(mod.address || '');
dest.port = dest.port || mod.port;
dest.host = dest.host || mod.host || 'localhost';
@ -197,23 +201,12 @@ module.exports.create = function (deps, config) {
var listenPromises = [];
var tcpPortMap = {};
function addPorts(bindList) {
if (!bindList) {
return;
}
if (Array.isArray(bindList)) {
bindList.filter(Number).forEach(function (port) {
tcpPortMap[port] = true;
});
}
else if (Number(bindList)) {
tcpPortMap[bindList] = true;
}
}
config.tcp.bind.filter(Number).forEach(function (port) {
tcpPortMap[port] = true;
});
addPorts(config.tcp.bind);
(config.tcp.modules || []).forEach(function (mod) {
if (mod.name === 'forward') {
if (mod.type === 'forward') {
var forwarder = createTcpForwarder(mod);
mod.ports.forEach(function (port) {
if (!tcpPortMap[port]) {
@ -229,25 +222,15 @@ module.exports.create = function (deps, config) {
}
});
// Even though these ports were specified in different places we treat any TCP
// connections we haven't been told to just forward exactly as is equal so that
// we can potentially use the same ports for different protocols.
addPorts(config.tls.bind);
addPorts(config.http.bind);
var portList = Object.keys(tcpPortMap).map(Number).sort();
portList.forEach(function (port) {
listenPromises.push(listeners.tcp.add(port, netHandler));
});
if (config.dns.bind) {
if (Array.isArray(config.dns.bind)) {
config.dns.bind.map(function (port) {
listenPromises.push(listeners.udp.add(port, dnsListener));
});
} else {
listenPromises.push(listeners.udp.add(config.dns.bind, dnsListener));
}
if (config.udp.bind) {
config.udp.bind.forEach(function (port) {
listenPromises.push(listeners.udp.add(port, dnsListener.bind(port)));
});
}
if (!config.mdns.disabled) {

查看文件

@ -1,67 +0,0 @@
var adminDomains = [
'localhost.alpha.daplie.me'
, 'localhost.admin.daplie.me'
, 'alpha.localhost.daplie.me'
, 'admin.localhost.daplie.me'
, 'localhost.daplie.invalid'
];
module.exports.adminDomains = adminDomains;
module.exports.create = function (deps, conf) {
'use strict';
var path = require('path');
//var defaultServername = 'localhost.daplie.me';
//var defaultWebRoot = '.';
var assetsPath = path.join(__dirname, '..', '..', 'packages', 'assets');
var opts = {};
opts.global = opts.global || {};
opts.sites = opts.sites || [];
opts.sites._map = {};
// argv.sites
opts.groups = [];
// 'packages', 'assets', 'com.daplie.goldilocks'
opts.global = {
modules: [ // TODO uh-oh we've got a mixed bag of modules (various types), a true map
{ $id: 'greenlock', email: opts.email, tos: opts.tos }
, { $id: 'rvpn', email: opts.email, tos: opts.tos }
//, { $id: 'content', content: content }
, { $id: 'livereload', on: opts.livereload }
, { $id: 'app', path: opts.expressApp }
]
, paths: [
{ $id: '/assets/', modules: [ { $id: 'serve', paths: [ assetsPath ] } ] }
// TODO figure this b out
, { $id: '/.well-known/', modules: [
{ $id: 'serve', paths: [ path.join(assetsPath, 'well-known') ] }
] }
]
};
opts.defaults = {
modules: []
, paths: [
/*
{ $id: '/', modules: [
{ $id: 'serve', paths: [ defaultWebRoot ] }
, { $id: 'indexes', paths: [ defaultWebRoot ] }
] }
*/
]
};
adminDomains.forEach(function (id) {
opts.sites.push({
$id: id
, paths: [
{ $id: '/', modules: [ { $id: 'serve', paths: [ path.resolve(__dirname, '..', '..', 'admin', 'public') ] } ] }
, { $id: '/api/', modules: [ { $id: 'app', path: path.join(__dirname, 'admin') } ] }
]
});
});
var app = require('../app.js')(deps, conf, opts);
return require('http').createServer(app);
};

查看文件

@ -65,18 +65,21 @@ module.exports.create = function (deps, conf, greenlockMiddleware) {
});
}
function hostMatchesDomains(req, domains) {
function hostMatchesDomains(req, domainList) {
var host = separatePort((req.headers || req).host).host.toLowerCase();
return domains.some(function (pattern) {
return domainList.some(function (pattern) {
return domainMatches(pattern, host);
});
}
function determinePrimaryHost() {
var result;
if (Array.isArray(conf.http.domains)) {
conf.http.domains.some(function (dom) {
if (Array.isArray(conf.domains)) {
conf.domains.some(function (dom) {
if (!dom.modules || !dom.modules.http) {
return false;
}
return dom.names.some(function (domain) {
if (domain[0] !== '*') {
result = domain;
@ -178,7 +181,7 @@ module.exports.create = function (deps, conf, greenlockMiddleware) {
if (headers.url.indexOf('/.well-known/cloud-challenge/') !== 0) {
return false;
}
return emitConnection(deps.loopback.server, conn, opts);
return emitConnection(deps.ddns.loopbackServer, conn, opts);
}
var httpsRedirectServer;
@ -202,11 +205,11 @@ module.exports.create = function (deps, conf, greenlockMiddleware) {
var host = separatePort(headers.host).host;
if (!adminDomains) {
adminDomains = require('./admin').adminDomains;
adminDomains = require('../admin').adminDomains;
}
if (adminDomains.indexOf(host) !== -1) {
if (!adminServer) {
adminServer = require('./admin').create(deps, conf);
adminServer = require('../admin').create(deps, conf);
}
return emitConnection(adminServer, conn, opts);
}
@ -466,21 +469,24 @@ module.exports.create = function (deps, conf, greenlockMiddleware) {
if (checkAdmin(conn, opts, headers)) { return; }
var prom = PromiseA.resolve(false);
(conf.http.domains || []).forEach(function (dom) {
(conf.domains || []).forEach(function (dom) {
prom = prom.then(function (handled) {
if (handled) {
return handled;
}
if (!dom.modules || !dom.modules.http) {
return false;
}
if (!hostMatchesDomains(headers, dom.names)) {
return false;
}
var subProm = PromiseA.resolve(false);
dom.modules.forEach(function (mod) {
if (moduleChecks[mod.name]) {
dom.modules.http.forEach(function (mod) {
if (moduleChecks[mod.type]) {
subProm = subProm.then(function (handled) {
if (handled) { return handled; }
return moduleChecks[mod.name](mod, conn, opts, headers);
return moduleChecks[mod.type](mod, conn, opts, headers);
});
} else {
console.warn('unknown HTTP module under domains', dom.names.join(','), mod);
@ -498,8 +504,8 @@ module.exports.create = function (deps, conf, greenlockMiddleware) {
return false;
}
if (moduleChecks[mod.name]) {
return moduleChecks[mod.name](mod, conn, opts, headers);
if (moduleChecks[mod.type]) {
return moduleChecks[mod.type](mod, conn, opts, headers);
}
console.warn('unknown HTTP module found', mod);
});

查看文件

@ -27,8 +27,8 @@ module.exports.create = function (deps, config, netHandler) {
return value || '';
}
function nameMatchesDomains(name, domains) {
return domains.some(function (pattern) {
function nameMatchesDomains(name, domainList) {
return domainList.some(function (pattern) {
return domainMatches(pattern, name);
});
}
@ -50,10 +50,7 @@ module.exports.create = function (deps, config, netHandler) {
return;
}
process.nextTick(function () {
socket.unshift(opts.firstChunk);
});
writer.write(opts.firstChunk);
socket.pipe(writer);
writer.pipe(socket);
@ -135,14 +132,17 @@ module.exports.create = function (deps, config, netHandler) {
}
var handled = false;
if (Array.isArray(config.tls.domains)) {
handled = config.tls.domains.some(function (dom) {
if (Array.isArray(config.domains)) {
handled = config.domains.some(function (dom) {
if (!dom.modules || !dom.modules.tls) {
return false;
}
if (!nameMatchesDomains(opts.domain, dom.names)) {
return false;
}
return dom.modules.some(function (mod) {
if (mod.name !== 'acme') {
return dom.modules.tls.some(function (mod) {
if (mod.type !== 'acme') {
return false;
}
complete(mod, dom.names);
@ -156,7 +156,7 @@ module.exports.create = function (deps, config, netHandler) {
if (Array.isArray(config.tls.modules)) {
handled = config.tls.modules.some(function (mod) {
if (mod.name !== 'acme') {
if (mod.type !== 'acme') {
return false;
}
if (!nameMatchesDomains(opts.domain, mod.domains)) {
@ -171,26 +171,6 @@ module.exports.create = function (deps, config, netHandler) {
return;
}
var defAcmeConf;
if (config.tls.acme) {
defAcmeConf = config.tls.acme;
} else {
defAcmeConf = {
email: config.tls.email
, server: config.tls.acmeDirectoryUrl || le.server
, challengeType: config.tls.challengeType || le.challengeType
, approvedDomains: config.tls.servernames
};
}
// Check config for domain name
// TODO: if `approvedDomains` isn't defined check all other modules to see if they can
// handle this domain (and what other domains it's grouped with).
if (-1 !== (defAcmeConf.approvedDomains || []).indexOf(opts.domain)) {
complete(defAcmeConf, defAcmeConf.approvedDomains);
return;
}
cb(new Error('domain is not allowed'));
}
});
@ -322,20 +302,23 @@ module.exports.create = function (deps, config, netHandler) {
}
function checkModule(mod) {
if (mod.name === 'proxy') {
if (mod.type === 'proxy') {
return proxy(socket, opts, mod);
}
if (mod.name !== 'acme') {
if (mod.type !== 'acme') {
console.error('saw unknown TLS module', mod);
}
}
var handled = (config.tls.domains || []).some(function (dom) {
var handled = (config.domains || []).some(function (dom) {
if (!dom.modules || !dom.modules.tls) {
return false;
}
if (!nameMatchesDomains(opts.servername, dom.names)) {
return false;
}
return dom.modules.some(checkModule);
return dom.modules.tls.some(checkModule);
});
if (handled) {
return;

查看文件

@ -3,6 +3,8 @@
var PromiseA = require('bluebird');
var path = require('path');
var fs = PromiseA.promisifyAll(require('fs'));
var jwt = require('jsonwebtoken');
var crypto = require('crypto');
module.exports.create = function (deps, conf) {
var hrIds = require('human-readable-ids').humanReadableIds;
@ -65,12 +67,129 @@ module.exports.create = function (deps, conf) {
}
};
var confCb;
var config = {
save: function (changes) {
deps.messenger.send({
type: 'com.daplie.goldilocks/config'
, changes: changes
});
return new deps.PromiseA(function (resolve, reject) {
var timeoutId = setTimeout(function () {
reject(new Error('Did not receive config update from main process in a reasonable time'));
confCb = null;
}, 15*1000);
confCb = function (config) {
confCb = null;
clearTimeout(timeoutId);
resolve(config);
};
});
}
};
function updateConf(config) {
if (confCb) {
confCb(config);
}
}
var userTokens = {
_filename: 'user-tokens.json'
, _cache: {}
, _convertToken: function convertToken(id, token) {
// convert the token into something that looks more like what OAuth3 uses internally
// as sessions so we can use it with OAuth3. We don't use OAuth3's internal session
// storage because it effectively only supports storing tokens based on provider URI.
// We also use the token as the `access_token` instead of `refresh_token` because the
// refresh functionality is closely tied to the storage.
var decoded = jwt.decode(token);
if (!decoded) {
return null;
}
return {
id: id
, access_token: token
, token: decoded
, provider_uri: decoded.iss || decoded.issuer || decoded.provider_uri
, client_uri: decoded.azp
, scope: decoded.scp || decoded.scope || decoded.grants
};
}
, all: function allUserTokens() {
var self = this;
if (self._cacheComplete) {
return deps.PromiseA.resolve(Object.values(self._cache));
}
return read(self._filename).then(function (tokens) {
// We will read every single token into our cache, so it will be complete once we finish
// creating the result (it's set out of order so we can directly return the result).
self._cacheComplete = true;
return Object.keys(tokens).map(function (id) {
self._cache[id] = self._convertToken(id, tokens[id]);
return self._cache[id];
});
});
}
, get: function getUserToken(id) {
var self = this;
if (self._cache.hasOwnProperty(id) || self._cacheComplete) {
return deps.PromiseA.resolve(self._cache[id] || null);
}
return read(self._filename).then(function (tokens) {
self._cache[id] = self._convertToken(id, tokens[id]);
return self._cache[id];
});
}
, save: function saveUserToken(newToken) {
var self = this;
return read(self._filename).then(function (tokens) {
var rawToken;
if (typeof newToken === 'string') {
rawToken = newToken;
} else {
rawToken = newToken.refresh_token || newToken.access_token;
}
if (typeof rawToken !== 'string') {
throw new Error('cannot save invalid session: missing refresh_token and access_token');
}
var decoded = jwt.decode(rawToken);
var idHash = crypto.createHash('sha256');
idHash.update(decoded.sub || decoded.ppid || decoded.appScopedId || '');
idHash.update(decoded.iss || decoded.issuer || '');
idHash.update(decoded.aud || decoded.audience || '');
var scope = decoded.scope || decoded.scp || decoded.grants || '';
idHash.update(scope.split(/[,\s]+/mg).sort().join(','));
var id = idHash.digest('hex');
tokens[id] = rawToken;
return write(self._filename, tokens).then(function () {
// Delete the current cache so that if this is an update it will refresh
// the cache once we read the ID.
delete self._cache[id];
return self.get(id);
});
});
}
, remove: function removeUserToken(id) {
var self = this;
return read(self._filename).then(function (tokens) {
var present = delete tokens[id];
if (!present) {
return present;
}
return write(self._filename, tokens).then(function () {
delete self._cache[id];
return true;
});
});
}
};
@ -99,6 +218,8 @@ module.exports.create = function (deps, conf) {
return {
owners: owners
, config: config
, updateConf: updateConf
, tokens: userTokens
, mdnsId: mdnsId
};
};

查看文件

@ -1,124 +1,67 @@
'use strict';
module.exports.create = function (deps, config) {
var PromiseA = require('bluebird');
var fs = PromiseA.promisifyAll(require('fs'));
var stunnel = require('stunnel');
var jwt = require('jsonwebtoken');
var activeTunnels = {};
var activeDomains = {};
var path = require('path');
var tokensPath = path.join(__dirname, '..', 'var', 'tokens.json');
var storage = {
_read: function () {
var tokens;
try {
tokens = require(tokensPath);
} catch (err) {
tokens = {};
function fillData(data) {
if (typeof data === 'string') {
data = { jwt: data };
}
if (!data.jwt) {
throw new Error("missing 'jwt' from tunnel data");
}
var decoded = jwt.decode(data.jwt);
if (!decoded) {
throw new Error('invalid JWT');
}
if (!data.tunnelUrl) {
if (!decoded.aud) {
throw new Error('missing tunnelUrl and audience');
}
return tokens;
}
, _write: function (tokens) {
return fs.mkdirAsync(path.dirname(tokensPath)).catch(function (err) {
if (err.code !== 'EEXIST') {
console.error('failed to mkdir', path.dirname(tokensPath), err.toString());
}
}).then(function () {
return fs.writeFileAsync(tokensPath, JSON.stringify(tokens), 'utf8');
});
}
, _makeKey: function (token) {
// We use a stripped down version of the token contents so that if the token is
// re-issued the nonce and the iat and any other less important things are different
// we don't save essentially duplicate tokens multiple times.
var parsed = JSON.parse((new Buffer(token.split('.')[1], 'base64')).toString());
var stripped = {};
['aud', 'iss', 'domains'].forEach(function (key) {
if (parsed[key]) {
stripped[key] = parsed[key];
}
});
stripped.domains.sort();
var hash = require('crypto').createHash('sha256');
return hash.update(JSON.stringify(stripped)).digest('hex');
data.tunnelUrl = 'wss://' + decoded.aud + '/';
}
, all: function () {
var tokens = storage._read();
return PromiseA.resolve(Object.keys(tokens).map(function (key) {
return tokens[key];
}));
data.domains = (decoded.domains || []).slice().sort().join(',');
if (!data.domains) {
throw new Error('JWT contains no domains to be forwarded');
}
, save: function (token) {
return PromiseA.resolve().then(function () {
var curTokens = storage._read();
curTokens[storage._makeKey(token.jwt)] = token;
return storage._write(curTokens);
});
return data;
}
async function removeToken(data) {
data = fillData(data);
// Not sure if we might want to throw an error indicating the token didn't
// even belong to a server that existed, but since it never existed we can
// consider it as "removed".
if (!activeTunnels[data.tunnelUrl]) {
return;
}
, del: function (token) {
return PromiseA.resolve().then(function () {
var curTokens = storage._read();
delete curTokens[storage._makeKey(token.jwt)];
return storage._write(curTokens);
});
}
};
function acquireToken(session) {
var OAUTH3 = deps.OAUTH3;
// session seems to be changed by the API call for some reason, so save the
// owner before that happens.
var owner = session.id;
// The OAUTH3 library stores some things on the root session object that we usually
// just leave inside the token, but we need to pull those out before we use it here
session.provider_uri = session.provider_uri || session.token.provider_uri || session.token.iss;
session.client_uri = session.client_uri || session.token.azp;
session.scope = session.scope || session.token.scp;
console.log('asking for tunnel token from', session.token.aud);
return OAUTH3.discover(session.token.aud).then(function (directives) {
var opts = {
api: 'tunnel.token'
, session: session
, data: {
// filter to all domains that are on this device
//domains: Object.keys(domainsMap)
device: {
hostname: config.device.hostname
, id: config.device.uid || config.device.id
}
}
};
return OAUTH3.api(directives.api, opts).then(function (result) {
console.log('got a token from the tunnel server?');
result.owner = owner;
return result;
});
console.log('removing token from tunnel at', data.tunnelUrl);
return activeTunnels[data.tunnelUrl].clear(data.jwt).then(function () {
delete activeDomains[data.domains];
});
}
function addToken(data) {
if (!data.jwt) {
return PromiseA.reject(new Error("missing 'jwt' from tunnel data"));
}
if (!data.tunnelUrl) {
var decoded;
try {
decoded = JSON.parse(new Buffer(data.jwt.split('.')[1], 'base64').toString('ascii'));
} catch (err) {
console.warn('invalid web token given to tunnel manager', err);
return PromiseA.reject(err);
async function addToken(data) {
data = fillData(data);
if (activeDomains[data.domains]) {
// If already have a token with the exact same domains and to the same tunnel
// server there isn't really a need to add a new one
if (activeDomains[data.domains].tunnelUrl === data.tunnelUrl) {
return;
}
if (!decoded.aud) {
console.warn('tunnel manager given token with no tunnelUrl or audience');
var err = new Error('missing tunnelUrl and audience');
return PromiseA.reject(err);
}
data.tunnelUrl = 'wss://' + decoded.aud + '/';
// Otherwise we want to detach from the other tunnel server in favor of the new one
console.warn('added token with the exact same domains as another');
await removeToken(activeDomains[data.domains]);
}
if (!activeTunnels[data.tunnelUrl]) {
@ -142,96 +85,61 @@ module.exports.create = function (deps, config) {
});
}
console.log('appending token to tunnel at', data.tunnelUrl);
return activeTunnels[data.tunnelUrl].append(data.jwt);
console.log('appending token to tunnel at', data.tunnelUrl, 'for domains', data.domains);
await activeTunnels[data.tunnelUrl].append(data.jwt);
// Now that we know the tunnel server accepted our token we can save it
// to keep record of what domains we are handling and what tunnel server
// those domains should go to.
activeDomains[data.domains] = data;
// This is mostly for the start, but return the host for the tunnel server
// we've connected to (after stripping the protocol and path away).
return data.tunnelUrl.replace(/^[a-z]*:\/\//i, '').replace(/\/.*/, '');
}
function removeToken(data) {
if (!data.tunnelUrl) {
var decoded;
try {
decoded = JSON.parse(new Buffer(data.jwt.split('.')[1], 'base64').toString('ascii'));
} catch (err) {
console.warn('invalid web token given to tunnel manager', err);
return PromiseA.reject(err);
}
if (!decoded.aud) {
console.warn('tunnel manager given token with no tunnelUrl or audience');
var err = new Error('missing tunnelUrl and audience');
return PromiseA.reject(err);
}
data.tunnelUrl = 'wss://' + decoded.aud + '/';
}
async function acquireToken(session, domains) {
var OAUTH3 = deps.OAUTH3;
// Not sure if we actually want to return an error that the token didn't even belong to a
// server that existed, but since it never existed we can consider it as "removed".
if (!activeTunnels[data.tunnelUrl]) {
return PromiseA.resolve();
}
// The OAUTH3 library stores some things on the root session object that we usually
// just leave inside the token, but we need to pull those out before we use it here
session.provider_uri = session.provider_uri || session.token.provider_uri || session.token.iss;
session.client_uri = session.client_uri || session.token.azp;
session.scope = session.scope || session.token.scp;
console.log('removing token from tunnel at', data.tunnelUrl);
return activeTunnels[data.tunnelUrl].clear(data.jwt);
console.log('asking for tunnel token from', session.token.aud);
var opts = {
api: 'tunnel.token'
, session: session
, data: {
domains: domains
, device: {
hostname: config.device.hostname
, id: config.device.uid || config.device.id
}
}
};
var directives = await OAUTH3.discover(session.token.aud);
var tokenData = await OAUTH3.api(directives.api, opts);
return addToken(tokenData);
}
if (config.tunnel) {
var confTokens = config.tunnel;
if (typeof confTokens === 'string') {
confTokens = confTokens.split(',');
}
confTokens.forEach(function (jwt) {
if (typeof jwt === 'object') {
jwt.owner = 'config';
addToken(jwt);
} else {
addToken({ jwt: jwt, owner: 'config' });
}
function disconnectAll() {
Object.keys(activeTunnels).forEach(function (key) {
activeTunnels[key].end();
});
}
storage.all().then(function (stored) {
stored.forEach(function (result) {
addToken(result);
});
});
function currentTokens() {
return JSON.parse(JSON.stringify(activeDomains));
}
return {
start: function (session) {
return acquireToken(session).then(function (token) {
return addToken(token).then(function () {
return storage.save(token);
});
});
}
, add: function (data) {
return addToken(data).then(function () {
return storage.save(data);
});
}
, remove: function (data) {
return storage.del(data.jwt).then(function () {
return removeToken(data);
});
}
, get: function (owner) {
return storage.all().then(function (tokens) {
var result = {};
tokens.forEach(function (data) {
if (!result[data.owner]) {
result[data.owner] = {};
}
if (!result[data.owner][data.tunnelUrl]) {
result[data.owner][data.tunnelUrl] = [];
}
data.decoded = JSON.parse(new Buffer(data.jwt.split('.')[0], 'base64'));
result[data.owner][data.tunnelUrl].push(data);
});
if (owner) {
return result[owner] || {};
}
return result;
});
}
start: acquireToken
, startDirect: addToken
, remove: removeToken
, disconnect: disconnectAll
, current: currentTokens
};
};

查看文件

@ -1,6 +1,7 @@
'use strict';
var config;
var modules;
// Everything that uses the config should be reading it when relevant rather than
// just at the beginning, so we keep the reference for the main object and just
@ -15,24 +16,43 @@ function update(conf) {
config[key] = conf[key];
}
});
console.log('config', JSON.stringify(config));
console.log('config update', JSON.stringify(config));
Object.values(modules).forEach(function (mod) {
if (typeof mod.updateConf === 'function') {
mod.updateConf(config);
}
});
}
function create(conf) {
var PromiseA = require('bluebird');
var OAUTH3 = require('../packages/assets/org.oauth3');
require('../packages/assets/org.oauth3/oauth3.domains.js');
require('../packages/assets/org.oauth3/oauth3.dns.js');
require('../packages/assets/org.oauth3/oauth3.tunnel.js');
OAUTH3._hooks = require('../packages/assets/org.oauth3/oauth3.node.storage.js');
config = conf;
var deps = {
messenger: process
, PromiseA: require('bluebird')
, PromiseA: PromiseA
, OAUTH3: OAUTH3
, request: PromiseA.promisify(require('request'))
, recase: require('recase').create({})
// Note that if a custom createConnections is used it will be called with different
// sets of custom options based on what is actually being proxied. Most notably the
// HTTP proxying connection creation is not something we currently control.
, net: require('net')
};
deps.storage = require('./storage').create(deps, conf);
deps.proxy = require('./proxy-conn').create(deps, conf);
deps.socks5 = require('./socks5-server').create(deps, conf);
deps.loopback = require('./loopback').create(deps, conf);
deps.ddns = require('./ddns').create(deps, conf);
modules = {
storage: require('./storage').create(deps, conf)
, proxy: require('./proxy-conn').create(deps, conf)
, socks5: require('./socks5-server').create(deps, conf)
, ddns: require('./ddns').create(deps, conf)
};
Object.assign(deps, modules);
require('./goldilocks.js').create(deps, conf);
process.removeListener('message', create);

75
package-lock.json generated
查看文件

@ -427,6 +427,11 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
},
"deep-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
"integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU="
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1029,6 +1034,11 @@
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
},
"jsonschema": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.0.tgz",
"integrity": "sha512-XDJApzBauMg0TinJNP4iVcJl99PQ4JbWKK7nwzpOIkAOVveDKgh/2xm41T3x7Spu4PWMhnnQpNJmUSIUgl6sKg=="
},
"jsonwebtoken": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-7.4.1.tgz",
@ -1168,6 +1178,11 @@
"resolved": "https://registry.npmjs.org/localhost.daplie.me-certificates/-/localhost.daplie.me-certificates-1.3.5.tgz",
"integrity": "sha1-GjqH5PlX8mn2LP7mCmNpe9JVOpo="
},
"lodash": {
"version": "4.17.4",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz",
"integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4="
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@ -1301,11 +1316,38 @@
"integrity": "sha1-5P805slf37WuzAjeZZb0NgWn20U=",
"optional": true
},
"needle": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/needle/-/needle-1.1.2.tgz",
"integrity": "sha1-0oQaElv9dP77MMA0QQQ2kGHD4To=",
"requires": {
"debug": "2.6.1",
"iconv-lite": "0.4.15"
}
},
"negotiator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
"integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk="
},
"network": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/network/-/network-0.4.0.tgz",
"integrity": "sha1-ngk+TZzpBjmHJTL6YC/oVf87aSk=",
"requires": {
"async": "1.5.2",
"commander": "2.9.0",
"needle": "1.1.2",
"wmic": "0.1.0"
},
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo="
}
}
},
"node-forge": {
"version": "0.6.49",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.6.49.tgz",
@ -1865,9 +1907,9 @@
}
},
"socket-pair": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/socket-pair/-/socket-pair-1.0.1.tgz",
"integrity": "sha1-mneFcEv9yOj2NxwodeyjIeMT/po=",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/socket-pair/-/socket-pair-1.0.3.tgz",
"integrity": "sha512-O1WJMNIPAAGCzzJi1Lk9K9adctKM4DukiUO6G6sQSs+CqEAZ5uGX86uIMDKygBZZr62YHDoOGH1rJShOzw6i9Q==",
"requires": {
"bluebird": "3.5.0"
}
@ -1967,14 +2009,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
"integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4="
},
"stream-pair": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-pair/-/stream-pair-1.0.3.tgz",
"integrity": "sha1-vIdY/jnTgQuva3VMj5BI8PuRNn0=",
"requires": {
"readable-stream": "2.2.11"
}
},
"string_decoder": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.2.tgz",
@ -2233,6 +2267,25 @@
}
}
},
"wmic": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wmic/-/wmic-0.1.0.tgz",
"integrity": "sha1-eLQasR0VTLgSgZ4SkWdNrVXY4dc=",
"requires": {
"async": "2.5.0",
"iconv-lite": "0.4.15"
},
"dependencies": {
"async": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/async/-/async-2.5.0.tgz",
"integrity": "sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFTKE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==",
"requires": {
"lodash": "4.17.4"
}
}
}
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

查看文件

@ -41,6 +41,7 @@
"bluebird": "^3.4.6",
"body-parser": "git+https://github.com/expressjs/body-parser.git#1.16.1",
"commander": "^2.9.0",
"deep-equal": "^1.0.1",
"dns-suite": "git+https://git@git.daplie.com/Daplie/dns-suite#v1",
"express": "git+https://github.com/expressjs/express.git#4.x",
"finalhandler": "^0.4.0",
@ -49,12 +50,14 @@
"human-readable-ids": "git+https://git.daplie.com/Daplie/human-readable-ids-js#master",
"ipaddr.js": "git+https://github.com/whitequark/ipaddr.js.git#v1.3.0",
"js-yaml": "^3.8.3",
"jsonschema": "^1.2.0",
"jsonwebtoken": "^7.4.0",
"le-challenge-ddns": "git+https://git.daplie.com/Daplie/le-challenge-ddns.git#master",
"le-challenge-fs": "git+https://git.daplie.com/Daplie/le-challenge-webroot.git#master",
"le-challenge-sni": "^2.0.1",
"le-store-certbot": "git+https://git.daplie.com/Daplie/le-store-certbot.git#master",
"localhost.daplie.me-certificates": "^1.3.5",
"network": "^0.4.0",
"recase": "git+https://git.daplie.com/coolaj86/recase-js.git#v1.0.4",
"redirect-https": "^1.1.0",
"request": "^2.81.0",
@ -63,7 +66,7 @@
"serve-static": "^1.10.0",
"server-destroy": "^1.0.1",
"sni": "^1.0.0",
"socket-pair": "^1.0.1",
"socket-pair": "^1.0.3",
"socksv5": "0.0.6",
"stunnel": "git+https://git.daplie.com/Daplie/node-tunnel-client.git#v1",
"stunneld": "git+https://git.daplie.com/Daplie/node-tunnel-server.git#v1",

查看文件

@ -1,23 +0,0 @@
'use strict';
var api = require('./index.js').api;
var OAUTH3 = require('../../assets/org.oauth3/');
// these all auto-register
require('../../assets/org.oauth3/oauth3.domains.js');
require('../../assets/org.oauth3/oauth3.dns.js');
require('../../assets/org.oauth3/oauth3.tunnel.js');
OAUTH3._hooks = require('../../assets/org.oauth3/oauth3.node.storage.js');
api.tunnel(
{
OAUTH3: OAUTH3
, options: {
device: {
hostname: 'test.local'
, id: ''
}
}
}
// OAUTH3.hooks.session.get('oauth3.org').then(function (result) { console.log(result) });
, require('./test.session.json')
);