feat: init cmd-hook and move terminal-service to services directory

feat: put cmd-hook in container

feat: cmd-hook request type, auto restart setup

feat: handle ls
This commit is contained in:
Justin Lin committed 2025-05-05 19:51:06 +08:00
1 parent 58b6448c3d
commit 0b08a9a5d9
16 files changed
+343 -21

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./bin/cmd-hook"
cmd = "go build -o ./bin/cmd-hook ."
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
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type CommandHandler func(cmd string) (CommandParams, error)
type CommandParams struct {
Discover []FileObject `json:"discover,omitempty"`
Add []FileObject `json:"add,omitempty"`
Remove []FileObject `json:"remove,omitempty"`
Pwd string `json:"pwd,omitempty"`
}
type FileObject struct {
Path string `json:"path"`
Type FileType `json:"type"`
Discovered bool `json:"discovered"`
Empty bool `json:"empty,omitempty"`
}
var commandHandlers = map[string]CommandHandler{
"ls": handleLs,
"cd": handleCd,
"touch": handleTouch,
"mkdir": handleMkdir,
"cp": handleCp,
"rm": handleRm,
"rmdir": handleRmdir,
"mv": handleMv,
}
func handleCommand(cmd string) (CommandParams, error) {
parts := strings.Fields(cmd)
if len(parts) == 0 {
return CommandParams{}, nil
}
handler, exists := commandHandlers[parts[0]]
if !exists {
return CommandParams{}, nil
}
return handler(cmd)
}
func handleCd(cmd string) (CommandParams, error) {
return CommandParams{
Pwd: os.Getenv("PWD"),
}, nil
}
func handleLs(cmd string) (CommandParams, error) {
fmt.Println("handleLs", cmd)
parts := strings.Fields(cmd)[1:]
path := "."
if len(parts) > 1 {
path = parts[1]
}
files := make([]FileObject, 0)
err := discover(&files, path, 0)
if err != nil {
return CommandParams{}, err
}
return CommandParams{
Discover: files,
}, nil
}
func discover(files *[]FileObject, path string, level int) error {
absPath := resolvePath(path)
fileType, err := fileExists(absPath)
if err != nil {
return fmt.Errorf("ls: cannot access '%s': %v", path, err)
}
if fileType == FileTypeFile {
*files = append(*files, FileObject{
Path: absPath,
Type: fileType,
Discovered: level <= 1,
})
return nil
}
entries, err := os.ReadDir(absPath)
if err != nil {
return fmt.Errorf("error reading directory: %v", err)
}
*files = append(*files, FileObject{
Path: absPath,
Type: fileType,
Discovered: level <= 1,
Empty: len(entries) == 0,
})
if level >= 2 {
return nil
}
for _, entry := range entries {
discover(files, filepath.Join(absPath, entry.Name()), level+1)
}
return nil
}
func handleTouch(cmd string) (CommandParams, error) {
// TODO: Implement touch command
return CommandParams{}, nil
}
func handleMkdir(cmd string) (CommandParams, error) {
// TODO: Implement mkdir command
return CommandParams{}, nil
}
func handleCp(cmd string) (CommandParams, error) {
// TODO: Implement cp command
return CommandParams{}, nil
}
func handleRm(cmd string) (CommandParams, error) {
// TODO: Implement rm command
return CommandParams{}, nil
}
func handleRmdir(cmd string) (CommandParams, error) {
// TODO: Implement rmdir command
return CommandParams{}, nil
}
func handleMv(cmd string) (CommandParams, error) {
// TODO: Implement mv command
return CommandParams{}, nil
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/linux-odyssey/linux-odyssey/services/cmd-hook
go 1.24.2
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
type CommandPayload struct {
Command string `json:"command"`
Output string `json:"output"`
Error string `json:"error"`
ExitCode string `json:"exit_code"`
PWD string `json:"pwd"`
Params CommandParams `json:"params"`
}
func readOrNone(file string) string {
data, err := os.ReadFile(file)
if err != nil {
return ""
}
return string(data)
}
func main() {
cmdName := os.Getenv("CMD_NAME")
if cmdName == "" {
os.Exit(0)
}
payload := CommandPayload{
Command: cmdName,
Output: readOrNone(os.Getenv("CMD_OUTPUT_FILE")),
Error: readOrNone(os.Getenv("CMD_ERROR_FILE")),
ExitCode: os.Getenv("CMD_EXIT_CODE"),
PWD: os.Getenv("PWD"),
}
params, err := handleCommand(cmdName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error handling command: %v\n", err)
os.Exit(1)
}
payload.Params = params
apiEndpoint := os.Getenv("API_ENDPOINT")
token := os.Getenv("TOKEN")
jsonData, err := json.MarshalIndent(payload, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marshaling payload: %v\n", err)
os.Exit(1)
}
fmt.Println(string(jsonData))
req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/commands", apiEndpoint), bytes.NewBuffer(jsonData))
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Error sending request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "Failed to send command: %s\nResponse Body: %s\n", resp.Status, string(body))
os.Exit(1)
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"path/filepath"
)
type FileType string
const (
FileTypeFile FileType = "file"
FileTypeDir FileType = "directory"
)
func resolvePath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(os.Getenv("PWD"), path)
}
func fileExists(path string) (FileType, error) {
fileInfo, err := os.Stat(path)
if err != nil {
return "", err
}
if fileInfo.IsDir() {
return FileTypeDir, nil
}
return FileTypeFile, nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
+52
View File
@@ -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
+56
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
module github.com/linux-odyssey/linux-odyssey/services/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
)
+8
View File
@@ -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=
+82
View File
@@ -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>
+179
View File
@@ -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))
}