package server import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "io/fs" "log" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "text/template" "time" "github.com/urfave/cli/v2" "go-micro.dev/v6/ai" _ "go-micro.dev/v6/ai/anthropic" _ "go-micro.dev/v6/ai/atlascloud" _ "go-micro.dev/v6/ai/gemini" _ "go-micro.dev/v6/ai/groq" _ "go-micro.dev/v6/ai/mistral" _ "go-micro.dev/v6/ai/openai" _ "go-micro.dev/v6/ai/together" "go-micro.dev/v6/auth" "go-micro.dev/v6/client" "go-micro.dev/v6/cmd" codecBytes "go-micro.dev/v6/codec/bytes" "go-micro.dev/v6/registry" "go-micro.dev/v6/store" "golang.org/x/crypto/bcrypt" ) // HTML is the embedded filesystem for templates and static files, set by main.go var HTML fs.FS const agentSystemPrompt = "You are an agent that helps users interact with microservices. Use the available tools to fulfill user requests. When you call a tool, explain what you are doing." var ( apiCache struct { sync.Mutex data map[string]any time time.Time } ) type templates struct { api *template.Template service *template.Template form *template.Template home *template.Template logs *template.Template log *template.Template status *template.Template authTokens *template.Template authLogin *template.Template authUsers *template.Template playground *template.Template scopes *template.Template } type TemplateUser struct { ID string } // Account is an alias for auth.Account from the framework. // The gateway stores accounts in the default store under "auth/" keys. // Scopes on accounts are checked against endpoint-scopes by checkEndpointScopes. type Account = auth.Account func parseTemplates() *templates { return &templates{ api: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/api.html")), service: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/service.html")), form: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/form.html")), home: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/home.html")), logs: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/logs.html")), log: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/log.html")), status: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/status.html")), authTokens: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_tokens.html")), authLogin: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")), authUsers: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_users.html")), playground: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/playground.html")), scopes: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/scopes.html")), } } // Helper to extract user info from JWT cookie func getUser(r *http.Request) string { cookie, err := r.Cookie("micro_token") if err != nil || cookie.Value == "" { return "" } // Parse JWT claims (just decode, don't verify) parts := strings.Split(cookie.Value, ".") if len(parts) != 3 { return "" } payload, err := decodeSegment(parts[1]) if err != nil { return "" } var claims map[string]any if err := json.Unmarshal(payload, &claims); err != nil { return "" } if sub, ok := claims["sub"].(string); ok { return sub } if id, ok := claims["id"].(string); ok { return id } return "" } // Helper to decode JWT base64url segment func decodeSegment(seg string) ([]byte, error) { // JWT uses base64url, no padding missing := len(seg) % 4 if missing != 0 { seg += strings.Repeat("=", 4-missing) } return decodeBase64Url(seg) } func decodeBase64Url(s string) ([]byte, error) { return base64.URLEncoding.DecodeString(s) } // Helper: store JWT token func storeJWTToken(storeInst store.Store, token, userID string) { _ = storeInst.Write(&store.Record{Key: "jwt/" + token, Value: []byte(userID)}) } // Helper: check if JWT token is revoked (not present in store) func isTokenRevoked(storeInst store.Store, token string) bool { recs, _ := storeInst.Read("jwt/" + token) return len(recs) == 0 } // Helper: delete all JWT tokens for a user func deleteUserTokens(storeInst store.Store, userID string) { recs, _ := storeInst.Read("jwt/", store.ReadPrefix()) for _, rec := range recs { if string(rec.Value) == userID { _ = storeInst.Delete(rec.Key) } } } // Updated authRequired to accept storeInst as argument func authRequired(storeInst store.Store) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var token string // 1. Check Authorization: Bearer header authz := r.Header.Get("Authorization") if strings.HasPrefix(authz, "Bearer ") { token = strings.TrimPrefix(authz, "Bearer ") token = strings.TrimSpace(token) } // 2. Fallback to micro_token cookie if no header if token == "" { cookie, err := r.Cookie("micro_token") if err == nil && cookie.Value != "" { token = cookie.Value } } if token == "" { if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"missing or invalid token"}`)) return } // For API endpoints, return 401. For UI, redirect to login. if strings.HasPrefix(r.URL.Path, "/api/") { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Unauthorized: missing token")) return } http.Redirect(w, r, "/auth/login", http.StatusFound) return } claims, err := ParseJWT(token) if err != nil { if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"invalid token"}`)) return } if strings.HasPrefix(r.URL.Path, "/api/") { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Unauthorized: invalid token")) return } http.Redirect(w, r, "/auth/login", http.StatusFound) return } if exp, ok := claims["exp"].(float64); ok { if int64(exp) < time.Now().Unix() { if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"token expired"}`)) return } if strings.HasPrefix(r.URL.Path, "/api/") { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Unauthorized: token expired")) return } http.Redirect(w, r, "/auth/login", http.StatusFound) return } } // Check for token revocation if isTokenRevoked(storeInst, token) { if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"token revoked"}`)) return } if strings.HasPrefix(r.URL.Path, "/api/") { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Unauthorized: token revoked")) return } http.Redirect(w, r, "/auth/login", http.StatusFound) return } next(w, r) } } } func wrapAuth(authRequired func(http.HandlerFunc) http.HandlerFunc) func(http.HandlerFunc) http.HandlerFunc { return func(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if strings.HasPrefix(path, "/auth/login") || strings.HasPrefix(path, "/auth/logout") || path == "/styles.css" || path == "/main.js" { h(w, r) return } authRequired(h)(w, r) } } } func getDashboardData() (serviceCount, runningCount, stoppedCount int, statusDot string) { homeDir, err := os.UserHomeDir() if err != nil { return } pidDir := homeDir + "/micro/run" dirEntries, err := os.ReadDir(pidDir) if err != nil { return } for _, entry := range dirEntries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") || strings.HasPrefix(entry.Name(), ".") { continue } pidFile := pidDir + "/" + entry.Name() pidBytes, err := os.ReadFile(pidFile) if err != nil { continue } lines := strings.Split(string(pidBytes), "\n") pid := "-" if len(lines) > 0 && len(lines[0]) > 0 { pid = lines[0] } serviceCount++ if pid != "-" { if _, err := os.FindProcess(parsePid(pid)); err == nil { if processRunning(pid) { runningCount++ } else { stoppedCount++ } } else { stoppedCount++ } } else { stoppedCount++ } } if serviceCount > 0 && runningCount == serviceCount { statusDot = "green" } else if serviceCount > 0 && runningCount > 0 { statusDot = "yellow" } else { statusDot = "red" } return } func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Store, authEnabled bool) { var wrap func(http.HandlerFunc) http.HandlerFunc if authEnabled { authMw := authRequired(storeInst) wrap = wrapAuth(authMw) } else { // No auth in dev mode - pass through handlers unchanged wrap = func(h http.HandlerFunc) http.HandlerFunc { return h } } // renderPage injects AuthEnabled into template data so the sidebar can // conditionally show/hide auth links. renderPage := func(w http.ResponseWriter, tmpl *template.Template, data map[string]any) error { data["AuthEnabled"] = authEnabled return tmpl.Execute(w, data) } // checkEndpointScopes verifies the caller's token scopes against the // required scopes for a service endpoint. Returns true if allowed. // If not allowed, writes a 403 response and returns false. checkEndpointScopes := func(w http.ResponseWriter, r *http.Request, endpointKey string) bool { if !authEnabled { return true } recs, _ := storeInst.Read("endpoint-scopes/" + endpointKey) if len(recs) == 0 { return true // no scopes configured = unrestricted } var requiredScopes []string if err := json.Unmarshal(recs[0].Value, &requiredScopes); err != nil || len(requiredScopes) == 0 { return true } // Extract caller's scopes from JWT callerScopes := []string{} token := "" if authz := r.Header.Get("Authorization"); strings.HasPrefix(authz, "Bearer ") { token = strings.TrimPrefix(authz, "Bearer ") } if token == "" { if cookie, err := r.Cookie("micro_token"); err == nil { token = cookie.Value } } if token != "" { if claims, err := ParseJWT(token); err == nil { if s, ok := claims["scopes"].([]interface{}); ok { for _, v := range s { if str, ok := v.(string); ok { callerScopes = append(callerScopes, str) } } } } } for _, cs := range callerScopes { if cs == "*" { return true } for _, rs := range requiredScopes { if cs == rs { return true } } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{ "error": "insufficient scopes", "required_scopes": strings.Join(requiredScopes, ","), }) return false } // Serve static files with correct Content-Type mux.HandleFunc("/styles.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") f, err := HTML.Open("web/styles.css") if err != nil { w.WriteHeader(http.StatusNotFound) return } defer f.Close() _, _ = io.Copy(w, f) }) mux.HandleFunc("/main.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") f, err := HTML.Open("web/main.js") if err != nil { w.WriteHeader(http.StatusNotFound) return } defer f.Close() _, _ = io.Copy(w, f) }) // MCP API endpoints - list tools and call tools through the web server mux.HandleFunc("/mcp/tools", wrap(func(w http.ResponseWriter, r *http.Request) { services, err := registry.ListServices() if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } var tools []map[string]any for _, svc := range services { fullSvcs, err := registry.GetService(svc.Name) if err != nil || len(fullSvcs) == 0 { continue } for _, ep := range fullSvcs[0].Endpoints { toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name) description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name) if ep.Metadata != nil { if desc, ok := ep.Metadata["description"]; ok && desc != "" { description = desc } } inputSchema := map[string]any{ "type": "object", "properties": map[string]any{}, } if ep.Request != nil && len(ep.Request.Values) > 0 { props := inputSchema["properties"].(map[string]any) for _, field := range ep.Request.Values { props[field.Name] = map[string]any{ "type": mapGoTypeToJSON(field.Type), "description": fmt.Sprintf("%s field", field.Name), } } } tool := map[string]any{ "name": toolName, "description": description, "inputSchema": inputSchema, } // Extract scopes from endpoint metadata or store if ep.Metadata != nil { if scopes, ok := ep.Metadata["scopes"]; ok && scopes != "" { tool["scopes"] = strings.Split(scopes, ",") } } // Override with stored scopes (from UI) if present if recs, _ := storeInst.Read("endpoint-scopes/" + toolName); len(recs) > 0 { var storedScopes []string if err := json.Unmarshal(recs[0].Value, &storedScopes); err == nil && len(storedScopes) > 0 { tool["scopes"] = storedScopes } } tools = append(tools, tool) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"tools": tools}) })) mux.HandleFunc("/mcp/call", wrap(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) return } var req struct { Tool string `json:"tool"` Input map[string]any `json:"input"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } // Parse tool name into service and endpoint parts := strings.SplitN(req.Tool, ".", 2) if len(parts) != 2 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "invalid tool name, expected service.endpoint"}) return } serviceName := parts[0] endpointName := parts[1] // Check endpoint scopes if !checkEndpointScopes(w, r, req.Tool) { return } // Build RPC request using default client inputBytes, err := json.Marshal(req.Input) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } rpcReq := client.DefaultClient.NewRequest(serviceName, endpointName, &codecBytes.Frame{Data: inputBytes}) var rsp codecBytes.Frame if err := client.DefaultClient.Call(r.Context(), rpcReq, &rsp); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("RPC call failed: %v", err)}) return } var traceBytes [16]byte _, _ = rand.Read(traceBytes[:]) traceID := fmt.Sprintf("%x", traceBytes) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "result": json.RawMessage(rsp.Data), "trace_id": traceID, }) })) // Agent settings endpoints mux.HandleFunc("/api/agent/settings", wrap(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method == http.MethodGet { recs, _ := storeInst.Read("agent/settings") if len(recs) == 0 { json.NewEncoder(w).Encode(map[string]string{}) return } var settings map[string]string if err := json.Unmarshal(recs[0].Value, &settings); err != nil { log.Printf("[agent] failed to parse settings: %v", err) json.NewEncoder(w).Encode(map[string]string{}) return } json.NewEncoder(w).Encode(settings) return } if r.Method == http.MethodPost { var settings map[string]string if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } b, _ := json.Marshal(settings) _ = storeInst.Write(&store.Record{Key: "agent/settings", Value: b}) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) return } w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) })) // Agent prompt endpoint — sends user prompt to LLM with tool definitions mux.HandleFunc("/api/agent/prompt", wrap(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) return } var req struct { Prompt string `json:"prompt"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } // Load settings recs, _ := storeInst.Read("agent/settings") var settings map[string]string if len(recs) > 0 { if err := json.Unmarshal(recs[0].Value, &settings); err != nil { log.Printf("[agent] failed to parse settings: %v", err) } } apiKey := "" modelName := "" baseURL := "" provider := "" if settings != nil { if v := settings["api_key"]; v != "" { apiKey = v } if v := settings["model"]; v != "" { modelName = v } if v := settings["base_url"]; v != "" { baseURL = v } if v := settings["provider"]; v != "" { provider = v } } if apiKey == "" { json.NewEncoder(w).Encode(map[string]string{"error": "No API key configured. Go to Agent settings to add one."}) return } // Auto-detect provider if not explicitly set if provider == "" { provider = ai.AutoDetectProvider(baseURL) } // Discover tools from registry services, _ := registry.ListServices() var discoveredTools []ai.Tool // safeNameMap maps LLM-safe names back to original dotted names safeNameMap := map[string]string{} for _, svc := range services { fullSvcs, err := registry.GetService(svc.Name) if err != nil || len(fullSvcs) == 0 { continue } for _, ep := range fullSvcs[0].Endpoints { tName := fmt.Sprintf("%s.%s", svc.Name, ep.Name) safeName := strings.ReplaceAll(tName, ".", "_") safeNameMap[safeName] = tName desc := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name) if ep.Metadata != nil { if d, ok := ep.Metadata["description"]; ok && d != "" { desc = d } } props := map[string]any{} if ep.Request != nil { for _, field := range ep.Request.Values { props[field.Name] = map[string]any{ "type": mapGoTypeToJSON(field.Type), "description": fmt.Sprintf("%s (%s)", field.Name, field.Type), } } } discoveredTools = append(discoveredTools, ai.Tool{ Name: safeName, OriginalName: tName, Description: desc, Properties: props, }) } } // executeToolCall runs an RPC tool call and returns the result. // toolName can be either the original dotted name or the LLM-safe // underscored name; the safe name is resolved first. // Checks endpoint scopes against the caller's token before executing. executeToolCall := func(_ context.Context, call ai.ToolCall) ai.ToolResult { toolName := call.Name input := call.Input if orig, ok := safeNameMap[toolName]; ok { toolName = orig } // Check endpoint scopes if authEnabled { recs, _ := storeInst.Read("endpoint-scopes/" + toolName) if len(recs) > 0 { var requiredScopes []string if err := json.Unmarshal(recs[0].Value, &requiredScopes); err == nil && len(requiredScopes) > 0 { // Get caller's scopes from JWT callerScopes := []string{} token := "" if authz := r.Header.Get("Authorization"); strings.HasPrefix(authz, "Bearer ") { token = strings.TrimPrefix(authz, "Bearer ") } if token == "" { if cookie, err := r.Cookie("micro_token"); err == nil { token = cookie.Value } } if token != "" { if claims, err := ParseJWT(token); err == nil { if s, ok := claims["scopes"].([]interface{}); ok { for _, v := range s { if str, ok := v.(string); ok { callerScopes = append(callerScopes, str) } } } } } allowed := false for _, cs := range callerScopes { if cs == "*" { allowed = true break } for _, rs := range requiredScopes { if cs == rs { allowed = true break } } if allowed { break } } if !allowed { errMsg := fmt.Sprintf(`{"error":"insufficient scopes","required_scopes":"%s"}`, strings.Join(requiredScopes, ",")) return ai.ToolResult{ID: call.ID, Value: map[string]string{"error": "insufficient scopes", "required_scopes": strings.Join(requiredScopes, ",")}, Content: errMsg} } } } } parts := strings.SplitN(toolName, ".", 2) if len(parts) != 2 { errMsg := `{"error":"invalid tool name"}` return ai.ToolResult{ID: call.ID, Value: map[string]string{"error": "invalid tool name"}, Content: errMsg} } inputBytes, _ := json.Marshal(input) rpcReq := client.DefaultClient.NewRequest(parts[0], parts[1], &codecBytes.Frame{Data: inputBytes}) var rsp codecBytes.Frame if err := client.DefaultClient.Call(r.Context(), rpcReq, &rsp); err != nil { errMsg := fmt.Sprintf(`{"error":"%s"}`, err.Error()) return ai.ToolResult{ID: call.ID, Value: map[string]string{"error": err.Error()}, Content: errMsg} } var rpcResult any if err := json.Unmarshal(rsp.Data, &rpcResult); err != nil { rpcResult = string(rsp.Data) } return ai.ToolResult{ID: call.ID, Value: rpcResult, Content: string(rsp.Data)} } // Create model with options var modelOpts []ai.Option modelOpts = append(modelOpts, ai.WithAPIKey(apiKey)) if modelName != "" { modelOpts = append(modelOpts, ai.WithModel(modelName)) } if baseURL != "" { modelOpts = append(modelOpts, ai.WithBaseURL(baseURL)) } modelOpts = append(modelOpts, ai.WithToolHandler(executeToolCall)) m := ai.New(provider, modelOpts...) if m == nil { json.NewEncoder(w).Encode(map[string]string{"error": "Failed to create model provider"}) return } // Build request modelReq := &ai.Request{ Prompt: req.Prompt, SystemPrompt: agentSystemPrompt, Tools: discoveredTools, } // Generate response response, err := m.Generate(r.Context(), modelReq) if err != nil { json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } // Build result result := map[string]any{} if response.Reply != "" { result["reply"] = response.Reply } if len(response.ToolCalls) > 0 { var toolCalls []map[string]any for _, tc := range response.ToolCalls { toolCalls = append(toolCalls, map[string]any{ "tool": tc.Name, "input": tc.Input, }) } result["tool_calls"] = toolCalls } if response.Answer != "" { result["answer"] = response.Answer } json.NewEncoder(w).Encode(result) })) mux.HandleFunc("/", wrap(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if strings.HasPrefix(path, "/auth/") { // Let the dedicated /auth/* handlers process this return } userID := getUser(r) var user any if userID != "" { user = &TemplateUser{ID: userID} } else { user = nil } if path == "/" { serviceCount, runningCount, stoppedCount, statusDot := getDashboardData() // Fetch registered services for the home page var homeServices []string if svcs, err := registry.ListServices(); err == nil { for _, s := range svcs { homeServices = append(homeServices, s.Name) } sort.Strings(homeServices) } err := renderPage(w, tmpls.home, map[string]any{ "Title": "Home", "WebLink": "/", "ServiceCount": serviceCount, "RunningCount": runningCount, "StoppedCount": stoppedCount, "StatusDot": statusDot, "Services": homeServices, "User": user, }) if err != nil { log.Printf("[TEMPLATE ERROR] home: %v", err) } return } if path == "/api" || path == "/api/" { apiCache.Lock() useCache := false if apiCache.data != nil && time.Since(apiCache.time) < 30*time.Second { useCache = true } var apiData map[string]any var sidebarEndpoints []map[string]string if useCache { apiData = apiCache.data if v, ok := apiData["SidebarEndpoints"]; ok { sidebarEndpoints, _ = v.([]map[string]string) } } else { services, _ := registry.ListServices() var apiServices []map[string]any for _, srv := range services { srvs, err := registry.GetService(srv.Name) if err != nil || len(srvs) == 0 { continue } s := srvs[0] if len(s.Endpoints) == 0 { continue } endpoints := []map[string]any{} for _, ep := range s.Endpoints { parts := strings.Split(ep.Name, ".") if len(parts) != 2 { continue } apiPath := fmt.Sprintf("/api/%s/%s/%s", s.Name, parts[0], parts[1]) var params, response string if ep.Request != nil && len(ep.Request.Values) > 0 { params += "" } else { params = "No parameters" } if ep.Response != nil && len(ep.Response.Values) > 0 { response += "" } else { response = "No response fields" } endpoints = append(endpoints, map[string]any{ "Name": ep.Name, "Path": apiPath, "Params": params, "Response": response, }) } anchor := strings.ReplaceAll(s.Name, ".", "-") apiServices = append(apiServices, map[string]any{ "Name": s.Name, "Anchor": anchor, "Endpoints": endpoints, }) sidebarEndpoints = append(sidebarEndpoints, map[string]string{"Name": s.Name, "Anchor": anchor}) } sort.Slice(sidebarEndpoints, func(i, j int) bool { return sidebarEndpoints[i]["Name"] < sidebarEndpoints[j]["Name"] }) apiData = map[string]any{"Title": "API", "WebLink": "/", "Services": apiServices, "SidebarEndpoints": sidebarEndpoints, "SidebarEndpointsEnabled": true, "User": user} apiCache.data = apiData apiCache.time = time.Now() } apiCache.Unlock() // Add API auth doc at the top apiData["ApiAuthDoc"] = `
API Authentication Required: All API calls to /api/... endpoints (except this page) must include an Authorization: Bearer <token> header.
You can generate tokens on the Tokens page.
` _ = renderPage(w, tmpls.api, apiData) return } if path == "/services" { // Do NOT include SidebarEndpoints on this page services, _ := registry.ListServices() var serviceNames []string for _, service := range services { serviceNames = append(serviceNames, service.Name) } sort.Strings(serviceNames) _ = renderPage(w, tmpls.service, map[string]any{"Title": "Services", "WebLink": "/", "Services": serviceNames, "User": user}) return } if path == "/agent" { _ = renderPage(w, tmpls.playground, map[string]any{"Title": "Agent", "WebLink": "/", "User": user}) return } if path == "/logs" || path == "/logs/" { // Do NOT include SidebarEndpoints on this page homeDir, err := os.UserHomeDir() if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Could not get home directory")) return } logsDir := homeDir + "/micro/logs" dirEntries, err := os.ReadDir(logsDir) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Could not list logs directory: " + err.Error())) return } serviceNames := []string{} for _, entry := range dirEntries { name := entry.Name() if !entry.IsDir() && strings.HasSuffix(name, ".log") && !strings.HasPrefix(name, ".") { serviceNames = append(serviceNames, strings.TrimSuffix(name, ".log")) } } _ = renderPage(w, tmpls.logs, map[string]any{"Title": "Logs", "WebLink": "/", "Services": serviceNames, "User": user}) return } if strings.HasPrefix(path, "/logs/") { // Do NOT include SidebarEndpoints on this page service := strings.TrimPrefix(path, "/logs/") if service == "" { w.WriteHeader(http.StatusNotFound) w.Write([]byte("Service not specified")) return } homeDir, err := os.UserHomeDir() if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Could not get home directory")) return } logFilePath := homeDir + "/micro/logs/" + service + ".log" f, err := os.Open(logFilePath) if err != nil { w.WriteHeader(http.StatusNotFound) w.Write([]byte("Could not open log file for service: " + service)) return } defer f.Close() logBytes, err := io.ReadAll(f) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Could not read log file for service: " + service)) return } logText := string(logBytes) _ = renderPage(w, tmpls.log, map[string]any{"Title": "Logs for " + service, "WebLink": "/logs", "Service": service, "Log": logText, "User": user}) return } if path == "/status" { // Do NOT include SidebarEndpoints on this page homeDir, err := os.UserHomeDir() if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Could not get home directory")) return } pidDir := homeDir + "/micro/run" dirEntries, err := os.ReadDir(pidDir) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Could not list pid directory: " + err.Error())) return } statuses := []map[string]string{} for _, entry := range dirEntries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") || strings.HasPrefix(entry.Name(), ".") { continue } pidFile := pidDir + "/" + entry.Name() pidBytes, err := os.ReadFile(pidFile) if err != nil { statuses = append(statuses, map[string]string{ "Service": entry.Name(), "Dir": "-", "Status": "unknown", "PID": "-", "Uptime": "-", "ID": strings.TrimSuffix(entry.Name(), ".pid"), }) continue } lines := strings.Split(string(pidBytes), "\n") pid := "-" dir := "-" service := "-" start := "-" if len(lines) > 0 && len(lines[0]) > 0 { pid = lines[0] } if len(lines) > 1 && len(lines[1]) > 0 { dir = lines[1] } if len(lines) > 2 && len(lines[2]) > 0 { service = lines[2] } if len(lines) > 3 && len(lines[3]) > 0 { start = lines[3] } status := "stopped" if pid != "-" { if _, err := os.FindProcess(parsePid(pid)); err == nil { if processRunning(pid) { status = "running" } } else { status = "stopped" } } uptime := "-" if start != "-" { if t, err := parseStartTime(start); err == nil { uptime = time.Since(t).Truncate(time.Second).String() } } statuses = append(statuses, map[string]string{ "Service": service, "Dir": dir, "Status": status, "PID": pid, "Uptime": uptime, "ID": strings.TrimSuffix(entry.Name(), ".pid"), }) } _ = renderPage(w, tmpls.status, map[string]any{"Title": "Status", "WebLink": "/", "Statuses": statuses, "User": user}) return } // Match /{service} and /{service}/{endpoint} parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) >= 1 && parts[0] != "api" && parts[0] != "html" && parts[0] != "services" { service := parts[0] if len(parts) == 1 { s, err := registry.GetService(service) if err != nil || len(s) == 0 { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, "Service not found: %s", service) return } endpoints := []map[string]string{} for _, ep := range s[0].Endpoints { endpoints = append(endpoints, map[string]string{ "Name": ep.Name, "Path": fmt.Sprintf("/%s/%s", service, ep.Name), }) } b, _ := json.MarshalIndent(s[0], "", " ") _ = renderPage(w, tmpls.service, map[string]any{ "Title": "Service: " + service, "WebLink": "/", "ServiceName": service, "Endpoints": endpoints, "Description": string(b), "User": user, }) return } if len(parts) == 2 { service := parts[0] endpoint := parts[1] // Use the actual endpoint name from the URL, e.g. Foo.Bar s, err := registry.GetService(service) if err != nil || len(s) == 0 { w.WriteHeader(http.StatusNotFound) w.Write([]byte("Service not found: " + service)) return } var ep *registry.Endpoint for _, eps := range s[0].Endpoints { if eps.Name == endpoint { ep = eps break } } if ep == nil { w.WriteHeader(http.StatusNotFound) w.Write([]byte("Endpoint not found")) return } if r.Method == http.MethodGet { // Build form fields from endpoint request values var inputs []map[string]string if ep.Request != nil && len(ep.Request.Values) > 0 { for _, input := range ep.Request.Values { inputs = append(inputs, map[string]string{ "Label": input.Name, "Name": input.Name, "Placeholder": input.Name, "Value": "", }) } } _ = renderPage(w, tmpls.form, map[string]any{ "Title": "Service: " + service, "WebLink": "/", "ServiceName": service, "EndpointName": ep.Name, "Inputs": inputs, "Action": service + "/" + endpoint, "User": user, }) return } if r.Method == http.MethodPost { // Check endpoint scopes endpointKey := fmt.Sprintf("%s.%s", service, endpoint) if !checkEndpointScopes(w, r, endpointKey) { return } // Parse form values into a map var reqBody map[string]interface{} if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { defer r.Body.Close() _ = json.NewDecoder(r.Body).Decode(&reqBody) } else { reqBody = map[string]interface{}{} _ = r.ParseForm() for k, v := range r.Form { if len(v) == 1 { if len(v[0]) == 0 { continue } reqBody[k] = v[0] } else { reqBody[k] = v } } } // For now, just echo the request body as JSON w.Header().Set("Content-Type", "application/json") b, _ := json.MarshalIndent(reqBody, "", " ") w.Write(b) return } } } w.WriteHeader(http.StatusNotFound) w.Write([]byte("Not found")) })) // Auth routes - only registered when auth is enabled if authEnabled { authMw := authRequired(storeInst) // loadEndpointScopes returns all stored endpoint scopes from the store loadEndpointScopes := func() map[string][]string { recs, _ := storeInst.Read("endpoint-scopes/", store.ReadPrefix()) result := map[string][]string{} for _, rec := range recs { name := strings.TrimPrefix(rec.Key, "endpoint-scopes/") var scopes []string if err := json.Unmarshal(rec.Value, &scopes); err == nil && len(scopes) > 0 { result[name] = scopes } } return result } // Scopes management — per-endpoint scope requirements mux.HandleFunc("/auth/scopes", authMw(func(w http.ResponseWriter, r *http.Request) { userID := getUser(r) var user any if userID != "" { user = &TemplateUser{ID: userID} } success := false if r.Method == http.MethodPost { endpoint := r.FormValue("endpoint") scopesStr := r.FormValue("scopes") if endpoint != "" { if scopesStr == "" { _ = storeInst.Delete("endpoint-scopes/" + endpoint) } else { scopes := strings.Split(scopesStr, ",") for i := range scopes { scopes[i] = strings.TrimSpace(scopes[i]) } b, _ := json.Marshal(scopes) _ = storeInst.Write(&store.Record{Key: "endpoint-scopes/" + endpoint, Value: b}) } success = true } } // Discover endpoints services, _ := registry.ListServices() storedScopes := loadEndpointScopes() type endpointEntry struct { Name string Service string Endpoint string Scopes []string ScopesStr string } var endpoints []endpointEntry for _, svc := range services { fullSvcs, err := registry.GetService(svc.Name) if err != nil || len(fullSvcs) == 0 { continue } for _, ep := range fullSvcs[0].Endpoints { key := fmt.Sprintf("%s.%s", svc.Name, ep.Name) scopes := storedScopes[key] scopesStr := strings.Join(scopes, ", ") endpoints = append(endpoints, endpointEntry{ Name: key, Service: svc.Name, Endpoint: ep.Name, Scopes: scopes, ScopesStr: scopesStr, }) } } _ = renderPage(w, tmpls.scopes, map[string]any{ "Title": "Scopes", "Endpoints": endpoints, "User": user, "Success": success, }) })) // Bulk set scopes for endpoints matching a pattern mux.HandleFunc("/auth/scopes/bulk", authMw(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Redirect(w, r, "/auth/scopes", http.StatusSeeOther) return } pattern := r.FormValue("pattern") scopesStr := r.FormValue("scopes") if pattern == "" { http.Redirect(w, r, "/auth/scopes", http.StatusSeeOther) return } scopes := []string{} if scopesStr != "" { scopes = strings.Split(scopesStr, ",") for i := range scopes { scopes[i] = strings.TrimSpace(scopes[i]) } } // Find matching endpoints services, _ := registry.ListServices() for _, svc := range services { fullSvcs, err := registry.GetService(svc.Name) if err != nil || len(fullSvcs) == 0 { continue } for _, ep := range fullSvcs[0].Endpoints { key := fmt.Sprintf("%s.%s", svc.Name, ep.Name) matched := false if strings.HasSuffix(pattern, "*") { prefix := strings.TrimSuffix(pattern, "*") matched = strings.HasPrefix(key, prefix) } else { matched = key == pattern } if matched { if len(scopes) == 0 { _ = storeInst.Delete("endpoint-scopes/" + key) } else { b, _ := json.Marshal(scopes) _ = storeInst.Write(&store.Record{Key: "endpoint-scopes/" + key, Value: b}) } } } } http.Redirect(w, r, "/auth/scopes", http.StatusSeeOther) })) mux.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{Name: "micro_token", Value: "", Path: "/", Expires: time.Now().Add(-1 * time.Hour), HttpOnly: true}) http.Redirect(w, r, "/auth/login", http.StatusSeeOther) }) mux.HandleFunc("/auth/tokens", authMw(func(w http.ResponseWriter, r *http.Request) { userID := getUser(r) var user any if userID != "" { user = &TemplateUser{ID: userID} } else { user = nil } if r.Method == http.MethodPost { id := r.FormValue("id") typeStr := r.FormValue("type") scopesStr := r.FormValue("scopes") accType := "user" switch typeStr { case "admin": accType = "admin" case "service": accType = "service" } scopes := []string{"*"} if scopesStr != "" { scopes = strings.Split(scopesStr, ",") for i := range scopes { scopes[i] = strings.TrimSpace(scopes[i]) } } acc := &Account{ ID: id, Type: accType, Scopes: scopes, Metadata: map[string]string{"created": time.Now().Format(time.RFC3339)}, } // Service tokens do not require a password, generate a JWT directly tok, _ := GenerateJWT(acc.ID, acc.Type, acc.Scopes, 24*time.Hour) acc.Metadata["token"] = tok b, _ := json.Marshal(acc) _ = storeInst.Write(&store.Record{Key: "auth/" + id, Value: b}) storeJWTToken(storeInst, tok, acc.ID) // Store the JWT token http.Redirect(w, r, "/auth/tokens", http.StatusSeeOther) return } recs, _ := storeInst.Read("auth/", store.ReadPrefix()) var tokens []map[string]any for _, rec := range recs { var acc Account if err := json.Unmarshal(rec.Value, &acc); err == nil { tok := "" if t, ok := acc.Metadata["token"]; ok { tok = t } var tokenPrefix, tokenSuffix string if len(tok) > 12 { tokenPrefix = tok[:4] tokenSuffix = tok[len(tok)-4:] } else { tokenPrefix = tok tokenSuffix = "" } tokens = append(tokens, map[string]any{ "ID": acc.ID, "Type": acc.Type, "Scopes": acc.Scopes, "Metadata": acc.Metadata, "Token": tok, "TokenPrefix": tokenPrefix, "TokenSuffix": tokenSuffix, }) } } _ = renderPage(w, tmpls.authTokens, map[string]any{"Title": "Tokens", "Tokens": tokens, "User": user, "Sub": userID}) })) mux.HandleFunc("/auth/users", authMw(func(w http.ResponseWriter, r *http.Request) { userID := getUser(r) var user any if userID != "" { user = &TemplateUser{ID: userID} } else { user = nil } if r.Method == http.MethodPost { if del := r.FormValue("delete"); del != "" { // Delete user _ = storeInst.Delete("auth/" + del) deleteUserTokens(storeInst, del) // Mark default admin as deleted so it won't be recreated on restart if del == "admin" { _ = storeInst.Write(&store.Record{Key: "auth/.admin-deleted", Value: []byte("true")}) } http.Redirect(w, r, "/auth/users", http.StatusSeeOther) return } id := r.FormValue("id") if id == "" { http.Redirect(w, r, "/auth/users", http.StatusSeeOther) return } pass := r.FormValue("password") typeStr := r.FormValue("type") accType := "user" if typeStr == "admin" { accType = "admin" } hash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) acc := &Account{ ID: id, Type: accType, Scopes: []string{"*"}, Metadata: map[string]string{"created": time.Now().Format(time.RFC3339), "password_hash": string(hash)}, } b, _ := json.Marshal(acc) _ = storeInst.Write(&store.Record{Key: "auth/" + id, Value: b}) http.Redirect(w, r, "/auth/users", http.StatusSeeOther) return } recs, _ := storeInst.Read("auth/", store.ReadPrefix()) var users []Account for _, rec := range recs { var acc Account if err := json.Unmarshal(rec.Value, &acc); err == nil { if acc.Type == "user" || acc.Type == "admin" { users = append(users, acc) } } } _ = renderPage(w, tmpls.authUsers, map[string]any{"Title": "Users", "Users": users, "User": user}) })) mux.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { loginTmpl, err := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html") if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Template error: " + err.Error())) return } _ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "", "User": getUser(r), "HideSidebar": true}) return } if r.Method == http.MethodPost { id := r.FormValue("id") pass := r.FormValue("password") recKey := "auth/" + id recs, _ := storeInst.Read(recKey) if len(recs) == 0 { loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html") _ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Invalid credentials", "User": "", "HideSidebar": true}) return } var acc Account if err := json.Unmarshal(recs[0].Value, &acc); err != nil { loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html") _ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Invalid credentials", "User": "", "HideSidebar": true}) return } hash, ok := acc.Metadata["password_hash"] if !ok || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil { loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html") _ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Invalid credentials", "User": "", "HideSidebar": true}) return } tok, err := GenerateJWT(acc.ID, acc.Type, acc.Scopes, 24*time.Hour) if err != nil { log.Printf("[LOGIN ERROR] Token generation failed: %v\nAccount: %+v", err, acc) loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html") _ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Token error", "User": "", "HideSidebar": true}) return } storeJWTToken(storeInst, tok, acc.ID) // Store the JWT token http.SetCookie(w, &http.Cookie{ Name: "micro_token", Value: tok, Path: "/", Expires: time.Now().Add(time.Hour * 24), HttpOnly: true, }) http.Redirect(w, r, "/", http.StatusSeeOther) return } w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte("Method not allowed")) }) } // end if authEnabled } func Run(c *cli.Context) error { addr := c.String("address") if addr == "" { addr = ":8080" } mcpAddr := c.String("mcp-address") // Run the gateway with authentication enabled opts := GatewayOptions{ Address: addr, AuthEnabled: true, Context: c.Context, MCPEnabled: mcpAddr != "", MCPAddress: mcpAddr, } return RunGateway(opts) } // mapGoTypeToJSON maps Go types to JSON schema types func mapGoTypeToJSON(goType string) string { switch goType { case "string": return "string" case "int", "int32", "int64", "uint", "uint32", "uint64": return "integer" case "float32", "float64": return "number" case "bool": return "boolean" default: return "object" } } // --- PID FILES --- func parsePid(pidStr string) int { pid, _ := strconv.Atoi(pidStr) return pid } func processRunning(pid string) bool { proc, err := os.FindProcess(parsePid(pid)) if err != nil { return false } // On unix, sending syscall.Signal(0) checks if process exists return proc.Signal(syscall.Signal(0)) == nil } func initAuth() error { // --- AUTH SETUP --- homeDir, _ := os.UserHomeDir() keyDir := filepath.Join(homeDir, "micro", "keys") privPath := filepath.Join(keyDir, "private.pem") pubPath := filepath.Join(keyDir, "public.pem") _ = os.MkdirAll(keyDir, 0700) // Generate keypair if not exist if _, err := os.Stat(privPath); os.IsNotExist(err) { priv, _ := rsa.GenerateKey(rand.Reader, 2048) privBytes := x509.MarshalPKCS1PrivateKey(priv) privPem := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes}) _ = os.WriteFile(privPath, privPem, 0600) // Use PKIX format for public key pubBytes, _ := x509.MarshalPKIXPublicKey(&priv.PublicKey) pubPem := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}) _ = os.WriteFile(pubPath, pubPem, 0644) } _, _ = os.ReadFile(privPath) _, _ = os.ReadFile(pubPath) storeInst := store.DefaultStore // --- Ensure default admin account exists on first run --- // If the admin was explicitly deleted (marker key exists), don't recreate. adminID := "admin" adminPass := "micro" adminKey := "auth/" + adminID adminDeletedKey := "auth/.admin-deleted" if recs, _ := storeInst.Read(adminDeletedKey); len(recs) > 0 { // Admin was explicitly deleted — don't recreate } else if recs, _ := storeInst.Read(adminKey); len(recs) == 0 { // Hash the admin password with bcrypt hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) if err != nil { return err } acc := &Account{ ID: adminID, Type: "admin", Scopes: []string{"*"}, Metadata: map[string]string{"created": time.Now().Format(time.RFC3339), "password_hash": string(hash)}, } b, _ := json.Marshal(acc) _ = storeInst.Write(&store.Record{Key: adminKey, Value: b}) } return nil } // parseStartTime parses a string as RFC3339 time func parseStartTime(s string) (time.Time, error) { return time.Parse(time.RFC3339, s) } func init() { cmd.Register(&cli.Command{ Name: "server", Usage: "Production mode: run the micro server with dashboard and auth", Action: Run, Flags: []cli.Flag{ &cli.StringFlag{ Name: "address", Usage: "Address to listen on", EnvVars: []string{"MICRO_SERVER_ADDRESS"}, Value: ":8080", }, &cli.StringFlag{ Name: "mcp-address", Usage: "MCP gateway address (e.g., :3000). Enables MCP protocol support for AI tools.", EnvVars: []string{"MICRO_MCP_ADDRESS"}, }, }, }) }