Skip to content

Commit

Permalink
compose tunnel agent: add docker API
Browse files Browse the repository at this point in the history
- passthrough API to docker on port 3001
- websocket impl for exec and logs
  • Loading branch information
Roy Razon committed Jul 31, 2023
1 parent 6dd0502 commit 7fc723f
Show file tree
Hide file tree
Showing 21 changed files with 692 additions and 85 deletions.
3 changes: 3 additions & 0 deletions packages/compose-tunnel-agent/Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM node:18-alpine as development
WORKDIR /app
CMD [ "yarn", "-s", "dev" ]
3 changes: 1 addition & 2 deletions packages/compose-tunnel-agent/docker-compose.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ services:
preevy_proxy:
build:
context: .
target: development
dockerfile: Dockerfile.dev
volumes:
- ${HOME}/.ssh:/root/.ssh


1 change: 1 addition & 0 deletions packages/compose-tunnel-agent/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ services:

ports:
- 3000
- 3001

# healthcheck:
# test: wget --no-verbose --tries=1 --spider http://localhost:3000/healthz || exit 1
Expand Down
65 changes: 47 additions & 18 deletions packages/compose-tunnel-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { ConnectionCheckResult, requiredEnv, checkConnection, formatPublicKey, p
import createDockerClient from './src/docker'
import createApiServer from './src/api-server'
import { sshClient as createSshClient } from './src/ssh'
import { createDockerProxy } from './src/docker-proxy'

const homeDir = process.env.HOME || '/root'
const dockerSocket = '/var/run/docker.sock'

const readDir = async (dir: string) => {
try {
Expand Down Expand Up @@ -70,11 +72,11 @@ const formatConnectionCheckResult = (

const writeLineToStdout = (s: string) => [s, EOL].forEach(d => process.stdout.write(d))

const main = async () => {
const log = pino({
level: process.env.DEBUG || process.env.DOCKER_PROXY_DEBUG ? 'debug' : 'info',
}, pinoPretty({ destination: pino.destination(process.stderr) }))
const log = pino({
level: process.env.DEBUG || process.env.DOCKER_PROXY_DEBUG ? 'debug' : 'info',
}, pinoPretty({ destination: pino.destination(process.stderr) }))

const main = async () => {
const { connectionConfig, sshUrl } = await sshConnectionConfigFromEnv()

log.debug('ssh config: %j', {
Expand All @@ -92,20 +94,21 @@ const main = async () => {
process.exit(0)
}

const docker = new Docker({ socketPath: '/var/run/docker.sock' })
const docker = new Docker({ socketPath: dockerSocket })
const dockerClient = createDockerClient({ log: log.child({ name: 'docker' }), docker, debounceWait: 500 })

const sshLog = log.child({ name: 'ssh' })
const sshClient = await createSshClient({
connectionConfig,
tunnelNameResolver: tunnelNameResolver({ userDefinedSuffix: process.env.TUNNEL_URL_SUFFIX }),
log: log.child({ name: 'ssh' }),
log: sshLog,
onError: err => {
log.error(err)
process.exit(1)
},
})

log.info('ssh client connected to %j', sshUrl)
sshLog.info('ssh client connected to %j', sshUrl)
let currentTunnels = dockerClient.getRunningServices().then(services => sshClient.updateTunnels(services))

void dockerClient.startListening({
Expand All @@ -115,25 +118,51 @@ const main = async () => {
},
})

const listenAddress = process.env.PORT ?? 3000
if (typeof listenAddress === 'string' && Number.isNaN(Number(listenAddress))) {
await rimraf(listenAddress)
const apiListenAddress = process.env.PORT ?? 3000
if (typeof apiListenAddress === 'string' && Number.isNaN(Number(apiListenAddress))) {
await rimraf(apiListenAddress)
}

const apiServerLog = log.child({ name: 'api' })
const apiServer = createApiServer({
log: log.child({ name: 'api' }),
currentSshState: async () => (
await currentTunnels
),
log: apiServerLog,
currentSshState: async () => (await currentTunnels),
})
.listen(listenAddress, () => {
log.info(`listening on ${inspect(apiServer.address())}`)
.listen(apiListenAddress, () => {
apiServerLog.info(`API server listening on ${inspect(apiServer.address())}`)
})
.on('error', err => {
log.error(err)
apiServerLog.error(err)
process.exit(1)
})
.unref()

const dockerProxyListenAddress = process.env.DOCKER_PROXY_PORT ?? 3001
if (typeof dockerProxyListenAddress === 'string' && Number.isNaN(Number(dockerProxyListenAddress))) {
await rimraf(dockerProxyListenAddress)
}

const dockerProxyLog = log.child({ name: 'docker-proxy' })
const dockerProxyServer = createDockerProxy({
log: dockerProxyLog,
dockerSocket,
docker,
})
.listen(dockerProxyListenAddress, () => {
dockerProxyLog.info(`Docker proxy listening on ${inspect(dockerProxyServer.address())}`)
})
.on('error', err => {
dockerProxyLog.error(err)
process.exit(1)
})
.unref()
}

void main()
void main();

['SIGTERM', 'SIGINT'].forEach(signal => {
process.once(signal, async () => {
log.info(`shutting down on ${signal}`)
process.exit(0)
})
})
5 changes: 5 additions & 0 deletions packages/compose-tunnel-agent/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
15 changes: 11 additions & 4 deletions packages/compose-tunnel-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,38 @@
"pino": "^8.11.0",
"pino-pretty": "^9.4.0",
"rimraf": "^5.0.0",
"ssh2": "^1.12.0"
"ssh2": "^1.12.0",
"ws": "^8.13.0"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
"@types/dockerode": "^3.3.14",
"@types/http-proxy": "^1.17.9",
"@types/lodash": "^4.14.192",
"@types/node": "18",
"@types/node-fetch": "^2.6.3",
"@types/shell-escape": "^0.2.1",
"@types/ssh2": "^1.11.8",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"esbuild": "^0.17.14",
"eslint": "^8.36.0",
"husky": "^8.0.0",
"jest": "^29.4.3",
"lint-staged": "^13.1.2",
"node-fetch": "2.6.9",
"tsx": "^3.12.3",
"typescript": "^5.0.4"
"typescript": "^5.0.4",
"wait-for-expect": "^3.0.2"
},
"scripts": {
"start": "node out/index.js",
"dev": "tsx watch ./index.ts",
"lint": "eslint . --ext .ts,.tsx --cache",
"clean": "rm -rf dist out",
"build": "node --version && node build.mjs",
"build": "yarn tsc --noEmit && node build.mjs",
"prepack": "yarn build",
"prepare": "cd ../.. && husky install",
"bump-to": "yarn version --no-commit-hooks --no-git-tag-version --new-version"
"test": "yarn jest"
}
}
55 changes: 21 additions & 34 deletions packages/compose-tunnel-agent/src/api-server.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,32 @@
import http from 'node:http'
import url from 'node:url'
import { Logger } from '@preevy/common'
import { SshState } from './ssh/index'
import { SshState } from './ssh'
import { NotFoundError, respondAccordingToAccept, respondJson, tryHandler } from './http'

const respond = (res: http.ServerResponse, content: string, type = 'text/plain', status = 200) => {
res.writeHead(status, { 'Content-Type': type })
res.end(content)
}

const respondJson = (
res: http.ServerResponse,
content: unknown,
status = 200,
) => respond(res, JSON.stringify(content), 'application/json', status)

const respondNotFound = (res: http.ServerResponse) => respond(res, 'Not found', 'text/plain', 404)

const createApiServer = ({
log, currentSshState,
}: {
const createApiServer = ({ log, currentSshState }: {
log: Logger
currentSshState: ()=> Promise<SshState>
}) => http.createServer(async (req, res) => {
log.debug('web request URL: %j', req.url)
}) => {
const server = http.createServer(tryHandler({ log }, async (req, res) => {
log.debug('api request: %s %s', req.method || '', req.url || '')

const { pathname: path } = url.parse(req.url || '')

if (!req.url) {
respondNotFound(res)
return
}
const [path] = req.url.split('?')
if (path === '/tunnels') {
respondJson(res, await currentSshState())
return
}

if (path === '/tunnels') {
respondJson(res, await currentSshState())
return
}
if (path === '/healthz') {
respondAccordingToAccept(req, res, 'OK')
return
}

if (path === '/healthz') {
respond(res, 'OK')
return
}
throw new NotFoundError()
}))

respondNotFound(res)
})
return server
}

export default createApiServer
Loading

0 comments on commit 7fc723f

Please sign in to comment.