diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ceebf20 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module ollie-httpgw + +go 1.25.6 + +require 9fans.net/go v0.0.7 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d81a876 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +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= +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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7c15f14 --- /dev/null +++ b/main.go @@ -0,0 +1,226 @@ +// 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 ( + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "path" + "strings" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" +) + +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("/", 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 handler(w http.ResponseWriter, r *http.Request) { + fsys, err := dial() + if err != nil { + http.Error(w, "9p connect: "+err.Error(), http.StatusBadGateway) + return + } + defer fsys.Close() + + p := strings.TrimPrefix(r.URL.Path, "/") + + switch r.Method { + case http.MethodGet: + handleRead(w, fsys, p) + case http.MethodHead: + handleStat(w, fsys, p) + case http.MethodPut: + handleWrite(w, r, fsys, p) + case http.MethodPost: + handleCreate(w, r, fsys, p) + case http.MethodDelete: + handleRemove(w, fsys, p) + case http.MethodPatch: + handleWstat(w, r, fsys, p) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) { + d, err := fsys.Stat(p) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + setStatHeaders(w, d) + + if d.Mode&plan9.DMDIR != 0 { + fid, err := fsys.Open(p, plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + dirs, err := fid.Dirreadall() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + entries := make([]dirEntry, len(dirs)) + for i, de := range dirs { + entries[i] = dirToEntry(de) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) + return + } + + fid, err := fsys.Open(p, plan9.OREAD) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer fid.Close() + w.Header().Set("Content-Type", "application/octet-stream") + io.Copy(w, fid) +} + +func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) { + d, err := fsys.Stat(p) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + setStatHeaders(w, d) +} + +func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fid, err := fsys.Open(p, plan9.OWRITE) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + n, err := fid.Write(body) + fid.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"bytes_written": n}) +} + +func handleCreate(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { + dir := path.Dir(p) + name := path.Base(p) + if dir == "." { + dir = "" + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fid, err := fsys.Create(path.Join(dir, name), plan9.OWRITE, 0644) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(body) > 0 { + fid.Write(body) + } + fid.Close() + w.WriteHeader(http.StatusCreated) +} + +func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) { + if err := fsys.Remove(p); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + 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 { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + d := &plan9.Dir{Name: req.Name} + if err := fsys.Wstat(p, d); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/mkfile b/mkfile new file mode 100644 index 0000000..96f5b27 --- /dev/null +++ b/mkfile @@ -0,0 +1,11 @@ +INSTALL_PATH=$HOME/bin + +all:V: install + +build:V: + go build -o $INSTALL_PATH/ollie-httpgw . + +install:V: build + +clean:V: + rm -f $INSTALL_PATH/ollie-httpgw