├── .gitignore ├── README.md ├── cli.js ├── daemon.js ├── init └── tlsproxy.service ├── js ├── certutil.js ├── httputil │ ├── actions │ │ ├── proxy.js │ │ ├── redirect.js │ │ └── serve.js │ ├── index.js │ ├── parse-url.js │ └── wsproxy.js ├── pmutil.js └── userid.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | conf.json 2 | conf 3 | node_modules 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tlsproxy 2 | 3 | tlsproxy is a web proxy server, meant to be the process listening to 4 | port 80, 443, etc, and forwarding the requests to internal ports. It can also 5 | serve a directory. It features automatic HTTPS certificates using letsencrypt. 6 | 7 | ## Usage 8 | 9 | First, install tlsproxy: 10 | 11 | sudo npm install -g tlsproxy 12 | 13 | Next step is to create the necessary files in `/etc/tlsproxy` and install systemd 14 | unit files. That's just one command: 15 | 16 | sudo tlsproxy setup 17 | 18 | If you're not using systemd, you'll have to find a way to start tlsproxy on boot 19 | yourself. 20 | 21 | Next, edit `/etc/tlsproxy/conf.json`. The `email` field is the email used for 22 | letsencrypt certificates. The `user` and `group` fields are the default user 23 | and group for running processes. 24 | 25 | If you leave `user` and `group` as `www-data` in the conf file, you may have to 26 | create the user and group `www-data` if it doesn't exist already. 27 | 28 | ## Configuration 29 | 30 | Configuration is done with json files in `/etc/tlsproxy/sites`. All files there 31 | are automatically sourced. The root of a file could either be an object, or it 32 | could be an array containing multiple site objects. 33 | 34 | ### Example 35 | 36 | Here's an example of a proxy for a site served using https, which works both 37 | with and without www, with a redirect from http to https. 38 | It assumes that there's already an http server running on port 8085 which 39 | serves the actual website. 40 | 41 | https will magically work and be updated whenever necessary and everything, 42 | just because we used `https` in the host field. 43 | 44 | { 45 | "host": ["https://example.com", "https://www.example.com"], 46 | "redirectFrom": ["http://example.com", "http://www.example.com"], 47 | "action": { 48 | "type": "proxy", 49 | "to": "http://localhost:8085" 50 | } 51 | } 52 | 53 | Here's an example without redirectFrom, for completeness' sake: 54 | 55 | [ 56 | { 57 | "host": ["https://example.com", "https://www.example.com"], 58 | "action": { 59 | "type": "proxy", 60 | "to": "http://localhost:8085" 61 | } 62 | }, 63 | { 64 | "host": ["http://example.com", "http://www.example.com"], 65 | "action": { 66 | "type": "redirect", 67 | "to": "https://$host/$path" 68 | } 69 | } 70 | ] 71 | 72 | ### Other Example 73 | 74 | Here's an example of just serving files in a directory. 75 | 76 | { 77 | "host": "https://static.example.com", 78 | "redirectFrom": "http://static.example.com", 79 | "action": { 80 | "type": "serve", 81 | "path": "/var/www/static.example.com/public" 82 | } 83 | } 84 | 85 | ### Properties 86 | 87 | Here's a list of the properties a site object can have. 88 | 89 | * `host`: 90 | * The host(s) the site will be available from. 91 | * If an array is provided, all values will be treated as aliases. 92 | * A host should look like this: `https://foo.example.com`. 93 | * Both `http://` and `https://` are accepted. 94 | * Adding a port is optional, and is done by adding `:` to the end. 95 | * If no port is provided, 80 will be used for http, and 443 for https. 96 | * `redirectFrom`: 97 | * The host(s) which will redirect to the site. 98 | * Follows the same rules as `host`. 99 | * `action`: 100 | * The action to be performed when someone requests the site. 101 | * `type`: Can be "proxy", "redirect", "serve", or "none". 102 | * `to`: (if `type` is "redirect" or "proxy"): 103 | * The host to proxy/redirect to. 104 | * `websocket`: (if `type` is `proxy`): 105 | * The URL to proxy websockets to. 106 | * `path`: (if `type` is "serve"): 107 | * The path to serve files in. 108 | * `code` (if `type` is "redirect"): 109 | * The status code to be sent to the client. 110 | * Defaults to 302 (temporary redirect). 111 | * `exec`: 112 | * Execute a process, to let tlsproxy start the servers it's a proxy 113 | for if that's desired. 114 | * The process is automatically restarted if it dies, unless it dies 115 | immediately after being started multiple times. 116 | * `at`: The directory to run the process in. 117 | * `run`: An array. The first entry is the command to run, the subsequent 118 | are the arguments. 119 | * `id`: The id of the process. 120 | host, command, and directory. 121 | * `env`: Environment variables. 122 | * `group`: 123 | * The group to execute the process as. 124 | * Defaults to `group` in `/etc/tlsproxy/conf.json`. 125 | * `user`: 126 | * The user to execute the process as. 127 | * Defaults to `user` in `/etc/tlsproxy/conf.json`. 128 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var colors = require("colors"); 4 | 5 | var confpath = process.env.PROXY_CONF; 6 | if (!confpath) 7 | confpath = "/etc/tlsproxy"; 8 | 9 | var defaultGroup = "www-data"; 10 | var defaultUser = "www-data"; 11 | 12 | var fs = require("fs"); 13 | var net = require("net"); 14 | var mkdirp = require("mkdirp"); 15 | 16 | var version = JSON.parse( 17 | fs.readFileSync(__dirname+"/package.json", "utf-8")).version; 18 | 19 | // Calculate the display length of a string, stripping out escape codes 20 | function reallen(str) { 21 | var len = 0; 22 | var inColor = 0; 23 | for (var i = 0; i < str.length; ++i) { 24 | var c = str[i]; 25 | if (c === "\u001b") { 26 | inColor = 1; 27 | } else if (inColor === 1) { 28 | if (c === "[") 29 | inColor = 2; 30 | } else if (inColor === 2) { 31 | if (c === "m") 32 | inColor = 0; 33 | } else { 34 | len += 1; 35 | } 36 | } 37 | 38 | return len; 39 | } 40 | 41 | function printTable(arr) { 42 | var maxlen = []; 43 | 44 | var vline = "\u2502".bold.grey; // │ 45 | var hline = "\u2500".bold.grey; // ─ 46 | var vhcross = "\u253c".bold.grey; // ┼ 47 | var vright = "\u251c".bold.grey; // ├ 48 | var vleft = "\u2524".bold.grey; // ┤ 49 | var hup = "\u2534".bold.grey; // ┴ 50 | var hdown = "\u252c".bold.grey; // ┬ 51 | var ctl = "\u250c".bold.grey; // ┌ 52 | var cbl = "\u2514".bold.grey; // └ 53 | var ctr = "\u2510".bold.grey; // ┐ 54 | var cbr = "\u2518".bold.grey; // ┘ 55 | 56 | // Calcutare the biggest lengths for each column 57 | arr.forEach(el => { 58 | el.forEach((s, i) => { 59 | s = s.toString(); 60 | if (maxlen[i] === undefined || maxlen[i] < s.length) 61 | maxlen[i] = reallen(s); 62 | }); 63 | }); 64 | 65 | // Create pretty lines 66 | var tablesep = ""; // separator between names and values 67 | var tabletop = ""; // top of the table 68 | var tablebot = ""; // bottom of the table 69 | maxlen.forEach((n, i) => { 70 | tablesep += new Array(n + 3).join(hline); 71 | tabletop += new Array(n + 3).join(hline); 72 | tablebot += new Array(n + 3).join(hline); 73 | if (i !== maxlen.length - 1) { 74 | tablesep += vhcross; 75 | tabletop += hdown; 76 | tablebot += hup; 77 | } 78 | }); 79 | tablesep = vright + tablesep + vleft; 80 | tabletop = ctl + tabletop + ctr; 81 | tablebot = cbl + tablebot + cbr; 82 | 83 | // Print the lines 84 | console.log(tabletop); 85 | arr.forEach((el, i) => { 86 | var line = ""; 87 | el.forEach((s, j) => { 88 | s = s.toString(); 89 | var len = maxlen[j]; 90 | 91 | // The first row should be colored, as it's the titles 92 | if (i === 0) 93 | s = s.bold.cyan; 94 | 95 | // Right pad with spaces 96 | var missing = len - reallen(s); 97 | for (var k = 0; k < missing; ++k) s = s+" " 98 | 99 | // Add |s 100 | if (j !== 0) 101 | s = " "+vline+" "+s; 102 | 103 | line += s; 104 | }); 105 | console.log(vline+" "+line+" "+vline); 106 | 107 | // Print the separator between the titles and the values 108 | if (i === 0) 109 | console.log(tablesep); 110 | }); 111 | console.log(tablebot); 112 | } 113 | 114 | function copy(p1, p2) { 115 | var rs = fs.createWriteStream(p2); 116 | fs.createReadStream(p1).pipe(rs); 117 | } 118 | 119 | function fileExists(path) { 120 | try { 121 | fs.accessSync(path, fs.F_OK); 122 | return true; 123 | } catch (err) { 124 | return false; 125 | } 126 | } 127 | 128 | function ipcConn() { 129 | var conn; 130 | try { 131 | conn = net.createConnection(confpath+"/tlsproxy.sock"); 132 | 133 | function send(name, data, cb) { 134 | var obj = { 135 | name: name, 136 | data: data 137 | }; 138 | conn.write(JSON.stringify(obj)); 139 | 140 | conn.once("data", d => { 141 | var obj = JSON.parse(d); 142 | if (obj.error) { 143 | console.error(obj.error); 144 | process.exit(1); 145 | } 146 | cb(obj); 147 | }); 148 | } 149 | 150 | return { 151 | send: send, 152 | }; 153 | } catch (err) { 154 | if (err.code === "ENOENT") 155 | throw "tlsproxy is not running!"; 156 | else 157 | console.trace(err); 158 | } 159 | return conn; 160 | } 161 | 162 | var cmds = { 163 | "help": function() { 164 | console.log("Usage: "+process.argv[1]+" "); 165 | console.log("commands:"); 166 | console.log("\thelp: show this help text"); 167 | console.log("\tversion: show the version"); 168 | console.log("\tsetup: set up init scripts and conf file"); 169 | console.log("\tproc-list: list processes managed by tlsproxy"); 170 | console.log("\tproc-start : start a process"); 171 | console.log("\tproc-stop : stop a process"); 172 | console.log("\tproc-restart : restart a process"); 173 | }, 174 | 175 | "version": function() { 176 | var conn = ipcConn(); 177 | conn.send("version", {}, r => { 178 | var srvver = r.version; 179 | console.log("Client version: "+version); 180 | console.log("Server version: "+srvver); 181 | process.exit(); 182 | }); 183 | }, 184 | "--version": function() { cmds.version(); }, 185 | 186 | "setup": function() { 187 | if (process.platform !== "linux") 188 | return console.log("Setup only supports Linux."); 189 | 190 | mkdirp.sync(confpath); 191 | mkdirp.sync(confpath+"/sites"); 192 | 193 | mkdirp.sync("/opt/tlsproxy"); 194 | if (fileExists ("/opt/tlsproxy/daemon.js")) 195 | fs.unlinkSync("/opt/tlsproxy/daemon.js"); 196 | fs.symlinkSync(__dirname+"/daemon.js", "/opt/tlsproxy/daemon.js"); 197 | 198 | // Default config 199 | if (!fileExists(confpath+"/conf.json")) { 200 | fs.writeFileSync(confpath+"/conf.json", JSON.stringify({ 201 | email: "example@example.com", 202 | testing: true, 203 | group: defaultGroup, 204 | user: defaultUser 205 | }, null, 4)); 206 | console.log(confpath+"/conf.json created. Please edit."); 207 | } 208 | 209 | var initpath = fs.realpathSync("/proc/1/exe"); 210 | 211 | // systemd 212 | if (initpath.indexOf("systemd") != -1) { 213 | copy( 214 | __dirname+"/init/tlsproxy.service", 215 | "/etc/systemd/system/tlsproxy.service"); 216 | console.log("tlsproxy installed."); 217 | console.log("Enable with 'systemctl enable tlsproxy',"); 218 | console.log("then start with 'systemctl start tlsproxy'"); 219 | } else { 220 | console.log("Systemd not detected, no unit file will be installed.") 221 | } 222 | }, 223 | 224 | "reload": function() { 225 | var conn = ipcConn(); 226 | conn.send("reload", {}, r => { 227 | console.log("Reloaded."); 228 | process.exit(); 229 | }); 230 | }, 231 | 232 | "proc-list": function() { 233 | var conn = ipcConn(); 234 | conn.send("proc-list", {}, r => { 235 | var table = [ 236 | [ "id", "state", "restarts" ] 237 | ]; 238 | r.forEach(proc => { 239 | var state = proc.state; 240 | if (state === "running") state = state.green.bold; 241 | else if (state === "stopped") state = state.yellow.bold; 242 | else state = state.red.bold; 243 | 244 | table.push([ proc.id, state, proc.restarts ]); 245 | }); 246 | printTable(table); 247 | process.exit(); 248 | }); 249 | }, 250 | 251 | "proc-start": function() { 252 | var conn = ipcConn(); 253 | conn.send("proc-start", { id: process.argv[3] }, r => { 254 | cmds["proc-list"](); 255 | }); 256 | }, 257 | 258 | "proc-stop": function() { 259 | var conn = ipcConn(); 260 | conn.send("proc-stop", { id: process.argv[3] }, r => { 261 | cmds["proc-list"](); 262 | }); 263 | }, 264 | 265 | "proc-restart": function() { 266 | var conn = ipcConn(); 267 | conn.send("proc-restart", { id: process.argv[3] }, r => { 268 | cmds["proc-list"](); 269 | }); 270 | } 271 | }; 272 | 273 | if (cmds[process.argv[2]]) 274 | cmds[process.argv[2]](); 275 | else 276 | cmds.help(); 277 | -------------------------------------------------------------------------------- /daemon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var confpath = process.env.PROXY_CONF; 4 | if (!confpath) 5 | confpath = "/etc/tlsproxy"; 6 | 7 | var fs = require("fs"); 8 | var pathlib = require("path"); 9 | var urllib = require("url"); 10 | var net = require("net"); 11 | var mkdirp = require("mkdirp"); 12 | var userid = require("./js/userid"); 13 | var certutil = require("./js/certutil"); 14 | var httputil = require("./js/httputil"); 15 | var pmutil = require("./js/pmutil"); 16 | 17 | var version = JSON.parse( 18 | fs.readFileSync(__dirname+"/package.json", "utf-8")).version 19 | 20 | var conf = JSON.parse(fs.readFileSync(confpath+"/conf.json")); 21 | conf.confpath = confpath; 22 | 23 | if (conf.testing) 24 | console.log("Testing mode enabled."); 25 | 26 | var sites = confpath+"/sites"; 27 | mkdirp.sync(sites); 28 | 29 | function throwIfMissing(path, arr) { 30 | var missing = []; 31 | 32 | arr.forEach(elem => { 33 | if (elem[0] === undefined || elem[0] === null) 34 | missing.push(elem[1]); 35 | }); 36 | 37 | if (missing.length > 0) 38 | throw "Missing keys "+missing.join(", ")+" at "+path; 39 | } 40 | 41 | function addAction(path, host, action) { 42 | throwIfMissing(path, [ 43 | [host, "host"], 44 | [action, "action"]]); 45 | 46 | var url = urllib.parse(host); 47 | 48 | var port = url.port; 49 | var protocol = url.protocol; 50 | var domain = url.hostname; 51 | 52 | if (port === null) { 53 | if (protocol === "http:") 54 | port = 80; 55 | else if (protocol === "https:") 56 | port = 443; 57 | } 58 | 59 | try { 60 | httputil.host(conf, domain, port, protocol, action); 61 | } catch (err) { 62 | console.trace(err); 63 | throw err.toString()+" at "+path; 64 | } 65 | } 66 | 67 | function add(path, obj) { 68 | if (typeof obj !== "object") 69 | throw "Expected object, got "+(typeof obj)+" at "+path; 70 | 71 | if (obj.disabled) 72 | return; 73 | 74 | var host = obj.host; 75 | if (typeof host === "string") 76 | host = [host]; 77 | else if (!(host instanceof Array)) 78 | host = []; 79 | 80 | // Add action for each host 81 | host.forEach(h => { 82 | obj.host = h; 83 | addAction(path, h, obj.action); 84 | }); 85 | 86 | var redirectFrom = obj.redirectFrom; 87 | if (typeof redirectFrom === "string") 88 | redirectFrom = [redirectFrom]; 89 | else if (!(redirectFrom instanceof Array)) 90 | redirectFrom = []; 91 | 92 | // Add redirect for each redirectFrom 93 | redirectFrom.forEach((r, i) => { 94 | if (host[i] === undefined) 95 | return; 96 | 97 | var action = { 98 | type: "redirect", 99 | to: host[i]+"/$path" 100 | }; 101 | 102 | addAction(path, r, action); 103 | }); 104 | 105 | // Execute command 106 | if (typeof obj.exec === "object") { 107 | var exec = obj.exec; 108 | throwIfMissing(path, [ 109 | [exec.at, "exec.at"], 110 | [exec.run, "exec.run"], 111 | [exec.id, "exec.id"] 112 | ]); 113 | 114 | // Add PORT env variable if proxy 115 | var env = exec.env || {}; 116 | if ( 117 | env.PORT === undefined && 118 | obj.action !== undefined && 119 | obj.action.type === "proxy") { 120 | 121 | var port = urllib.parse(obj.action.to).port; 122 | if (port) 123 | env.PORT = port; 124 | } 125 | 126 | // get GID and UID 127 | var user, group; 128 | var gid, uid; 129 | try { 130 | if (exec.group) 131 | group = exec.group; 132 | else 133 | group = conf.group; 134 | 135 | if (exec.user) 136 | user = exec.user; 137 | else 138 | user = conf.user; 139 | 140 | gid = userid.gid(group); 141 | uid = userid.uid(user); 142 | } catch (err) { 143 | console.error( 144 | err.toString()+" with user "+ 145 | user+", group "+group+" at "+path); 146 | 147 | gid = null; 148 | uid = null; 149 | } 150 | 151 | if (gid !== null && uid !== null) { 152 | pmutil.add(exec.id, exec.run, { 153 | cwd: exec.at, 154 | env: env, 155 | gid: gid, 156 | uid: uid 157 | }); 158 | } 159 | } 160 | } 161 | 162 | // Go through site files and add them 163 | function load() { 164 | fs.readdirSync(sites).forEach(file => { 165 | var path = pathlib.join(sites, file); 166 | 167 | var site; 168 | try { 169 | site = JSON.parse(fs.readFileSync(path)); 170 | } catch (err) { 171 | throw "Failed to parse "+path+": "+err.toString(); 172 | } 173 | 174 | if (site instanceof Array) 175 | site.forEach(x => add(path, x)); 176 | else if (typeof site == "object") 177 | add(path, site); 178 | else 179 | throw "Expected array or object, got "+(typeof site)+" at "+path; 180 | }); 181 | } 182 | load(); 183 | 184 | // Remove the 185 | try { 186 | fs.accessSync(confpath+"/tlsproxy.sock", fs.F_OK); 187 | fs.unlinkSync(confpath+"/tlsproxy.sock"); 188 | } catch (err) { 189 | if (err.code !== "ENOENT") 190 | console.error(err); 191 | } 192 | 193 | function ipcServerHandler(name, data, write) { 194 | switch (name) { 195 | case "version": 196 | write({ 197 | version: version 198 | }); 199 | break; 200 | 201 | case "proc-list": 202 | write(pmutil.list()); 203 | break; 204 | 205 | case "proc-start": 206 | pmutil.start(data.id); 207 | write(); 208 | break; 209 | 210 | case "proc-stop": 211 | pmutil.stop(data.id, () => { 212 | write(); 213 | }); 214 | break; 215 | 216 | case "proc-restart": 217 | pmutil.restart(data.id, () => { 218 | write(); 219 | }); 220 | break; 221 | 222 | default: 223 | write(); 224 | } 225 | } 226 | 227 | var ipcServer = net.createServer(conn => { 228 | function send(obj) { 229 | conn.end(JSON.stringify(obj || {})); 230 | } 231 | 232 | conn.on("data", d => { 233 | try { 234 | var obj = JSON.parse(d); 235 | ipcServerHandler(obj.name, obj.data, send); 236 | } catch (err) { 237 | try { 238 | send({ 239 | error: err.toString() 240 | }); 241 | } catch (err) { 242 | console.error("Couldn't write to ipc socket"); 243 | } 244 | console.trace(err); 245 | } 246 | }); 247 | }); 248 | ipcServer.listen(confpath+"/tlsproxy.sock") 249 | ipcServer.on("error", err => { 250 | console.log("Could not connect to "+confpath+"/tlsproxy.sock:"); 251 | console.error(err.toString()); 252 | process.exit(1); 253 | }); 254 | 255 | function onTerm() { 256 | pmutil.cleanup(); 257 | ipcServer.close(() => { 258 | console.log("exiting"); 259 | process.exit(1); 260 | }); 261 | 262 | // IPC server may hang, we want to exit even if that happens 263 | setTimeout(() => process.exit(1), 1000); 264 | } 265 | 266 | process.on("SIGTERM", onTerm); 267 | process.on("SIGINT", onTerm); 268 | -------------------------------------------------------------------------------- /init/tlsproxy.service: -------------------------------------------------------------------------------- 1 | [Service] 2 | ExecStart=/usr/bin/env node /opt/tlsproxy/daemon.js 3 | StandardOutput=syslog 4 | SyslogIdentifier=tlsproxy 5 | Environment=NODE_ENV=production 6 | 7 | [Install] 8 | WantedBy=multi-user.target 9 | -------------------------------------------------------------------------------- /js/certutil.js: -------------------------------------------------------------------------------- 1 | var pathlib = require("path"); 2 | var redirectHttps = require("redirect-https"); 3 | var Lex = require("letsencrypt-express"); 4 | 5 | var acmePath = "/.well-known/acme-challenge"; 6 | var server; 7 | 8 | var lexes = {}; 9 | 10 | exports.register = register; 11 | exports.sniCallback = sniCallback; 12 | exports.acmeResponder = acmeResponder; 13 | 14 | function register(conf, domain) { 15 | if (conf.testing) 16 | server = "staging"; 17 | else 18 | server = "https://acme-v01.api.letsencrypt.org/directory"; 19 | 20 | var configDir = pathlib.join(conf.confpath, "letsencrypt"); 21 | if (conf.testing) 22 | configDir += "-testing"; 23 | 24 | var lex = Lex.create({ 25 | configDir: configDir, 26 | 27 | server: server, 28 | 29 | challenges: { 30 | "http-01": require("le-challenge-fs").create({ 31 | webrootPath: acmePath 32 | }) 33 | }, 34 | 35 | store: require("le-store-certbot").create({ 36 | webrootPath: acmePath 37 | }), 38 | 39 | approveDomains: function(opts, certs, cb) { 40 | if (certs) { 41 | opts.domains = certs.altnames; 42 | } else { 43 | opts.email = conf.email; 44 | opts.agreeTos = true; 45 | } 46 | 47 | if (opts.domain === domain) { 48 | cb(null, { 49 | options: opts, 50 | certs: certs 51 | }); 52 | } else { 53 | cb("Domain "+domain+" doesn't match!"); 54 | } 55 | } 56 | }); 57 | 58 | lexes[domain] = lex; 59 | } 60 | 61 | function sniCallback(domain, cb) { 62 | if (lexes[domain] && lexes[domain].httpsOptions) 63 | lexes[domain].httpsOptions.SNICallback(domain, cb); 64 | else 65 | cb(true); 66 | } 67 | 68 | var acmeResponders = {}; 69 | 70 | function acmeResponder(cb) { 71 | return function(req, res) { 72 | if (req.url.indexOf(acmePath) === 0) { 73 | var domain = req.headers.host; 74 | var responder = acmeResponders[domain]; 75 | if (!responder) { 76 | var lex = lexes[domain]; 77 | responder = lex.middleware(redirectHttps()); 78 | acmeResponders[domain] = responder; 79 | } 80 | 81 | responder(req, res); 82 | } else { 83 | cb(req, res); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /js/httputil/actions/proxy.js: -------------------------------------------------------------------------------- 1 | var urllib = require("url"); 2 | var http = require("http"); 3 | var https = require("https"); 4 | var parseUrl = require("../parse-url"); 5 | 6 | module.exports = function(req, res, action) { 7 | if (action.to === undefined) { 8 | res.writeHead(500); 9 | return res.end("Option 'to' not provided"); 10 | } 11 | 12 | var to = parseUrl(req, res, action.to); 13 | var url = urllib.parse(to); 14 | 15 | function onResponse(pres) { 16 | res.writeHead(pres.statusCode, pres.headers); 17 | pres.pipe(res); 18 | } 19 | 20 | var options = { 21 | host: url.host, 22 | hostname: url.hostname, 23 | port: url.port, 24 | method: req.method, 25 | path: url.path + req.url.substring(1), 26 | headers: req.headers 27 | } 28 | 29 | // 30 | if (url.protocol === "https:") { 31 | preq = https.request(options, onResponse); 32 | } else if (url.protocol === "http:") { 33 | preq = http.request(options, onResponse); 34 | } else { 35 | res.writeHead(400); 36 | return res.end("Unknown protocol: "+url.protocol); 37 | } 38 | 39 | req.pipe(preq); 40 | 41 | preq.on("error", err => { 42 | res.writeHead(502); 43 | res.end(err.toString()); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /js/httputil/actions/redirect.js: -------------------------------------------------------------------------------- 1 | var parseUrl = require("../parse-url"); 2 | 3 | module.exports = function(req, res, action) { 4 | if (action.to === undefined) { 5 | res.writeHead(500); 6 | return res.end("Option 'to' not provided"); 7 | } 8 | 9 | var code = 302; 10 | if (action.code) 11 | code = action.code; 12 | 13 | var to = parseUrl(req, res, action.to); 14 | res.writeHead(code, { 15 | "location": to 16 | }); 17 | res.end("Redirecting to "+to); 18 | } 19 | -------------------------------------------------------------------------------- /js/httputil/actions/serve.js: -------------------------------------------------------------------------------- 1 | var pathlib = require("path"); 2 | var fs = require("fs"); 3 | var mime = require("mime"); 4 | 5 | module.exports = function(req, res, action) { 6 | if (action.path === undefined) { 7 | res.writeHead(500); 8 | return res.end("Option 'path' not provided"); 9 | } 10 | 11 | var path = pathlib.join(action.path, req.url); 12 | if (path.indexOf(action.path) !== 0) { 13 | res.writeHead(403); 14 | return res.end("Unauthorized"); 15 | } 16 | 17 | var index = action.index; 18 | if (index === undefined || index === null) { 19 | index = ["index.html", "index.htm"]; 20 | } else if (typeof index === "string") { 21 | index = [index]; 22 | } else { 23 | res.writeHead(500); 24 | return res.end("Option 'index' is invalid"); 25 | } 26 | 27 | serve(req, res, path, index); 28 | } 29 | 30 | function serveDirectory(req, res, path, index) { 31 | // Add / to the end if it doesn't exist already 32 | if (req.url[req.url.length - 1] !== "/") { 33 | res.writeHead(302, { 34 | location: req.url+"/" 35 | }); 36 | res.end("Redirecting to "+req.url+"/"); 37 | 38 | // Serve index 39 | } else { 40 | var valid = []; 41 | var cbs = index.length; 42 | 43 | function accessCb(err, name, i) { 44 | if (!err) 45 | valid[i] = name; 46 | 47 | cbs -= 1; 48 | if (cbs !== 0) 49 | return; 50 | 51 | var idx = null; 52 | for (var j = 0; j < index.length; ++j) { 53 | if (valid[j]) { 54 | idx = valid[j]; 55 | break; 56 | } 57 | } 58 | 59 | if (idx === null) { 60 | res.writeHead(404); 61 | res.end("404 not found: "+req.url); 62 | return; 63 | } 64 | serveFile(req, res, pathlib.join(path, idx)); 65 | } 66 | 67 | index.forEach((name, i) => { 68 | fs.access(pathlib.join(path, name), fs.F_OK, err => { 69 | accessCb(err, name, i); 70 | }); 71 | }); 72 | } 73 | } 74 | 75 | function serveFile(req, res, path, stat) { 76 | if (!stat) { 77 | fs.stat(path, (err, stat) => { 78 | if (err && err.code === "ENOENT") { 79 | res.writeHead(404); 80 | res.end("404 not found: "+req.url); 81 | return; 82 | } 83 | 84 | serveFile(req, res, path, stat); 85 | }); 86 | return; 87 | } 88 | 89 | var mimetype = mime.lookup(path); 90 | var readstream; 91 | 92 | var range = req.headers.range; 93 | 94 | var parts; 95 | if (range) 96 | parts = range.replace("bytes=", "").split("-"); 97 | else 98 | parts = [0]; 99 | 100 | var start = Math.max((parts[0] || 0), 0); 101 | var end; 102 | if (parts[1]) 103 | end = Math.min(parseInt(parts[1]), stat.size - 1); 104 | else 105 | end = stat.size - 1; 106 | 107 | var chunksize = (end - start) + 1; 108 | 109 | var headers = { 110 | "content-type": mimetype, 111 | "content-length": chunksize, 112 | }; 113 | if (range) { 114 | headers["content-range"] = 115 | "bytes " + start + "-" + end + "/" + stat.size; 116 | } else { 117 | headers["accept-ranges"] = "bytes"; 118 | } 119 | 120 | if (start > end) { 121 | res.writeHead(416); 122 | res.end("Range not satisfiable. Start: "+start+", end: "+end); 123 | return; 124 | } 125 | 126 | res.writeHead(range ? 206 : 200, headers); 127 | 128 | if (req.method == "HEAD") { 129 | res.end(); 130 | return; 131 | } 132 | 133 | fs.createReadStream(path, { start: start, end: end }) 134 | .on("data", d => res.write(d)) 135 | .on("end", () => res.end()) 136 | .on("error", err => res.end(err.toString())); 137 | } 138 | 139 | function serve(req, res, path, index) { 140 | fs.stat(path, (err, stat) => { 141 | if (err) { 142 | if (err.code === "ENOENT") { 143 | res.writeHead(404); 144 | res.end("404 not found: "+req.url); 145 | } else { 146 | res.writeHead(500); 147 | res.end(err.toString()); 148 | } 149 | return; 150 | } 151 | 152 | if (stat.isDirectory()) { 153 | serveDirectory(req, res, path, index); 154 | } else if (stat.isFile()) { 155 | serveFile(req, res, path, stat); 156 | } else { 157 | res.writeHead(500); 158 | res.end("Invalid path requested"); 159 | } 160 | }); 161 | } 162 | -------------------------------------------------------------------------------- /js/httputil/index.js: -------------------------------------------------------------------------------- 1 | var http = require("http"); 2 | var https = require("https"); 3 | var urllib = require("url"); 4 | var mime = require("mime"); 5 | var certutil = require("../certutil"); 6 | var WSProxy = require("./wsproxy"); 7 | 8 | exports.host = host; 9 | 10 | var actions = { 11 | redirect: require("./actions/redirect"), 12 | proxy: require("./actions/proxy"), 13 | serve: require("./actions/serve"), 14 | none: function() {} 15 | } 16 | 17 | function Server(conf, port, protocol) { 18 | var self = {}; 19 | 20 | var domains = {}; 21 | var wsdomains = {}; 22 | 23 | function onRequest(req, res) { 24 | if (typeof req.headers.host !== "string") { 25 | res.writeHead(400); 26 | res.end("No host header!"); 27 | console.log("Received request with no host header."); 28 | return; 29 | } 30 | 31 | var domain = req.headers.host.split(":")[0]; 32 | var action = domains[domain]; 33 | 34 | if (action === undefined) 35 | return res.end("Unknown host: "+domain); 36 | 37 | var h = actions[action.type]; 38 | if (!h) { 39 | res.writeHead(500); 40 | res.end("Unknown action type: "+ation.type); 41 | return; 42 | } 43 | h(req, res, action); 44 | } 45 | 46 | // Create http/https server 47 | var srv; 48 | if (protocol === "https:") { 49 | var opts = { 50 | SNICallback: certutil.sniCallback 51 | }; 52 | srv = https.createServer(opts, certutil.acmeResponder(onRequest)); 53 | } else if (protocol === "http:") { 54 | srv = http.createServer(certutil.acmeResponder(onRequest)); 55 | } else { 56 | throw "Unknown protocol: "+protocol; 57 | } 58 | 59 | // Listen 60 | srv.listen(port); 61 | console.log(protocol+" listening on port "+port); 62 | 63 | // Create websocket server 64 | var wssrv = WSProxy(wsdomains, srv); 65 | 66 | self.addDomain = function(domain, action) { 67 | if (actions[action.type] === undefined) 68 | throw "Unknown action type: "+action.type+" for "+domain; 69 | 70 | domains[domain] = action; 71 | 72 | if (action.type === "proxy" && typeof action.websocket === "string") 73 | wsdomains[domain] = action; 74 | 75 | if (protocol === "https:") { 76 | certutil.register(conf, domain); 77 | } 78 | } 79 | 80 | self.close = function(cb) { 81 | srv.close(cb); 82 | } 83 | 84 | return self; 85 | } 86 | 87 | var servers = {}; 88 | 89 | function host(conf, domain, port, protocol, action) { 90 | 91 | // Get or create server for port 92 | var srv = servers[port]; 93 | if (srv == undefined) { 94 | srv = Server(conf, port, protocol); 95 | servers[port] = srv; 96 | } 97 | 98 | // Add the domain to server 99 | srv.addDomain(domain, action); 100 | 101 | // Need an HTTP server for letsencrypt 102 | if (servers[80] == undefined && protocol === "https:") { 103 | servers[80] = Server(conf, 80, "http:"); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /js/httputil/parse-url.js: -------------------------------------------------------------------------------- 1 | module.exports = function(req, res, url) { 2 | return url 3 | .replace(/\$host/g, req.headers.host) 4 | .replace(/\$path/g, req.url.substring(1)); 5 | } 6 | -------------------------------------------------------------------------------- /js/httputil/wsproxy.js: -------------------------------------------------------------------------------- 1 | var WSServer = require("ws").Server; 2 | var WSClient = require("ws"); 3 | 4 | // Some codes are reserved, so we need to translate them 5 | // into something else 6 | function translateCode(code) { 7 | if (code === 1004 || code === 1005 || code === 1006) 8 | return 1000; 9 | else 10 | return code; 11 | } 12 | 13 | function onWSSocket(wsdomains, sock) { 14 | var req = sock.upgradeReq; 15 | if (typeof req.headers.host !== "string") { 16 | console.log("Received websocket request with no host header."); 17 | return; 18 | } 19 | 20 | var domain = req.headers.host.split(":")[0]; 21 | var action = wsdomains[domain]; 22 | 23 | if (action === undefined) 24 | return console.log("Unknown websocket host: "+domain); 25 | 26 | var psockQueue = []; 27 | var psockClosed = false; 28 | var sockClosed = false; 29 | var psock = new WSClient(action.websocket); 30 | 31 | // Send messages, or queue them up 32 | sock.on("message", msg => { 33 | if (psock.readyState === 1) { 34 | psock.send(msg); 35 | } else { 36 | psockQueue.push(msg); 37 | } 38 | }); 39 | psock.on("open", () => { 40 | psockQueue.forEach(msg => psock.send(msg)); 41 | psockQueue = null; 42 | }); 43 | 44 | psock.on("message", msg => { 45 | sock.send(msg); 46 | }); 47 | 48 | // Close one socket when the other one closes 49 | sock.on("close", (code, msg) => { 50 | sockClosed = true; 51 | if (!psockClosed) 52 | psock.close(translateCode(code), msg); 53 | }); 54 | psock.on("close", (code, msg) => { 55 | psockClosed = true; 56 | if (!sockClosed) 57 | sock.close(translateCode(code), msg); 58 | }); 59 | 60 | // Catch errors 61 | sock.on("error", err => { 62 | console.log("websocket proxy:", err.code); 63 | }); 64 | psock.on("error", err => { 65 | if (err.code !== "ECONNRESET") 66 | console.log("websocket proxy:", err.code); 67 | }); 68 | } 69 | 70 | module.exports = function(wsdomains, server) { 71 | var self = {}; 72 | 73 | var wssrv = new WSServer({ server: server }); 74 | wssrv.on("connection", function(sock) { 75 | onWSSocket(wsdomains, sock); 76 | }); 77 | 78 | return self; 79 | } 80 | -------------------------------------------------------------------------------- /js/pmutil.js: -------------------------------------------------------------------------------- 1 | var childProcess = require("child_process"); 2 | 3 | exports.add = add; 4 | exports.list = list; 5 | exports.start = start; 6 | exports.stop = stop; 7 | exports.restart = restart; 8 | exports.cleanup = cleanup; 9 | 10 | var processes = {}; 11 | var restartLimit = 10; 12 | 13 | class Process { 14 | constructor(id, run, options) { 15 | this.proc = null; 16 | this.id = id; 17 | this.cmd = run[0]; 18 | this.args = []; 19 | this.options = options; 20 | this.state = "stopped"; 21 | this.running = false; 22 | 23 | for (var i = 1; i < run.length; ++i) 24 | this.args.push(run[i]); 25 | 26 | this.restarts = 0; 27 | this.restartsResetTimeout = null; 28 | this.restartLimit = 15 29 | } 30 | 31 | onexit(code) { 32 | this.log("Process exited with code "+code+"."); 33 | this.running = false; 34 | 35 | if (this.state === "stopped") 36 | return; 37 | 38 | this.state = "errored"; 39 | 40 | if (this.restarts >= this.restartLimit) { 41 | this.log("Not restarting anymore after "+this.restarts+" restarts."); 42 | this.stop(); 43 | this.state = "errored"; 44 | return; 45 | } 46 | 47 | this.restarts += 1; 48 | 49 | this.log("Restarting in "+this.restarts+" seconds."); 50 | 51 | if (this.restartsResetTimeout) { 52 | clearTimeout(this.restartsResetTimeout); 53 | this.restartsResetTimeout = null; 54 | } 55 | 56 | setTimeout(() => { 57 | if (this.state === "stopped") { 58 | this.log("Not restarting anymore because state is stopped."); 59 | return; 60 | } 61 | 62 | this.start(); 63 | this.restartsResetTimeout = setTimeout(() => { 64 | this.restarts = 0; 65 | }, 5000); 66 | }, this.restarts * 1000); 67 | } 68 | 69 | start() { 70 | if (this.state === "running") 71 | throw "Process "+this.id+" already running."; 72 | 73 | this.state = "running"; 74 | this.proc = childProcess.spawn(this.cmd, this.args, this.options); 75 | this.running = true; 76 | this.log("Started process with pid "+this.proc.pid+"."); 77 | 78 | this.proc.stdout.on("data", d => { 79 | this.log(d.toString(), "stdout"); 80 | }); 81 | this.proc.stderr.on("data", d => { 82 | this.log(d.toString(), "stderr"); 83 | }); 84 | 85 | this.proc.on("error", err => { 86 | this.trace(err); 87 | this.state = "errored"; 88 | this.running = false; 89 | }); 90 | 91 | this.proc.on("exit", code => this.onexit(code)); 92 | } 93 | 94 | stop(cb) { 95 | if (!this.running && this.state === "stopped") 96 | return cb(); 97 | 98 | cb = cb || function() {}; 99 | 100 | this.state = "stopped"; 101 | this.proc.kill("SIGTERM"); 102 | 103 | var done = false; 104 | 105 | // SIGKILL if we haven't exited after a bit 106 | setTimeout(() => { 107 | if (this.running && !done) { 108 | done = true; 109 | this.log("Killing process because it didn't stop on SIGTERM."); 110 | this.proc.kill("SIGKILL"); 111 | this.running = false; 112 | this.state = "stopped"; 113 | 114 | setTimeout(cb, 100); 115 | } else if (!this.running && !done) { 116 | cb(); 117 | } 118 | }, 1000); 119 | 120 | // If we exit immediately, SIGTERM isn't necessary 121 | setTimeout(() => { 122 | if (!this.running && !done) { 123 | done = true; 124 | cb(); 125 | } 126 | }, 100); 127 | } 128 | 129 | log(msg, prefix) { 130 | msg = msg.substring(0, msg.length - 1); 131 | msg.split("\n").forEach(l => { 132 | if (prefix) 133 | l = prefix+": "+l; 134 | 135 | console.log("Process '"+this.id+"': "+l); 136 | }); 137 | } 138 | 139 | trace(err) { 140 | console.log("Process '"+this.id+"': Error:"); 141 | console.trace(err); 142 | } 143 | 144 | serialize() { 145 | return { 146 | id: this.id, 147 | state: this.state, 148 | restarts: this.restarts 149 | }; 150 | } 151 | } 152 | 153 | function add(id, run, options) { 154 | if (!(run instanceof Array)) 155 | throw "Expected run to be an array, got "+(typeof run); 156 | 157 | var proc = new Process(id, run, options); 158 | processes[proc.id] = proc; 159 | proc.start(); 160 | } 161 | 162 | function list() { 163 | var res = []; 164 | for (var i in processes) { 165 | if (!processes.hasOwnProperty(i)) 166 | continue; 167 | 168 | var proc = processes[i]; 169 | res.push(proc.serialize()); 170 | } 171 | return res; 172 | } 173 | 174 | function start(id) { 175 | var proc = processes[id]; 176 | if (!proc) 177 | throw "Process "+id+" doesn't exist."; 178 | if (proc.state === "running") 179 | throw "Process "+id+" is already running."; 180 | 181 | proc.start(); 182 | } 183 | 184 | function stop(id, cb) { 185 | var proc = processes[id]; 186 | if (!proc) 187 | throw "Process "+id+" doesn't exist."; 188 | if (proc.state === "stopped") 189 | throw "Process "+id+" is already stopped."; 190 | 191 | proc.stop(cb); 192 | } 193 | 194 | function restart(id, cb) { 195 | var proc = processes[id]; 196 | if (!proc) 197 | throw "Process "+id+" doesn't exist."; 198 | 199 | function h() { 200 | proc.start(); 201 | cb(); 202 | } 203 | 204 | if (!proc.stopped) 205 | proc.stop(h); 206 | else 207 | h(); 208 | } 209 | 210 | function cleanup() { 211 | for (var i in processes) { 212 | if (!processes.hasOwnProperty(i)) 213 | continue; 214 | 215 | var proc = processes[i]; 216 | if (!proc || !proc.running) 217 | continue; 218 | 219 | proc.stop(); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /js/userid.js: -------------------------------------------------------------------------------- 1 | var spawnSync = require("child_process").spawnSync; 2 | 3 | exports.uid = function(user) { 4 | var res = spawnSync("id", [ 5 | "--user", user 6 | ]); 7 | var n = parseInt(res.stdout); 8 | 9 | if (isNaN(n)) return false; 10 | else return n; 11 | } 12 | 13 | exports.gid = function(group) { 14 | var res = spawnSync("id", [ 15 | "--group", group 16 | ]); 17 | var n = parseInt(res.stdout); 18 | 19 | if (isNaN(n)) return false; 20 | else return n; 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tlsproxy", 3 | "version": "1.10.1", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "colors": "^1.1.2", 14 | "letsencrypt-express": "^2.0.0", 15 | "mime": "^1.3.4", 16 | "mkdirp": "^0.5.1", 17 | "ws": "^1.1.1" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/mortie/tlsproxy" 22 | }, 23 | "bin": { 24 | "tlsproxy": "./cli.js" 25 | } 26 | } 27 | --------------------------------------------------------------------------------