mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
feat: init terminal-service
feat: make interactive ws terminal
This commit is contained in:
1 parent
3b77d91796
commit
58b6448c3d
8 files changed
+391
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
config
|
config
|
||||||
.quest
|
.quest
|
||||||
|
tmp
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
Vendored
+3
@@ -4,5 +4,8 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"yaml.schemas": {
|
"yaml.schemas": {
|
||||||
"packages/game/quest.json": "quests/**/game.yml"
|
"packages/game/quest.json": "quests/**/game.yml"
|
||||||
|
},
|
||||||
|
"[go]": {
|
||||||
|
"editor.defaultFormatter": "golang.go"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
app_port = 0
|
||||||
|
enabled = false
|
||||||
|
proxy_port = 0
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Terminal Service
|
||||||
|
|
||||||
|
A lightweight terminal service for Linux Odyssey player containers, replacing SSH with a more efficient WebSocket-based solution.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- WebSocket-based terminal sessions
|
||||||
|
- PTY (pseudo-terminal) management
|
||||||
|
- Quest command execution
|
||||||
|
- File status and content inspection
|
||||||
|
- Direct integration with Traefik for frontend access
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The service is designed to be lightweight and efficient, using:
|
||||||
|
|
||||||
|
- gorilla/websocket for WebSocket connections
|
||||||
|
- creack/pty for terminal management
|
||||||
|
- Traefik for reverse proxying
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24 or later
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Traefik (for production deployment)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod init github.com/linux-odyssey/linux-odyssey/terminal-service
|
||||||
|
go get github.com/gorilla/websocket
|
||||||
|
go get github.com/creack/pty
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build
|
||||||
|
./terminal-service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration details will be added as the service develops.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The service implements security measures to ensure safe container access and command execution.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Same as the main Linux Odyssey project.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module github.com/linux-odyssey/linux-odyssey/terminal-service
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
|
golang.org/x/term v0.31.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||||
|
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>WebSocket Terminal</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
}
|
||||||
|
#terminal {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="terminal"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
theme: {
|
||||||
|
background: '#1e1e1e',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon.FitAddon()
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
term.open(document.getElementById('terminal'))
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
// Create WebSocket connection
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`)
|
||||||
|
|
||||||
|
// Handle WebSocket connection
|
||||||
|
ws.onopen = () => {
|
||||||
|
term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
term.write('\r\n\x1B[1;31mDisconnected from terminal\x1B[0m\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
term.write(
|
||||||
|
'\r\n\x1B[1;31mWebSocket error: ' + error.message + '\x1B[0m\r\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle terminal input
|
||||||
|
term.onData((data) => {
|
||||||
|
ws.send(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle WebSocket messages
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
// Convert the data to a string if it's not already
|
||||||
|
const data =
|
||||||
|
typeof event.data === 'string'
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data)
|
||||||
|
term.write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
fitAddon.fit()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
addr = flag.String("addr", ":8080", "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) {
|
||||||
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("upgrade:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
// Create new terminal
|
||||||
|
term, err := newTerminal()
|
||||||
|
if err != nil {
|
||||||
|
internalError(ws, "start:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer term.Close()
|
||||||
|
|
||||||
|
stdoutDone := make(chan struct{})
|
||||||
|
go pumpStdout(ws, term.pty, stdoutDone)
|
||||||
|
go ping(ws, stdoutDone)
|
||||||
|
|
||||||
|
pumpStdin(ws, term.pty)
|
||||||
|
|
||||||
|
// Wait for stdout to finish
|
||||||
|
<-stdoutDone
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, "index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
log.SetFlags(0)
|
||||||
|
http.HandleFunc("/", serveHome)
|
||||||
|
http.HandleFunc("/ws", serveWs)
|
||||||
|
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user