// 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 // 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/http" "path" "strings" "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") ) func main() { flag.Parse() 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, "/") switch first { case "", "a", "p", "m", "t", "sk", "help", "tr", "u", "x", "tmp": writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) 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.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, POST, DELETE, PATCH, OPTIONS" var methodDescriptions = map[string]string{ "GET": "read file content or list directory entries", "HEAD": "stat file (metadata in response headers)", "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 } 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") { if line != "" { 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 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 } 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(serializeKV(m)) 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) }