httpgw: remove 9P backend, D-Bus only

Drop ninep.go and all 9P-related flags/imports. The gateway now
exclusively uses D-Bus calls to org.ollie.SessionManager.

The 9P transport was unreliable for HTTP gateway use; D-Bus is
synchronous and well-suited for request/response translation.
This commit is contained in:
Levi Neely 2026-07-18 21:36:16 +02:00
parent 345508d9e1
commit 2d8b404338
4 changed files with 13 additions and 524 deletions

5
go.mod
View File

@ -2,7 +2,4 @@ module ollie-httpgw
go 1.25.6
require (
9fans.net/go v0.0.7
github.com/godbus/dbus/v5 v5.1.0
)
require github.com/godbus/dbus/v5 v5.1.0

34
go.sum
View File

@ -1,36 +1,2 @@
9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM=
9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

62
main.go
View File

@ -1,15 +1,10 @@
// ollie-httpgw - HTTP gateway for ollie session management.
//
// Supports two backend modes:
//
// dbus (default) - translates HTTP to D-Bus calls against org.ollie.SessionManager
// 9p - translates HTTP to 9P file operations
// Translates HTTP requests to D-Bus calls against org.ollie.SessionManager.
//
// Usage:
//
// ollie-httpgw [start|fgstart|stop|status] # dbus mode (default)
// ollie-httpgw 9p [start|fgstart|stop|status] # 9p mode
// ollie-httpgw dbus [start|fgstart|stop|status] # explicit dbus mode
// ollie-httpgw [start|fgstart|stop|status]
package main
import (
@ -24,48 +19,26 @@ import (
"os/exec"
"path/filepath"
"syscall"
"9fans.net/go/plan9/client"
)
//go:embed openapi.json
var openapiSpec []byte
var (
listen = flag.String("listen", ":8011", "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")
)
var listen = flag.String("listen", ":8011", "HTTP listen address")
const pidFile = "ollie-httpgw.pid"
// backendMode tracks which backend we're using.
var backendMode = "dbus"
func main() {
args := os.Args[1:]
// Parse optional mode prefix: "dbus" or "9p"
if len(args) > 0 {
switch args[0] {
case "dbus":
backendMode = "dbus"
args = args[1:]
case "9p":
backendMode = "9p"
args = args[1:]
}
}
if len(args) >= 1 {
subcmd := args[0]
switch subcmd {
case "start", "fgstart", "stop", "status":
flag.CommandLine.Parse(args[1:]) //nolint:errcheck
ns := client.Namespace()
ns := os.Getenv("NAMESPACE")
if ns == "" {
ns = os.TempDir()
ns = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER"))
}
pidPath := filepath.Join(ns, pidFile)
switch subcmd {
@ -74,7 +47,7 @@ func main() {
fmt.Println("ollie-httpgw already running")
os.Exit(0)
}
daemonize(args)
daemonize()
case "fgstart":
if gwRunning() {
fmt.Println("ollie-httpgw already running")
@ -95,7 +68,7 @@ func main() {
}
}
// Legacy: no subcommand — run in foreground directly (no pid file).
// No subcommand — run in foreground directly (no pid file).
flag.CommandLine.Parse(args) //nolint:errcheck
serve()
}
@ -109,22 +82,16 @@ func gwRunning() bool {
return false
}
func daemonize(subcmdArgs []string) {
func daemonize() {
exe, _ := os.Executable()
args := []string{backendMode, "fgstart", "-listen", *listen, "-service", *service}
if *network != "" {
args = append(args, "-net", *network)
}
if *addr != "" {
args = append(args, "-addr", *addr)
}
args := []string{"fgstart", "-listen", *listen}
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, mode %s)\n", cmd.Process.Pid, backendMode)
fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid)
}
func stopGateway(pidPath string) {
@ -154,16 +121,9 @@ func serve() {
w.Write(openapiSpec)
})
switch backendMode {
case "dbus":
http.HandleFunc("/", dbusHandler)
case "9p":
http.HandleFunc("/", ninepHandler)
default:
log.Fatalf("unknown backend mode: %s", backendMode)
}
log.Printf("ollie-httpgw listening on %s (mode: %s)", *listen, backendMode)
log.Printf("ollie-httpgw listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, nil))
}

434
ninep.go
View File

@ -1,434 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"strings"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
)
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 ninepHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/open" {
handleOpen(w, r)
return
}
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 "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:
handleNinepOptions(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 handleNinepOptions(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 == "models" {
writeJSON(w, http.StatusOK, parseModels(content))
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
}
if path.Base(p) == "cfg" {
m := parseContent(content, "cfg")
if mm, ok := m.(map[string]string); ok {
// Inject state from sibling file.
stateFile := path.Dir(p) + "/state"
if sf, err := fsys.Open(stateFile, plan9.OREAD); err == nil {
if sb, err := io.ReadAll(sf); err == nil {
mm["state"] = strings.TrimSpace(string(sb))
}
sf.Close()
}
}
writeJSON(w, http.StatusOK, m)
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"`
}
type modelsEntry struct {
Backend string `json:"backend"`
Models []string `json:"models"`
}
func parseModels(data []byte) []modelsEntry {
groups := make(map[string][]string)
var order []string
for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") {
if line == "" {
continue
}
b, m, ok := strings.Cut(line, "\t")
if !ok {
continue
}
if _, exists := groups[b]; !exists {
order = append(order, b)
}
groups[b] = append(groups[b], m)
}
out := make([]modelsEntry, len(order))
for i, b := range order {
out[i] = modelsEntry{Backend: b, Models: groups[b]}
}
return out
}
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 handleOpen(w http.ResponseWriter, r *http.Request) {
p := r.URL.Query().Get("path")
if p == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing path"})
return
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "xdg-open"
}
if err := exec.Command(editor, p).Start(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"opened": p})
}
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)
}