lightweight go reverse proxy for ollama with bearer token authentication
go proxy ollama

init

+338
+9
.env.example
··· 1 + # Authentication token (required) 2 + # Generate a strong random token for production use 3 + AUTH_TOKEN=your-secret-token-here 4 + 5 + # Ollama server URL (optional, defaults to http://localhost:11434) 6 + OLLAMA_URL=http://localhost:11434 7 + 8 + # Proxy server port (optional, defaults to 8080) 9 + PORT=8080
+30
.gitignore
··· 1 + # Binaries for programs and plugins 2 + *.exe 3 + *.exe~ 4 + *.dll 5 + *.so 6 + *.dylib 7 + 8 + # Test binary, built with `go test -c` 9 + *.test 10 + 11 + # Output of the go build 12 + ollama-proxy 13 + 14 + # Go workspace file 15 + go.work 16 + 17 + # Environment variables 18 + .env 19 + .env.local 20 + 21 + # IDE 22 + .vscode/ 23 + .idea/ 24 + *.swp 25 + *.swo 26 + *~ 27 + 28 + # OS 29 + .DS_Store 30 + Thumbs.db
+21
Makefile
··· 1 + .PHONY: build test lint run clean help 2 + 3 + build: ## Build the binary 4 + go build -o ollama-proxy 5 + 6 + test: ## Run tests 7 + go test -v ./... 8 + 9 + lint: ## Run linter 10 + golangci-lint run 11 + 12 + run: ## Run the application (requires AUTH_TOKEN env var) 13 + go run . 14 + 15 + clean: ## Remove build artifacts 16 + rm -f ollama-proxy 17 + 18 + help: ## Show this help message 19 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' 20 + 21 + .DEFAULT_GOAL := help
+38
README.md
··· 1 + # Ollama Proxy 2 + 3 + A lightweight Go reverse proxy for Ollama with Bearer token authentication. 4 + 5 + ## Features 6 + 7 + - Simple reverse proxy to Ollama API 8 + - Bearer token authentication 9 + - Easy configuration via environment variables 10 + 11 + ## Installation 12 + 13 + ```bash 14 + go install github.com/ollama/ollama-proxy@latest 15 + ``` 16 + 17 + ```bash 18 + go build -o ollama-proxy 19 + ``` 20 + 21 + ## Configuration 22 + 23 + The proxy is configured via environment variables: 24 + 25 + - `AUTH_TOKEN` (required): Bearer token for API authentication 26 + - `OLLAMA_URL` (optional): Ollama server URL (default: `http://localhost:11434`) 27 + - `PORT` (optional): Proxy server port (default: `8080`) 28 + 29 + ## Usage 30 + 31 + ### Start the proxy 32 + 33 + ```bash 34 + export AUTH_TOKEN="your-secret-token" 35 + export OLLAMA_URL="http://localhost:11434" 36 + export PORT="8080" 37 + ./ollama-proxy 38 + ```
+10
go.mod
··· 1 + module github.com/julienrbrt/ollama-proxy 2 + 3 + go 1.25 4 + 5 + require ( 6 + github.com/go-chi/chi/v5 v5.1.0 7 + gotest.tools/v3 v3.5.1 8 + ) 9 + 10 + require github.com/google/go-cmp v0.5.9 // indirect
+6
go.sum
··· 1 + github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 2 + github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 3 + github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 + github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 + gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 6 + gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+52
main.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "os" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/go-chi/chi/v5/middleware" 10 + ) 11 + 12 + const ( 13 + portEnv = "PORT" 14 + authEnv = "AUTH_TOKEN" 15 + ollamaEnv = "OLLAMA_URL" 16 + ) 17 + 18 + func main() { 19 + port := os.Getenv(portEnv) 20 + if port == "" { 21 + port = "8080" 22 + } 23 + 24 + ollamaURL := os.Getenv(ollamaEnv) 25 + if ollamaURL == "" { 26 + ollamaURL = "http://localhost:11434" 27 + } 28 + 29 + token := os.Getenv(authEnv) 30 + if token == "" { 31 + log.Fatal("AUTH_TOKEN environment variable is required") 32 + } 33 + 34 + proxy, err := newProxy(ollamaURL) 35 + if err != nil { 36 + log.Fatalf("failed to create proxy: %v", err) 37 + } 38 + 39 + r := chi.NewRouter() 40 + r.Use(middleware.Logger) 41 + r.Use(middleware.Recoverer) 42 + r.Use(authMiddleware(token)) 43 + 44 + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 45 + proxy.ServeHTTP(w, r) 46 + }) 47 + 48 + log.Printf("Starting proxy server on port %s, forwarding to %s", port, ollamaURL) 49 + if err := http.ListenAndServe(":"+port, r); err != nil { 50 + log.Fatalf("server failed: %v", err) 51 + } 52 + }
+54
proxy.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/http/httputil" 7 + "net/url" 8 + "strings" 9 + ) 10 + 11 + // authMiddleware validates Bearer token authorization 12 + func authMiddleware(token string) func(http.Handler) http.Handler { 13 + return func(next http.Handler) http.Handler { 14 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 + authHeader := r.Header.Get("Authorization") 16 + if authHeader == "" { 17 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 18 + return 19 + } 20 + 21 + parts := strings.SplitN(authHeader, " ", 2) 22 + if len(parts) != 2 || parts[0] != "Bearer" { 23 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 24 + return 25 + } 26 + 27 + if parts[1] == "" || parts[1] != token { 28 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 29 + return 30 + } 31 + 32 + next.ServeHTTP(w, r) 33 + }) 34 + } 35 + } 36 + 37 + // newProxy creates a reverse proxy for the target URL 38 + func newProxy(targetURL string) (*httputil.ReverseProxy, error) { 39 + if targetURL == "" { 40 + return nil, fmt.Errorf("target URL cannot be empty") 41 + } 42 + 43 + target, err := url.Parse(targetURL) 44 + if err != nil { 45 + return nil, fmt.Errorf("invalid target URL: %w", err) 46 + } 47 + 48 + if target.Scheme == "" || target.Host == "" { 49 + return nil, fmt.Errorf("invalid target URL: missing scheme or host") 50 + } 51 + 52 + proxy := httputil.NewSingleHostReverseProxy(target) 53 + return proxy, nil 54 + }
+118
proxy_test.go
··· 1 + package main 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "testing" 7 + 8 + "gotest.tools/v3/assert" 9 + ) 10 + 11 + func TestAuthMiddleware(t *testing.T) { 12 + tests := []struct { 13 + name string 14 + token string 15 + authHeader string 16 + expectedStatus int 17 + expectedBody string 18 + }{ 19 + { 20 + name: "valid token", 21 + token: "test-token-123", 22 + authHeader: "Bearer test-token-123", 23 + expectedStatus: http.StatusOK, 24 + expectedBody: "OK", 25 + }, 26 + { 27 + name: "invalid token", 28 + token: "test-token-123", 29 + authHeader: "Bearer wrong-token", 30 + expectedStatus: http.StatusUnauthorized, 31 + expectedBody: "Unauthorized\n", 32 + }, 33 + { 34 + name: "missing bearer prefix", 35 + token: "test-token-123", 36 + authHeader: "test-token-123", 37 + expectedStatus: http.StatusUnauthorized, 38 + expectedBody: "Unauthorized\n", 39 + }, 40 + { 41 + name: "missing authorization header", 42 + token: "test-token-123", 43 + authHeader: "", 44 + expectedStatus: http.StatusUnauthorized, 45 + expectedBody: "Unauthorized\n", 46 + }, 47 + { 48 + name: "empty token value", 49 + token: "test-token-123", 50 + authHeader: "Bearer ", 51 + expectedStatus: http.StatusUnauthorized, 52 + expectedBody: "Unauthorized\n", 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 + w.WriteHeader(http.StatusOK) 60 + _, _ = w.Write([]byte("OK")) 61 + }) 62 + 63 + middleware := authMiddleware(tt.token) 64 + req := httptest.NewRequest(http.MethodGet, "/", nil) 65 + if tt.authHeader != "" { 66 + req.Header.Set("Authorization", tt.authHeader) 67 + } 68 + 69 + rr := httptest.NewRecorder() 70 + middleware(handler).ServeHTTP(rr, req) 71 + 72 + assert.Equal(t, tt.expectedStatus, rr.Code) 73 + assert.Equal(t, tt.expectedBody, rr.Body.String()) 74 + }) 75 + } 76 + } 77 + 78 + func TestNewProxy(t *testing.T) { 79 + tests := []struct { 80 + name string 81 + targetURL string 82 + wantErr bool 83 + }{ 84 + { 85 + name: "valid http URL", 86 + targetURL: "http://localhost:11434", 87 + wantErr: false, 88 + }, 89 + { 90 + name: "valid https URL", 91 + targetURL: "https://example.com", 92 + wantErr: false, 93 + }, 94 + { 95 + name: "invalid URL", 96 + targetURL: "://invalid", 97 + wantErr: true, 98 + }, 99 + { 100 + name: "empty URL", 101 + targetURL: "", 102 + wantErr: true, 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + proxy, err := newProxy(tt.targetURL) 109 + if tt.wantErr { 110 + assert.Assert(t, err != nil) 111 + assert.Assert(t, proxy == nil) 112 + } else { 113 + assert.NilError(t, err) 114 + assert.Assert(t, proxy != nil) 115 + } 116 + }) 117 + } 118 + }