503 lines
13 KiB
Go
503 lines
13 KiB
Go
// ollie-httpgw - HTTP gateway for the ollie 9P server.
|
|
//
|
|
// Maps HTTP methods to 9P file operations:
|
|
//
|
|
// GET /path - read file content or list directory entries
|
|
// PUT /path - write request body to file (truncate)
|
|
// POST /path - create file (name = last path element)
|
|
// DELETE /path - remove file
|
|
// HEAD /path - stat file (metadata in headers)
|
|
// PATCH /path - wstat (rename via JSON {"name": "newname"})
|
|
package main
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"9fans.net/go/plan9"
|
|
"9fans.net/go/plan9/client"
|
|
)
|
|
|
|
//go:embed openapi.json
|
|
var openapiSpec []byte
|
|
|
|
var (
|
|
listen = flag.String("listen", ":8080", "HTTP listen address")
|
|
service = flag.String("service", "ollie", "9P service name to connect to")
|
|
network = flag.String("net", "", "network type (unix, tcp); empty = use namespace")
|
|
addr = flag.String("addr", "", "9P server address; empty = use namespace service")
|
|
)
|
|
|
|
const pidFile = "ollie-httpgw.pid"
|
|
|
|
func main() {
|
|
if len(os.Args) >= 2 {
|
|
subcmd := os.Args[1]
|
|
switch subcmd {
|
|
case "start", "fgstart", "stop", "status":
|
|
flag.CommandLine.Parse(os.Args[2:]) //nolint:errcheck
|
|
ns := client.Namespace()
|
|
if ns == "" {
|
|
fmt.Fprintln(os.Stderr, "no namespace")
|
|
os.Exit(1)
|
|
}
|
|
pidPath := filepath.Join(ns, pidFile)
|
|
switch subcmd {
|
|
case "start":
|
|
if gwRunning() {
|
|
fmt.Println("ollie-httpgw already running")
|
|
os.Exit(0)
|
|
}
|
|
daemonize()
|
|
case "fgstart":
|
|
if gwRunning() {
|
|
fmt.Println("ollie-httpgw already running")
|
|
os.Exit(0)
|
|
}
|
|
runGateway(pidPath)
|
|
case "stop":
|
|
stopGateway(pidPath)
|
|
case "status":
|
|
if gwRunning() {
|
|
fmt.Println("ollie-httpgw running")
|
|
} else {
|
|
fmt.Println("ollie-httpgw not running")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
// Legacy: no subcommand — run in foreground directly (no pid file).
|
|
flag.Parse()
|
|
serve()
|
|
}
|
|
|
|
func gwRunning() bool {
|
|
conn, err := net.Dial("tcp", *listen)
|
|
if err == nil {
|
|
conn.Close()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func daemonize() {
|
|
exe, _ := os.Executable()
|
|
args := []string{"fgstart", "-listen", *listen, "-service", *service}
|
|
if *network != "" {
|
|
args = append(args, "-net", *network)
|
|
}
|
|
if *addr != "" {
|
|
args = append(args, "-addr", *addr)
|
|
}
|
|
cmd := exec.Command(exe, args...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid)
|
|
}
|
|
|
|
func stopGateway(pidPath string) {
|
|
data, err := os.ReadFile(pidPath)
|
|
if err != nil {
|
|
fmt.Println("ollie-httpgw not running")
|
|
return
|
|
}
|
|
var pid int
|
|
fmt.Sscanf(string(data), "%d", &pid)
|
|
if pid > 0 {
|
|
syscall.Kill(pid, syscall.SIGTERM) //nolint:errcheck
|
|
}
|
|
os.Remove(pidPath) //nolint:errcheck
|
|
fmt.Println("ollie-httpgw stopped")
|
|
}
|
|
|
|
func runGateway(pidPath string) {
|
|
os.WriteFile(pidPath, fmt.Appendf(nil, "%d", os.Getpid()), 0644) //nolint:errcheck
|
|
serve()
|
|
os.Remove(pidPath) //nolint:errcheck
|
|
}
|
|
|
|
func serve() {
|
|
http.HandleFunc("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(openapiSpec)
|
|
})
|
|
http.HandleFunc("/", handler)
|
|
log.Printf("ollie-httpgw listening on %s", *listen)
|
|
log.Fatal(http.ListenAndServe(*listen, nil))
|
|
}
|
|
|
|
func dial() (*client.Fsys, error) {
|
|
if *addr != "" {
|
|
n := *network
|
|
if n == "" {
|
|
n = "tcp"
|
|
}
|
|
return client.Mount(n, *addr)
|
|
}
|
|
return client.MountService(*service)
|
|
}
|
|
|
|
type dirEntry struct {
|
|
Name string `json:"name"`
|
|
Size uint64 `json:"size"`
|
|
Mode uint32 `json:"mode"`
|
|
Mtime uint32 `json:"mtime"`
|
|
IsDir bool `json:"is_dir"`
|
|
}
|
|
|
|
func dirToEntry(d *plan9.Dir) dirEntry {
|
|
return dirEntry{
|
|
Name: d.Name,
|
|
Size: d.Length,
|
|
Mode: uint32(d.Mode),
|
|
Mtime: d.Mtime,
|
|
IsDir: d.Mode&plan9.DMDIR != 0,
|
|
}
|
|
}
|
|
|
|
func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) {
|
|
w.Header().Set("X-9p-Name", d.Name)
|
|
w.Header().Set("X-9p-Size", fmt.Sprintf("%d", d.Length))
|
|
w.Header().Set("X-9p-Mode", fmt.Sprintf("%o", d.Mode))
|
|
w.Header().Set("X-9p-Mtime", fmt.Sprintf("%d", d.Mtime))
|
|
if d.Mode&plan9.DMDIR != 0 {
|
|
w.Header().Set("X-9p-Type", "dir")
|
|
} else {
|
|
w.Header().Set("X-9p-Type", "file")
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func handler(w http.ResponseWriter, r *http.Request) {
|
|
fsys, err := dial()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()})
|
|
return
|
|
}
|
|
defer fsys.Close()
|
|
|
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
// Internal paths not exposed via HTTP.
|
|
first, _, _ := strings.Cut(p, "/")
|
|
// Paths never exposed via HTTP.
|
|
switch first {
|
|
case "p", "help", "tr", "u", "x", "tmp":
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
// Read-only paths — only GET and HEAD allowed.
|
|
switch first {
|
|
case "", "sk":
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "read-only"})
|
|
return
|
|
}
|
|
}
|
|
base := path.Base(p)
|
|
switch base {
|
|
case "systemprompt", "statewait":
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
case "new":
|
|
if r.Method == http.MethodPost {
|
|
handleWrite(w, r, fsys, p)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
handleRead(w, fsys, p)
|
|
case http.MethodHead:
|
|
handleStat(w, fsys, p)
|
|
case http.MethodPost:
|
|
if base == "chat" {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"})
|
|
return
|
|
}
|
|
handleWrite(w, r, fsys, p)
|
|
case http.MethodPut:
|
|
handlePut(w, r, fsys, p)
|
|
case http.MethodDelete:
|
|
handleRemove(w, fsys, p)
|
|
case http.MethodPatch:
|
|
handleWstat(w, r, fsys, p)
|
|
case http.MethodOptions:
|
|
handleOptions(w, r, p)
|
|
default:
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
}
|
|
}
|
|
|
|
var allowedMethods = "GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS"
|
|
|
|
var methodDescriptions = map[string]string{
|
|
"GET": "read file content or list directory entries",
|
|
"HEAD": "stat file (metadata in response headers)",
|
|
"PUT": "write raw body to file (truncate)",
|
|
"POST": "write to file or trigger action",
|
|
"DELETE": "remove file or session",
|
|
"PATCH": "rename via JSON {\"name\": \"newname\"}",
|
|
"OPTIONS": "describe available operations for this path",
|
|
}
|
|
|
|
func handleOptions(w http.ResponseWriter, _ *http.Request, p string) {
|
|
w.Header().Set("Allow", allowedMethods)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"path": "/" + p,
|
|
"methods": methodDescriptions,
|
|
})
|
|
}
|
|
|
|
func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
|
d, err := fsys.Stat(p)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
setStatHeaders(w, d)
|
|
|
|
if d.Mode&plan9.DMDIR != 0 {
|
|
fid, err := fsys.Open(p, plan9.OREAD)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer fid.Close()
|
|
dirs, err := fid.Dirreadall()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if p == "s" {
|
|
var ids []string
|
|
for _, de := range dirs {
|
|
if de.Mode&plan9.DMDIR != 0 {
|
|
ids = append(ids, de.Name)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, ids)
|
|
return
|
|
}
|
|
entries := make([]dirEntry, len(dirs))
|
|
for i, de := range dirs {
|
|
entries[i] = dirToEntry(de)
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
return
|
|
}
|
|
|
|
fid, err := fsys.Open(p, plan9.OREAD)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer fid.Close()
|
|
content, err := io.ReadAll(fid)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if p == "s/idx" {
|
|
writeJSON(w, http.StatusOK, parseIdx(content))
|
|
return
|
|
}
|
|
if path.Base(p) == "chat" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write(content)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, parseContent(content, path.Base(p)))
|
|
}
|
|
|
|
type idxEntry struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
CWD string `json:"cwd"`
|
|
Backend string `json:"backend"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
func parseIdx(data []byte) []idxEntry {
|
|
var entries []idxEntry
|
|
for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.SplitN(line, "\t", 5)
|
|
if len(fields) != 5 {
|
|
continue
|
|
}
|
|
entries = append(entries, idxEntry{
|
|
ID: fields[0],
|
|
State: fields[1],
|
|
CWD: fields[2],
|
|
Backend: fields[3],
|
|
Model: fields[4],
|
|
})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// parseContent detects the wire format and returns the appropriate Go value:
|
|
// - key=value lines → map[string]string
|
|
// - tab-separated records → [][]string
|
|
// - multiple newline-separated values → []string
|
|
// - single value → map[string]string{name: value}
|
|
func parseContent(data []byte, name string) any {
|
|
s := strings.TrimRight(string(data), "\n")
|
|
if s == "" {
|
|
return map[string]string{}
|
|
}
|
|
first, _, _ := strings.Cut(s, "\n")
|
|
if strings.Contains(first, "=") {
|
|
m := make(map[string]string)
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
k, v, _ := strings.Cut(line, "=")
|
|
m[k] = v
|
|
}
|
|
return m
|
|
}
|
|
if strings.Contains(first, "\t") {
|
|
var rows [][]string
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
if line != "" {
|
|
rows = append(rows, strings.Split(line, "\t"))
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
var lines []string
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
lines = append(lines, line)
|
|
}
|
|
if len(lines) == 1 {
|
|
return map[string]string{name: lines[0]}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// serializeKV serializes a map to newline-separated key=value lines.
|
|
func serializeKV(m map[string]any) []byte {
|
|
var sb strings.Builder
|
|
for k, v := range m {
|
|
fmt.Fprintf(&sb, "%s=%v\n", k, v)
|
|
}
|
|
return []byte(sb.String())
|
|
}
|
|
|
|
func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
|
d, err := fsys.Stat(p)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
setStatHeaders(w, d)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
|
|
func handlePut(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
fid, err := fsys.Open(p, plan9.OWRITE|plan9.OTRUNC)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
n, err := fid.Write(body)
|
|
fid.Close()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n})
|
|
}
|
|
|
|
func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
|
var m map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
// If the body has a single key matching the file name, write just the value.
|
|
// Otherwise serialize as key=value lines (e.g. for "new").
|
|
var payload []byte
|
|
base := path.Base(p)
|
|
if len(m) == 1 {
|
|
if v, ok := m[base]; ok {
|
|
payload = []byte(fmt.Sprintf("%v", v))
|
|
}
|
|
}
|
|
if payload == nil {
|
|
payload = serializeKV(m)
|
|
}
|
|
fid, err := fsys.Open(p, plan9.OWRITE)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
n, err := fid.Write(payload)
|
|
fid.Close()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n})
|
|
}
|
|
|
|
|
|
func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
|
if err := fsys.Remove(p); err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
d := &plan9.Dir{Name: req.Name}
|
|
if err := fsys.Wstat(p, d); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|