feat: connect terminal service to backend

feat: put terminal service into container

refactor: separate main into terminal and websocket

feat: create zsh using commander in home

feat: pass backendUrl to container

feat: pass token to ws server to cmd-hook

feat: gen terminal access token for frontend

feat: success to connect through traefik

fix: handle invalid jwt

feat: connect to session container via traefik
This commit is contained in:
Justin Lin committed 2025-05-05 19:53:33 +08:00
1 parent 0b08a9a5d9
commit 5e81db2759
20 files changed
+278 -206

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ const tasks = computed(() => {
<div class="h-full w-full max-h-full"> <div class="h-full w-full max-h-full">
<p id="topic" class="text-text font-black" v-if="sessionStore.quest"> <p id="topic" class="text-text font-black" v-if="sessionStore.quest">
{{ sessionStore.quest.title }} {{ sessionStore.quest.title }}
{{ sessionStore.session?.token }}
</p> </p>
<br /> <br />
<div> <div>
+6 -6
View File
@@ -94,12 +94,12 @@ const useSession = defineStore('session', {
}, },
setup() { setup() {
if (hasSetup) return if (hasSetup) return
socket.on('terminal', (data: string) => { // socket.on('terminal', (data: string) => {
term.write(data) // term.write(data)
}) // })
term.onData((data: string) => { // term.onData((data: string) => {
socket.emit('terminal', data) // socket.emit('terminal', data)
}) // })
socket.on('graph', (event: FileGraphUpdateEvent) => { socket.on('graph', (event: FileGraphUpdateEvent) => {
if (!this.session) return if (!this.session) return
this.session.graph.handleEvent(event) this.session.graph.handleEvent(event)
+1
View File
@@ -21,4 +21,5 @@ export interface Session {
pwd: string pwd: string
tasks: ITask[] tasks: ITask[]
responses: IResponse[] responses: IResponse[]
token: string
} }
+24
View File
@@ -1,5 +1,22 @@
version: '3' version: '3'
services: services:
traefik:
image: traefik:v3.3
networks:
- players-dev
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:80'
- '--entrypoints.websecure.address=:443'
ports:
- '80:80'
- '443:443'
- '8080:8080'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
db: db:
image: mongo:6 image: mongo:6
restart: always restart: always
@@ -19,6 +36,13 @@ services:
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)'
- 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base'
- 'traefik.http.routers.base.middlewares=base-stripprefix'
- 'traefik.http.routers.base.entrypoints=web'
- 'traefik.http.services.base.loadbalancer.server.port=9090'
networks: networks:
internal: internal:
+1 -1
View File
@@ -29,7 +29,7 @@ precmd() {
export CMD_ERROR_FILE=/tmp/cmd.error export CMD_ERROR_FILE=/tmp/cmd.error
# Now run your Node.js script with the result as an argument # Now run your Node.js script with the result as an argument
/usr/local/bin/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null /usr/local/lib/cmd-hook/cmd-hook; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
# Reset variables # Reset variables
unset CMD_START_TIME unset CMD_START_TIME
+13 -13
View File
@@ -1,15 +1,18 @@
FROM golang:1.24-alpine as builder FROM golang:1.24-alpine as cmd-hook-builder
WORKDIR /app WORKDIR /app
COPY ./services/cmd-hook/go.mod ./ COPY ./services/cmd-hook/go.mod ./
RUN go mod download RUN go mod download
COPY ./services/cmd-hook ./ COPY ./services/cmd-hook ./
RUN go build -o cmd-hook RUN go build -o cmd-hook
FROM golang:1.24-alpine as terminal-builder
WORKDIR /app
COPY ./services/terminal-service/go.mod ./
COPY ./services/terminal-service/go.sum ./
RUN go mod download
COPY ./services/terminal-service ./
RUN go build -o terminal-service
FROM debian:bookworm-slim as main FROM debian:bookworm-slim as main
LABEL maintainer="wancat@wancat.cc" LABEL maintainer="wancat@wancat.cc"
@@ -18,16 +21,13 @@ RUN apt-get update && apt-get install -y \
vim \ vim \
zsh \ zsh \
curl \ curl \
openssh-server sudo
COPY --from=builder /app/cmd-hook /usr/local/bin/cmd-hook
COPY ./quests/.includes/zshrc /etc/zsh/zshrc COPY ./quests/.includes/zshrc /etc/zsh/zshrc
RUN rm -f /etc/motd /etc/issue /etc/issue.net /etc/update-motd.d/*
RUN rm -rf /etc/skel && mkdir -p /etc/skel RUN rm -rf /etc/skel && mkdir -p /etc/skel
RUN mkdir -p /etc/ssh/authorized_keys && chmod 755 /etc/ssh/authorized_keys
COPY ./quests/.includes/sshd_config /etc/ssh/sshd_config
COPY ./quests/entrypoint.sh /entrypoint.sh COPY ./quests/entrypoint.sh /entrypoint.sh
COPY --from=cmd-hook-builder /app/cmd-hook /usr/local/lib/cmd-hook/cmd-hook
COPY --from=terminal-builder /app/terminal-service /usr/local/lib/terminal-service/terminal-service
EXPOSE 22 EXPOSE 9090
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
+6 -9
View File
@@ -3,19 +3,16 @@
USERNAME=commander USERNAME=commander
echo "Initializing container for $USERNAME" echo "Initializing container for $USERNAME"
echo "BACKEND_URL: $BACKEND_URL"
if ! id -u ${USERNAME} >/dev/null 2>&1; then if ! id -u ${USERNAME} >/dev/null 2>&1; then
echo "Adding user $USERNAME" echo "Adding user $USERNAME"
adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \ adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} && \
find / -name ".gitkeep" -type f -exec rm -f {} + find / -name ".gitkeep" -type f -exec rm -f {} +
echo "Adding public key for $USERNAME"
echo "Public key: $PUB_KEY"
PUB_KEY_PATH=/etc/ssh/authorized_keys/${USERNAME}
echo $PUB_KEY > ${PUB_KEY_PATH}
chmod 644 ${PUB_KEY_PATH}
fi fi
mkdir -p /run/sshd # Preserve environment variables
echo "Starting SSH server" export BACKEND_URL
/usr/sbin/sshd -D -e
echo "Starting terminal service"
su - ${USERNAME} -w BACKEND_URL -c "/usr/local/lib/terminal-service/terminal-service"
@@ -7,11 +7,29 @@ import { asyncHandler } from '../../middleware/error'
import { questManager } from '../../models/quest' import { questManager } from '../../models/quest'
import { CLIFileExistenceChecker } from '../../containers/cli' import { CLIFileExistenceChecker } from '../../containers/cli'
import config from '../../config' import config from '../../config'
import { verifyJWT } from '../../utils/auth'
export const newCommand = asyncHandler(async (req: Request, res: Response) => { export const newCommand = asyncHandler(async (req: Request, res: Response) => {
if (!config.isProduction) { if (!config.isProduction) {
console.log('newCommand', JSON.stringify(req.body, null, 2)) console.log('newCommand', JSON.stringify(req.body, null, 2))
} }
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
res.status(401).json({ message: 'Unauthorized' })
return
}
let sessionId: string
try {
const decoded = await verifyJWT(token)
sessionId = decoded.sessionId
if (!sessionId) {
res.status(401).json({ message: 'Unauthorized' })
return
}
} catch (error) {
res.status(401).json({ message: 'Unauthorized' })
return
}
const body = commandSchema.safeParse(req.body) const body = commandSchema.safeParse(req.body)
if (!body.success) { if (!body.success) {
res.status(400).json({ message: 'Invalid command', errors: body.error }) res.status(400).json({ message: 'Invalid command', errors: body.error })
@@ -19,7 +37,6 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
} }
const command = body.data const command = body.data
const { sessionId } = req.user as any
const session = await Session.findById(sessionId) const session = await Session.findById(sessionId)
if (!session) { if (!session) {
res.status(404).json({ message: 'session not found' }) res.status(404).json({ message: 'session not found' })
+1 -6
View File
@@ -17,12 +17,7 @@ if (!config.isProduction) {
}) })
} }
router.use( router.use('/commands', commands)
'/commands',
passport.authenticate('jwt', { session: false }),
authRequired,
commands
)
router.use('/auth', authRouter) router.use('/auth', authRouter)
router.get('/leaderboard', leaderboardController) router.get('/leaderboard', leaderboardController)
+1 -31
View File
@@ -1,12 +1,9 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import type { Socket } from 'socket.io' import type { Socket } from 'socket.io'
import { Duplex } from 'stream'
import validator from 'validator' import validator from 'validator'
import type { Server as HttpServer } from 'http' import type { Server as HttpServer } from 'http'
import { ISession, Session } from '../../../packages/models' import { ISession, Session } from '../../../packages/models'
import { getAndStartContainer, attachContainer } from '../containers/docker.js'
import SessionMiddleware from '../middleware/session.js' import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
type SocketCallback = (event: string, ...args: any[]) => void type SocketCallback = (event: string, ...args: any[]) => void
@@ -61,41 +58,16 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
return return
} }
const token = await genJWT({
sessionId: session.id,
})
if (!session.containerId) {
next(new Error('Session has no container.'))
return
}
try {
const container = await getAndStartContainer(session.containerId)
const stream = await attachContainer(container, { token })
socket.data.context = { socket.data.context = {
session, session,
stream,
} }
next() next()
} catch (err) {
logger.error(err)
next(new Error('Failed to connect to container.'))
}
} }
function onConnect(socket: Socket) { function onConnect(socket: Socket) {
const { session, stream } = socket.data.context as { const { session } = socket.data.context as {
session: ISession session: ISession
stream: Duplex
} }
stream.on('data', (chunk: Buffer) => {
socket.emit('terminal', chunk.toString())
})
socket.on('terminal', function incoming(message) {
stream.write(message)
})
const socketCallback = (event: string, ...data: any[]) => { const socketCallback = (event: string, ...data: any[]) => {
socket.emit(event, ...data) socket.emit(event, ...data)
@@ -104,9 +76,7 @@ function onConnect(socket: Socket) {
listenToSession(session._id.toString(), socketCallback) listenToSession(session._id.toString(), socketCallback)
socket.on('disconnect', () => { socket.on('disconnect', () => {
stream.write('exit\n')
removeFromSession(session._id.toString(), socketCallback) removeFromSession(session._id.toString(), socketCallback)
stream.destroy()
}) })
} }
+13 -3
View File
@@ -22,9 +22,17 @@ const newContainerOptions = (
ExtraHosts: ['host.docker.internal:host-gateway'], ExtraHosts: ['host.docker.internal:host-gateway'],
}, },
ExposedPorts: { ExposedPorts: {
'22/tcp': {}, '9090/tcp': {},
},
Env: [`BACKEND_URL=${config.backendUrl}`],
Labels: {
'traefik.enable': 'true',
[`traefik.http.routers.terminal-${name}.rule`]: `PathPrefix(\`/terminal/${name}\`)`,
[`traefik.http.middlewares.terminal-${name}-stripprefix.stripprefix.prefixes`]: `/terminal/${name}`,
[`traefik.http.routers.terminal-${name}.middlewares`]: `terminal-${name}-stripprefix`,
[`traefik.http.services.terminal-${name}.loadbalancer.server.port`]: '9090',
[`traefik.http.routers.terminal-${name}.entrypoints`]: 'web',
}, },
Env: [`PUB_KEY=${config.docker.keypair.publicKey}`],
}) })
export async function createContainer( export async function createContainer(
@@ -48,7 +56,8 @@ export async function createContainer(
if (config.docker.mountCLI) { if (config.docker.mountCLI) {
logger.info('Mounting CLI', questId) logger.info('Mounting CLI', questId)
binds.push( binds.push(
`${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/bin:ro` `${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/lib/cmd-hook:ro`,
`${config.docker.hostProjectRoot}/services/terminal-service/bin:/usr/local/lib/terminal-service:ro`
) )
} }
const option = newContainerOptions(name, imageId, { binds }) const option = newContainerOptions(name, imageId, { binds })
@@ -98,6 +107,7 @@ export async function attachContainer(
const containerIp = (await container.inspect()).NetworkSettings.Networks[ const containerIp = (await container.inspect()).NetworkSettings.Networks[
config.docker.network config.docker.network
].IPAddress ].IPAddress
console.log('containerIp', containerIp)
let error: Error | null = null let error: Error | null = null
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
+12 -6
View File
@@ -1,7 +1,11 @@
import { HydratedDocument } from 'mongoose' import { HydratedDocument } from 'mongoose'
import { Session, UserProfile } from '../../../packages/models' import { Session, UserProfile } from '../../../packages/models'
import type { ISession, IUser } from '../../../packages/models' import type { ISession, IUser } from '../../../packages/models'
import { createContainer, deleteContainer } from '../containers/docker.js' import {
createContainer,
deleteContainer,
getAndStartContainer,
} from '../containers/docker.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
import { questManager } from './quest.js' import { questManager } from './quest.js'
@@ -48,11 +52,10 @@ export async function createNewSession(
throw new Error(`UserProfile ${userId} not found`) throw new Error(`UserProfile ${userId} not found`)
} }
const container = await createContainer( const containerName = `quest-${quest.id}-${userProfile.user.username}-${Date.now()}`
`quest-${quest.id}-${userProfile.user.username}-${Date.now()}`,
quest.id, const container = await createContainer(containerName, quest.id, quest.image)
quest.image await getAndStartContainer(container.id)
)
const newSession = new Session({ const newSession = new Session({
user: userId, user: userId,
@@ -60,6 +63,9 @@ export async function createNewSession(
containerId: container.id, containerId: container.id,
}) })
console.log('container', container.id)
console.log('containerName', `http://localhost/terminal/${containerName}`)
const progress = userProfile.progress.get(quest.id) const progress = userProfile.progress.get(quest.id)
if (!progress) { if (!progress) {
userProfile.progress.set(quest.id, { userProfile.progress.set(quest.id, {
+7 -1
View File
@@ -6,10 +6,11 @@ import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js'
import config from '../config.js' import config from '../config.js'
import { protectedProcedure, router } from '../trpc.js' import { protectedProcedure, router } from '../trpc.js'
import { questManager } from '../models/quest.js' import { questManager } from '../models/quest.js'
import { genJWT } from '../utils/auth'
export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>> export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>>
function sessionDetail(session: ISession) { async function sessionDetail(session: ISession) {
const quest = questManager.get(session.quest) const quest = questManager.get(session.quest)
if (!quest) { if (!quest) {
throw new TRPCError({ throw new TRPCError({
@@ -22,6 +23,10 @@ function sessionDetail(session: ISession) {
quest, quest,
new VoidFileExistenceChecker() new VoidFileExistenceChecker()
) )
const token = await genJWT({
sessionId: session._id.toString(),
})
return { return {
_id: session._id.toString(), _id: session._id.toString(),
user: session.user.toString(), user: session.user.toString(),
@@ -33,6 +38,7 @@ function sessionDetail(session: ISession) {
responses: gameSession.getResponses(), responses: gameSession.getResponses(),
tasks: gameSession.getTasks(), tasks: gameSession.getTasks(),
graph: gameSession.getGraph(), graph: gameSession.getGraph(),
token,
} }
} }
+6 -2
View File
@@ -48,7 +48,11 @@ func main() {
payload.Params = params payload.Params = params
apiEndpoint := os.Getenv("API_ENDPOINT") backendUrl := os.Getenv("BACKEND_URL")
if backendUrl == "" {
fmt.Fprintf(os.Stderr, "BACKEND_URL is not set\n")
os.Exit(1)
}
token := os.Getenv("TOKEN") token := os.Getenv("TOKEN")
jsonData, err := json.MarshalIndent(payload, "", " ") jsonData, err := json.MarshalIndent(payload, "", " ")
@@ -58,7 +62,7 @@ func main() {
} }
fmt.Println(string(jsonData)) fmt.Println(string(jsonData))
req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", apiEndpoint), bytes.NewBuffer(jsonData)) req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", backendUrl), bytes.NewBuffer(jsonData))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1) os.Exit(1)
+2 -2
View File
@@ -4,8 +4,8 @@ tmp_dir = "tmp"
[build] [build]
args_bin = [] args_bin = []
bin = "./tmp/main" bin = "./bin/terminal-service"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./bin/terminal-service ."
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = [] exclude_file = []
+6
View File
@@ -0,0 +1,6 @@
package main
import _ "embed"
//go:embed index.html
var indexHTML []byte
+5 -1
View File
@@ -39,9 +39,13 @@
term.open(document.getElementById('terminal')) term.open(document.getElementById('terminal'))
fitAddon.fit() fitAddon.fit()
const token = new URLSearchParams(window.location.search).get('token')
// Create WebSocket connection // Create WebSocket connection
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`) const ws = new WebSocket(
`${protocol}//${window.location.host}${window.location.pathname}/ws?token=${token}`
)
// Handle WebSocket connection // Handle WebSocket connection
ws.onopen = () => { ws.onopen = () => {
+20 -124
View File
@@ -2,136 +2,16 @@ package main
import ( import (
"flag" "flag"
"io" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"time"
"github.com/creack/pty"
"github.com/gorilla/websocket"
) )
var ( var (
addr = flag.String("addr", ":8080", "http service address") addr = flag.String("addr", ":9090", "http service address")
) )
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Maximum message size allowed from peer.
maxMessageSize = 8192
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Time to wait before force close on connection.
closeGracePeriod = 10 * time.Second
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow all origins for now - we'll restrict this in production
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type terminal struct {
cmd *exec.Cmd
pty *os.File
}
func newTerminal() (*terminal, error) {
// Start a new shell
cmd := exec.Command("bash")
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, err
}
return &terminal{
cmd: cmd,
pty: ptmx,
}, nil
}
func (t *terminal) Close() error {
if t.pty != nil {
t.pty.Close()
}
if t.cmd != nil {
t.cmd.Process.Kill()
t.cmd.Wait()
}
return nil
}
func pumpStdin(ws *websocket.Conn, w io.Writer) {
defer ws.Close()
ws.SetReadLimit(maxMessageSize)
ws.SetReadDeadline(time.Now().Add(pongWait))
ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := ws.ReadMessage()
if err != nil {
break
}
if _, err := w.Write(message); err != nil {
break
}
}
}
func pumpStdout(ws *websocket.Conn, r io.Reader, done chan struct{}) {
defer func() {
}()
buf := make([]byte, 1024)
for {
n, err := r.Read(buf)
if err != nil {
break
}
ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := ws.WriteMessage(websocket.TextMessage, buf[:n]); err != nil {
ws.Close()
break
}
}
close(done)
ws.SetWriteDeadline(time.Now().Add(writeWait))
ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
time.Sleep(closeGracePeriod)
ws.Close()
}
func ping(ws *websocket.Conn, done chan struct{}) {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil {
log.Println("ping:", err)
}
case <-done:
return
}
}
}
func internalError(ws *websocket.Conn, msg string, err error) {
log.Println(msg, err)
ws.WriteMessage(websocket.TextMessage, []byte("Internal server error."))
}
func serveWs(w http.ResponseWriter, r *http.Request) { func serveWs(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil) ws, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
@@ -140,8 +20,14 @@ func serveWs(w http.ResponseWriter, r *http.Request) {
} }
defer ws.Close() defer ws.Close()
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Create new terminal // Create new terminal
term, err := newTerminal() term, err := newTerminal(token)
if err != nil { if err != nil {
internalError(ws, "start:", err) internalError(ws, "start:", err)
return return
@@ -167,7 +53,15 @@ func serveHome(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
http.ServeFile(w, r, "index.html") w.Header().Set("Content-Type", "text/html")
w.Write(indexHTML)
}
func serveHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
for _, e := range os.Environ() {
fmt.Fprintln(w, e)
}
} }
func main() { func main() {
@@ -175,5 +69,7 @@ func main() {
log.SetFlags(0) log.SetFlags(0)
http.HandleFunc("/", serveHome) http.HandleFunc("/", serveHome)
http.HandleFunc("/ws", serveWs) http.HandleFunc("/ws", serveWs)
http.HandleFunc("/health", serveHealth)
fmt.Println("Starting terminal service on", *addr)
log.Fatal(http.ListenAndServe(*addr, nil)) log.Fatal(http.ListenAndServe(*addr, nil))
} }
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"os"
"os/exec"
"github.com/creack/pty"
)
type terminal struct {
cmd *exec.Cmd
pty *os.File
}
func newTerminal(token string) (*terminal, error) {
// Start a new shell in the home directory
cmd := exec.Command("/usr/bin/zsh")
cmd.Dir = os.Getenv("HOME")
cmd.Env = append(os.Environ(), "ZDOTDIR=/etc/zsh", "TOKEN="+token)
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, err
}
return &terminal{
cmd: cmd,
pty: ptmx,
}, nil
}
func (t *terminal) Close() error {
if t.pty != nil {
t.pty.Close()
}
if t.cmd != nil {
t.cmd.Process.Kill()
t.cmd.Wait()
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"io"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Maximum message size allowed from peer.
maxMessageSize = 8192
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Time to wait before force close on connection.
closeGracePeriod = 10 * time.Second
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow all origins for now - we'll restrict this in production
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func pumpStdin(ws *websocket.Conn, w io.Writer) {
defer ws.Close()
ws.SetReadLimit(maxMessageSize)
ws.SetReadDeadline(time.Now().Add(pongWait))
ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := ws.ReadMessage()
if err != nil {
break
}
if _, err := w.Write(message); err != nil {
break
}
}
}
func pumpStdout(ws *websocket.Conn, r io.Reader, done chan struct{}) {
defer func() {
}()
buf := make([]byte, 1024)
for {
n, err := r.Read(buf)
if err != nil {
break
}
ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := ws.WriteMessage(websocket.TextMessage, buf[:n]); err != nil {
ws.Close()
break
}
}
close(done)
ws.SetWriteDeadline(time.Now().Add(writeWait))
ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
time.Sleep(closeGracePeriod)
ws.Close()
}
func ping(ws *websocket.Conn, done chan struct{}) {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil {
log.Println("ping:", err)
}
case <-done:
return
}
}
}
func internalError(ws *websocket.Conn, msg string, err error) {
log.Println(msg, err)
ws.WriteMessage(websocket.TextMessage, []byte("Internal server error."))
}