diff --git a/neptune/mega/tunnel/api.js b/neptune/mega/tunnel/api.js index 46bc584e9..e077c0263 100644 --- a/neptune/mega/tunnel/api.js +++ b/neptune/mega/tunnel/api.js @@ -1,4 +1,4 @@ -export default function ({ app, mesh }) { +export default function ({ app, mesh, punch }) { var currentListens = [] var currentTargets = {} @@ -39,9 +39,12 @@ export default function ({ app, mesh }) { function getInbound(ep, protocol, name) { if (ep === app.endpoint.id) { return getLocalConfig().then( - config => config.inbound.find( - i => i.protocol === protocol && i.name === name - ) || null + config => { + var inbound = config.inbound.find( + i => i.protocol === protocol && i.name === name + ) || null + var hole = punch.findHole(ep) + } ) } else { return requestPeer(ep, new Message( @@ -170,6 +173,33 @@ export default function ({ app, mesh }) { } } + function createHole(ep, role) { + var h = punch.findHole(ep) + if(h) return h + + if(role === 'server') { + return punch.createOutboundHole(ep) + } else if(role === 'client') { + return punch.createInboundHole(ep) + } + } + + function updateHoleInfo(ep, ip, port, cert) { + checkIP(ip) + checkPort(Number.parseInt(port)) + punch.updateHoleInfo(ep, ip, port, cert) + } + + function syncPunch(ep) { + var hole = punch.findHole(ep) + if(!hole) throw `Invalid Hole State for ${ep}` + hole.punch() + } + + function deleteHole(ep, remote) { + punch.deleteHole(ep, remote) + } + function getLocalConfig() { return mesh.read('/local/config.json').then( data => data ? JSON.decode(data) : { inbound: [], outbound: [] } @@ -197,23 +227,34 @@ export default function ({ app, mesh }) { currentListens = [] currentTargets = {} + console.info(`Applying config: ${JSON.encode(config)}`) + config.inbound.forEach(i => { var protocol = i.protocol var name = i.name var listens = i.listens - var $selectedEP + var connectPeer = pipeline($=>$ .connectHTTPTunnel( new Message({ method: 'CONNECT', path: `/api/outbound/${protocol}/${name}`, }) - ).to($=>$ - .muxHTTP().to($=>$ - .pipe(() => mesh.connect($selectedEP)) + ).to($=>$.pipe(() => { + punch.createInboundHole($selectedEP) + var hole = punch.findHole($selectedEP) + if(hole && hole.ready()) { + console.info("Using direct session") + return hole.directSession() + } + console.info("Using hub forwarded session") + return pipeline($=>$ + .muxHTTP().to($=>$ + .pipe(mesh.connect($selectedEP)) + ) ) - ) + })) .onEnd(() => app.log(`Disconnected from ep ${$selectedEP} for ${protocol}/${name}`)) ) @@ -285,14 +326,24 @@ export default function ({ app, mesh }) { var $response return pipeline($=>$ .onStart(req) - .muxHTTP().to($=>$ - .pipe(mesh.connect(ep)) - ) + .pipe(() => { + var h = punch.findHole(ep) + if(h && h.ready()) { + return h.directSession() + } + return pipeline($=>$ + .muxHTTP().to($=>$ + .pipe(mesh.connect(ep)) + )) + }) .replaceMessage(res => { $response = res return new StreamEnd }) - .onEnd(() => $response) + .onEnd(() => { + console.info('Answers in api: ', $response) + return $response + }) ).spawn() } @@ -343,6 +394,22 @@ export default function ({ app, mesh }) { ) ) + var tunnelHole = null + var makeRespTunnel = pipeline($=>$ + .onStart(ctx => { + console.info("Making resp tunnel: ", ctx) + var ep = ctx.peer.id + tunnelHole = punch.findHole(ep) + if(!tunnelHole) throw `Invalid Hole State for ${ep}` + return new Data + }) + .pipe(() => { + var p = tunnelHole.makeRespTunnel() + tunnelHole = null + return p + }, () => tunnelHole) + ) + getLocalConfig().then(applyLocalConfig) return { @@ -355,6 +422,11 @@ export default function ({ app, mesh }) { setOutbound, deleteInbound, deleteOutbound, + createHole, + updateHoleInfo, + makeRespTunnel, + syncPunch, + deleteHole, servePeerInbound, } } diff --git a/neptune/mega/tunnel/main.js b/neptune/mega/tunnel/main.js index c899ed385..4089ea68a 100644 --- a/neptune/mega/tunnel/main.js +++ b/neptune/mega/tunnel/main.js @@ -1,8 +1,10 @@ +import initHole from './punch.js' import initAPI from './api.js' import initCLI from './cli.js' export default function ({ app, mesh, utils }) { - var api = initAPI({ app, mesh }) + var punch = initHole({ app, mesh }) + var api = initAPI({ app, mesh, punch }) var cli = initCLI({ app, mesh, utils, api }) var $ctx @@ -82,6 +84,17 @@ export default function ({ app, mesh, utils }) { }), }, + // '/api/punch/{destEp}': { + // 'GET': responder(({destEp}) => { + // return api.createHole(destEp) + // }), + + // 'DELETE': responder(({destEp}) => { + // api.deleteHole(destEp) + // return response(204) + // }) + // }, + '*': { 'GET': responder((_, req) => { return Promise.resolve(gui.serve(req) || response(404)) @@ -137,6 +150,64 @@ export default function ({ app, mesh, utils }) { 'CONNECT': api.servePeerInbound, }, + + '/api/ping': { + 'GET': responder(() => Promise.resolve(response(200))) + }, + + '/api/punch/{action}': { + 'GET': responder(({action}) => { + var ep = $ctx.peer.id + var ip = $ctx.peer.ip + var port = $ctx.peer.port + + console.log(`Punch Event: ${action} from ${ep} ${ip} ${port}`) + switch(action) { + case 'leave': + api.deleteHole(ep, true) + break + default: + return Promise.resolve(response(500, "Unknown punch action")) + } + return Promise.resolve(response(200)) + }), + + 'POST': responder(({action}, req) => { + var obj = JSON.decode(req.body) + var ep = $ctx.peer.id + var ip = $ctx.peer.ip + var port = $ctx.peer.port + + console.log(`Punch Event: ${action} from ${ep} ${ip} ${port}`) + console.log("Punch req: ", obj) + switch(action) { + case 'request': + api.createHole(ep, 'server') + api.updateHoleInfo(ep, ip, port, obj.cert) + api.syncPunch(ep) + // var certs = hole.signPeerCert(new crypto.PublicKey(obj.pkey)) + // return Promise.resolve(response(200, cert)) + break + case 'accept': + api.updateHoleInfo(ep, ip, port, obj.cert) + api.syncPunch(ep) + break + default: + return Promise.resolve(response(500, "Unknown punch action")) + } + return Promise.resolve(response(200)) + }), + + 'CONNECT': pipeline($=>$.pipe(api.makeRespTunnel, () => $ctx)) + }, + }) + + punch.setService((ctx) => { + // Tricky callback to set ctx, + // expecting everything in hole works + // just like it's coming from hub. + $ctx = ctx + return servePeer }) return pipeline($=>$ diff --git a/neptune/mega/tunnel/punch.js b/neptune/mega/tunnel/punch.js new file mode 100644 index 000000000..029a0b247 --- /dev/null +++ b/neptune/mega/tunnel/punch.js @@ -0,0 +1,522 @@ +export default function ({ app, mesh }) { + // Only available for symmetric NAT + function Hole(ep) { + // (idle) (handshake) (punching connected closed) (left fail) + var state = 'idle' + var bound = '0.0.0.0:' + randomPort() + var destIP = null + var destPort = null + var role = null + var session = null + var rtt = null + + var tlsOptions = { + certificate: null, + trusted: null + } + + var pHub = new pipeline.Hub + var $connection = null + var $response + + + // Check if ep is self. + console.info(`Creating hole to peer ${ep}, bound ${bound}`) + if (ep === app.endpoint.id) { + throw 'Must not create a hole to self' + } + + function directSession() { + if (!role || !destIP || !destPort) throw 'Hole not init correctly' + if (session) return session + + var retryTimes = 0 + + var buildCtx = () => { + return { + source: 'direct', + self: { + id: app.endpoint.id, + }, + peer: { + id: ep, + ip: destIP, + port: destPort, + } + } + } + + if (role === 'client') { + var reverseTunnel = null + var reverseTunnelStarted = false + + // make session to server side directly + session = pipeline($ => $ + .muxHTTP(() => ep + "direct", { version: 2 }).to($ => $ + .connectTLS({ + ...tlsOptions, + onState: tls => { + console.info('TLS State: ', tls) + if($connection.state === 'connected' && tls.state === 'connected') { + app.log(`Connected TLS to peer ${destIP}:${destPort}`) + state = 'connected' + retryTimes = 0 + + if (!reverseTunnelStarted) { + reverseTunnel.spawn() + reverseTunnelStarted = true + } + } + } + }).to($ => $ + .connect(() => `${destIP}:${destPort}`, { + bind: bound, + onState: function (conn) { + console.info("Conn Info: ", conn) + + if (conn.state === 'open') { + conn.socket.setRawOption(1, 15, new Data([1, 0, 0, 0])) + } else if (conn.state === 'connected') { + app.log(`Connected to peer ${destIP}:${destPort}`) + $connection = conn + } else if (conn.state === 'closed') { + app.log(`Disconnected from peer ${destIP}:${destPort}`) + $connection = null + state = 'closed' + retryTimes += 1 + } + + // Max Retry set to 10 + if (retryTimes > 10 || state === 'fail') { + console.info(`Retry limit exceeded, punch failed.`) + state = 'fail' + updateHoles() + } + }, + }) + .handleStreamEnd(evt => console.info('Hole connection end, retry: ', retryTimes + 1, ' reason: ', evt?.error)) + ) + ) + ) + + // reverse server for receiving requests + reverseTunnel = pipeline($ => $ + .onStart(new Data) + .repeat(() => new Timeout(1).wait().then(() => { + return state != 'fail' && state != 'left' + })).to($ => $ + .loop($ => $ + .connectHTTPTunnel( + new Message({ + method: 'CONNECT', + path: `/api/punch/tunnel`, + }) + ) + .to(session) + .pipe(() => svc(buildCtx())) + ) + ) + ) + + // Forced Heartbeats + // Do a PCR to the hole. + pacemaker() + + } else if (role === 'server') { + var $msg = null + var listen = pipeline($ => $ + .acceptTLS({ + ...tlsOptions, + onState: tls => console.info('TLS State: ', tls) + }).to($ => $ + .handleMessage(msg => { + console.info('Server Received: ', msg) + $msg = msg + return new Data + }).pipe(() => svc(buildCtx())), () => $msg + ) + ) + + console.info("Direct Server Listening...") + pipy.listen(bound, 'tcp', listen) + + session = pipeline($ => $ + .muxHTTP(() => ep + "direct", { version: 2 }).to($ => $ + .swap(() => pHub) + ) + ) + } + return session + } + + function request(req, callback) { + var store = req + return pipeline($ => $ + .onStart(req) + .muxHTTP().to($ => $.pipe( + mesh.connect(ep, { + bind: bound, + onState: conn => { + if (conn.state === 'open') + conn.socket.setRawOption(1, 15, new Data([1, 0, 0, 0])) + } + }) + )) + .print() + .replaceMessage(res => { + $response = res + return new StreamEnd + }) + .onEnd(() => { + console.info('Answers in hole: ', $response, store) + if (callback) + callback($response) + return $response + }) + ).spawn() + } + + // use THE port sending request to hub. + function requestPunch() { + role = 'client' + state = 'handshake' + var start = Date.now() + + console.info("Requesting punch") + request(new Message({ + method: 'POST', + path: '/api/punch/request', + }, JSON.encode({ + timestamp: Date.now(), + cert: genCert() + })), (resp) => { + var end = Date.now() + rtt = (end - start) / 2000 + console.info('Estimated RTT: ', rtt) + + if (resp.head.status != 200) { + app.log(`Failed on requesting`) + state = 'fail' + updateHoles() + } + }) + new Timeout(60).wait().then(connectOrFail) + } + + function acceptPunch() { + role = 'server' + state = 'handshake' + var start = Date.now() + + console.info("Accepting punch") + request(new Message({ + method: 'POST', + path: '/api/punch/accept', + }, JSON.encode({ + timestamp: Date.now(), + cert: genCert() + })), (resp) => { + var end = Date.now() + rtt = (end - start) / 2000 + console.info('Estimated RTT: ', rtt) + + if (!resp || resp.head.status != 200) { + app.log(`Failed on accepting`) + state = 'fail' + updateHoles() + } + }) + + new Timeout(60).wait().then(connectOrFail) + } + + // Locally generate certificate. + // The handshake process and hub + // will ensure peer is trustworthy + function genCert() { + var key = new crypto.PrivateKey({ type: 'rsa', bits: 2048 }) + var pKey = new crypto.PublicKey(key) + var cert = new crypto.Certificate({ + subject: { CN: 'Fake CA' }, + publicKey: pKey, + privateKey: key, + days: 365, + }) + + cert = new crypto.Certificate({ + subject: { CN: role }, + publicKey: pKey, + privateKey: key, + issuer: cert, + days: 365, + }) + + tlsOptions = { + certificate: { + cert: cert, + key: key, + } + } + + return cert.toPEM().toString() + } + + function addPeerCert(cert) { + var peerCert = new crypto.Certificate(cert) + console.info("TLS: ", tlsOptions) + tlsOptions['trusted'] = [peerCert] + } + + function updateNatInfo(ip, port) { + console.info(`Peer NAT Info: ${ip}:${port}`) + destIP = ip + destPort = port + } + + // Punch when: + // 1. Server accept message got 200 OK + // 2. Client receive accept + function punch() { + state = 'punching' + + console.info(`Punching to ${destIP}:${destPort} (${ep})`) + if (role === 'server') { + makeFakeCall(destIP, destPort) + } + directSession() + } + + function makeRespTunnel() { + console.info("Created Resp Tunnel") + state = 'connected' + + return pipeline($ => $ + .acceptHTTPTunnel(() => new Message({ status: 200 })).to($ => $ + .onStart(new Data) + .swap(() => pHub) + .onEnd(() => console.info(`Direct Connection from ${ep} lost`)) + ) + ) + } + + function connectOrFail() { + if (state === 'left') { + // Be quiet when left. + // The hole has been released. + return + } else if (state != 'connected') { + console.info(`Current state ${state}, made the hole failed`) + state = 'fail' + updateHoles() + } + } + + // send a SYN to dest, expect no return. + // this will cheat the firewall to allow inbound connection from peer. + function makeFakeCall(destIP, destPort) { + console.info("Making fake call") + pipeline($ => $ + .onStart(new Data).connect(`${destIP}:${destPort}`, { + bind: bound, + onState: function (conn) { + // Socket Option: REUSEPORT + if (conn.state === 'open') conn.socket.setRawOption(1, 15, new Data([1, 0, 0, 0])) + + // abort this connection. + if (conn.state === 'connecting') { + console.info('Performing early close') + conn.close() + } + } + }) + ).spawn() + } + + // Send something to server from time to time + // So the firewall and NAT rule should be held. + // + // Params: + // - pacemaker: whether called from pacemaker function + // + function heartbeat(pacemaker) { + if (state === 'fail' || state === 'left') return + if (role === 'server') return + + var heart = pipeline($ => $ + .onStart(new Message({ + method: 'GET', + path: '/api/ping' + })) + .pipe(session) + .replaceMessage(res => { + if (res.head.status != 200 && !pacemaker) + app.log("Cardiac Arrest happens, hole: ", ep) + if (pacemaker) return res + return new StreamEnd + }) + ) + + if (pacemaker) + return heart + + // if not called from pacemaker + // the heart should beat automatically :) + heart.spawn() + new Timeout(10).wait().then(() => heartbeat(false)) + } + + // Used on direct connection setup. + // To urge the connect filter try to call the peer + function pacemaker() { + rtt ??= 0.02 + + var timeout = [rtt, rtt, rtt, rtt, rtt, 2 * rtt, 3 * rtt, 5 * rtt, 8 * rtt, 13 * rtt] + var round = 0 + var cont = true + + console.info('Pacemaking......') + pipeline($ => $ + .onStart(new Data) + .repeat(() => { + if(round < 10) + return new Timeout(timeout[round]).wait().then(() => cont) + return false + }) + .to($ => $ + .pipe(() => heartbeat(true)) + .replaceMessage(resp => { + round += 1 + if (resp.head.status == 200) { + cont = false + heartbeat(false) + } + console.info('Pacemaker: ', resp) + return new StreamEnd + }) + ) + ).spawn() + } + + function leave(remote) { + if (role === 'server') { + pipy.listen(bound, 'tcp', null) + } + + if ($connection) { + $connection?.close() + } + $connection = null + if (state != 'fail') state = 'left' + if (!remote) { + request(new Message({ + method: 'GET', + path: '/api/punch/leave' + })) + } else app.log("Hole closed by peer ", ep) + } + + return { + role: () => role, + state: () => state, + ready: () => state === 'connected', + requestPunch, + acceptPunch, + updateNatInfo, + addPeerCert, + punch, + makeRespTunnel, + directSession, + leave, + } + } // End of Hole + + var holes = new Map + var fails = {} + var svc = null + + function updateHoles() { + holes.forEach((key, hole) => { + fails[key] ??= 0 + if (hole.state() === 'fail' || hole.state() === 'left') { + hole.leave() + holes.delete(key) + fails[key] += 1 + } + }) + console.info(`Holes after updating: `, holes) + } + + function createInboundHole(ep) { + updateHoles() + if (findHole(ep)) return + if (fails[ep] && fails[ep] >= 3) { + console.info(`Won't create hole to ${ep}, too many fails!`) + return + } + console.info(`Creating Inbound Hole to ${ep}`) + try { + var hole = Hole(ep) + hole.requestPunch() + holes.set(ep, hole) + } catch (err) { + updateHoles() + app.log('Failed to create Inbound Hole, Error: ', err) + } + + return hole + } + + function createOutboundHole(ep, natIp, natPort) { + updateHoles() + if (findHole(ep)) return + console.info(`Creating Outbound Hole to ${ep}`) + try { + var hole = Hole(ep) + hole.acceptPunch() + holes.set(ep, hole) + } catch (err) { + updateHoles() + app.log('Failed to create Outbound Hole, Error: ', err) + } + + return hole + } + + function updateHoleInfo(ep, natIp, natPort, cert) { + var hole = findHole(ep) + if (!hole) throw `No hole to update, ep ${ep}` + + hole.updateNatInfo(natIp, natPort) + hole.addPeerCert(cert) + } + + function deleteHole(ep, remote) { + var sel = findHole(ep) + if (!sel) return + sel.leave(remote) + updateHoles() + } + + function findHole(ep) { + return holes.get(ep) + } + + function setService(srvPeer) { + svc = srvPeer + } + + function randomPort() { + return Number.parseInt(Math.random() * (65535 - 1024)) + 1024 + } + + return { + getHoles: () => holes, + createInboundHole, + createOutboundHole, + updateHoleInfo, + deleteHole, + findHole, + setService, + randomPort, + } +} diff --git a/neptune/src/lib.rs b/neptune/src/lib.rs index b41193d2d..3eda46ce7 100644 --- a/neptune/src/lib.rs +++ b/neptune/src/lib.rs @@ -18,6 +18,7 @@ pub fn start_agent(database: &str, listen_port: u16) { let args = [ CString::new("ztm-pipy").unwrap(), CString::new("repo://ztm/agent").unwrap(), + CString::new("--reuse-port").unwrap(), CString::new("--args").unwrap(), // CString::new(format!("--database={}", database)).unwrap(), CString::new(format!("--data={}", database)).unwrap(),