mirror of
https://github.com/go-micro/go-micro.git
synced 2026-06-15 19:35:13 +02:00
* feat: expose framework primitives via API gateway and MCP
Add registry, store, and broker as both HTTP routes and MCP tools
so AI agents and HTTP clients can inspect and operate the framework.
API gateway (/micro/* namespace):
GET /micro/registry List registered services
GET /micro/registry/{name} Describe a service
GET /micro/store List store keys
GET /micro/store/{key} Read a record
POST /micro/store/{key} Write a record
POST /micro/broker/{topic} Publish a message
MCP gateway (micro_* tool prefix):
micro_registry_list List services
micro_registry_get Describe a service
micro_store_list List keys
micro_store_read Read a record
micro_store_write Write a record
micro_broker_publish Publish a message
Framework tools use a Handler field on the MCP Tool struct for
direct dispatch (no RPC). Service tools continue to use RPC.
Rate limiters and circuit breakers are applied to framework
tools the same as service tools.
* fix: make framework internals opt-in on API and MCP gateways
Framework primitives (registry, broker, store) are now only
exposed when explicitly enabled:
API gateway: micro api --internal
MCP gateway: Options{Internal: true}
Off by default — user services are always exposed, framework
internals require the flag. Banner output only shows framework
routes when enabled.
* fix: always expose framework internals, gate by auth in production
Revert the --internal flag approach. Framework primitives (registry,
broker, store) are now always exposed:
- micro api: /micro/* routes always available (dev tool)
- MCP gateway: micro_* tools always registered. When Auth is
configured (production), they require micro:admin scope.
Without Auth (dev), they're open — same as all other tools.
This follows the existing pattern: micro run/api = dev (open),
micro server = production (auth + scopes). Framework internals
follow the same security model as user services.
Remove the Internal option from MCP Options. Remove --internal
flag from micro api.
Note: scope persistence depends on the store backend. The default
in-memory store does not survive restarts. Use MICRO_STORE=file
for persistent scopes in production.
* fix: correct DefaultStore comment — it's file-backed, not memory
* fix(server): don't recreate deleted admin user on restart
When the default admin account is deleted via the dashboard, set
a marker key (auth/.admin-deleted) in the store. On startup, skip
admin creation if the marker exists. This prevents the default
admin/micro credentials from reappearing after restart when the
user has intentionally removed them.
---------
Co-authored-by: Claude <noreply@anthropic.com>
1684 lines
51 KiB
Go
1684 lines
51 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"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/v5/auth"
|
|
"go-micro.dev/v5/client"
|
|
"go-micro.dev/v5/cmd"
|
|
codecBytes "go-micro.dev/v5/codec/bytes"
|
|
"go-micro.dev/v5/ai"
|
|
_ "go-micro.dev/v5/ai/anthropic"
|
|
_ "go-micro.dev/v5/ai/atlascloud"
|
|
_ "go-micro.dev/v5/ai/gemini"
|
|
_ "go-micro.dev/v5/ai/groq"
|
|
_ "go-micro.dev/v5/ai/mistral"
|
|
_ "go-micro.dev/v5/ai/openai"
|
|
_ "go-micro.dev/v5/ai/together"
|
|
"go-micro.dev/v5/registry"
|
|
"go-micro.dev/v5/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/<id>" 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 getSidebarEndpoints() ([]map[string]string, error) {
|
|
apiCache.Lock()
|
|
defer apiCache.Unlock()
|
|
if apiCache.data != nil && time.Since(apiCache.time) < 30*time.Second {
|
|
if v, ok := apiCache.data["SidebarEndpoints"]; ok {
|
|
if endpoints, ok := v.([]map[string]string); ok {
|
|
return endpoints, nil
|
|
}
|
|
}
|
|
}
|
|
services, err := registry.ListServices()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var sidebarEndpoints []map[string]string
|
|
for _, srv := range services {
|
|
anchor := strings.ReplaceAll(srv.Name, ".", "-")
|
|
sidebarEndpoints = append(sidebarEndpoints, map[string]string{"Name": srv.Name, "Anchor": anchor})
|
|
}
|
|
sort.Slice(sidebarEndpoints, func(i, j int) bool {
|
|
return sidebarEndpoints[i]["Name"] < sidebarEndpoints[j]["Name"]
|
|
})
|
|
return sidebarEndpoints, nil
|
|
}
|
|
|
|
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(404)
|
|
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(404)
|
|
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(500)
|
|
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 != "POST" {
|
|
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 == "GET" {
|
|
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 == "POST" {
|
|
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 != "POST" {
|
|
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(toolName string, input map[string]any) (any, string) {
|
|
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 map[string]string{"error": "insufficient scopes", "required_scopes": strings.Join(requiredScopes, ",")}, errMsg
|
|
}
|
|
}
|
|
}
|
|
}
|
|
parts := strings.SplitN(toolName, ".", 2)
|
|
if len(parts) != 2 {
|
|
errMsg := `{"error":"invalid tool name"}`
|
|
return map[string]string{"error": "invalid tool name"}, 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 map[string]string{"error": err.Error()}, errMsg
|
|
}
|
|
var rpcResult any
|
|
if err := json.Unmarshal(rsp.Data, &rpcResult); err != nil {
|
|
rpcResult = string(rsp.Data)
|
|
}
|
|
return rpcResult, 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 += "<ul class=no-bullets>"
|
|
for _, v := range ep.Request.Values {
|
|
params += fmt.Sprintf("<li><b>%s</b> <span style='color:#888;'>%s</span></li>", v.Name, v.Type)
|
|
}
|
|
params += "</ul>"
|
|
} else {
|
|
params = "<i style='color:#888;'>No parameters</i>"
|
|
}
|
|
if ep.Response != nil && len(ep.Response.Values) > 0 {
|
|
response += "<ul class=no-bullets>"
|
|
for _, v := range ep.Response.Values {
|
|
response += fmt.Sprintf("<li><b>%s</b> <span style='color:#888;'>%s</span></li>", v.Name, v.Type)
|
|
}
|
|
response += "</ul>"
|
|
} else {
|
|
response = "<i style='color:#888;'>No response fields</i>"
|
|
}
|
|
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"] = `<div style='background:#f8f8e8; border:1px solid #e0e0b0; padding:1em; margin-bottom:2em; font-size:1.08em;'>
|
|
<b>API Authentication Required:</b> All API calls to <code>/api/...</code> endpoints (except this page) must include an <b>Authorization: Bearer <token></b> header. <br>
|
|
You can generate tokens on the <a href='/auth/tokens'>Tokens page</a>.
|
|
</div>`
|
|
_ = 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(500)
|
|
w.Write([]byte("Could not get home directory"))
|
|
return
|
|
}
|
|
logsDir := homeDir + "/micro/logs"
|
|
dirEntries, err := os.ReadDir(logsDir)
|
|
if err != nil {
|
|
w.WriteHeader(500)
|
|
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(404)
|
|
w.Write([]byte("Service not specified"))
|
|
return
|
|
}
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
w.WriteHeader(500)
|
|
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(404)
|
|
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(500)
|
|
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(500)
|
|
w.Write([]byte("Could not get home directory"))
|
|
return
|
|
}
|
|
pidDir := homeDir + "/micro/run"
|
|
dirEntries, err := os.ReadDir(pidDir)
|
|
if err != nil {
|
|
w.WriteHeader(500)
|
|
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(404)
|
|
w.Write([]byte(fmt.Sprintf("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(404)
|
|
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(404)
|
|
w.Write([]byte("Endpoint not found"))
|
|
return
|
|
}
|
|
if r.Method == "GET" {
|
|
// 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 == "POST" {
|
|
// 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(404)
|
|
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 == "POST" {
|
|
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 != "POST" {
|
|
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 == "POST" {
|
|
id := r.FormValue("id")
|
|
typeStr := r.FormValue("type")
|
|
scopesStr := r.FormValue("scopes")
|
|
accType := "user"
|
|
if typeStr == "admin" {
|
|
accType = "admin"
|
|
} else if typeStr == "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 == "POST" {
|
|
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 == "GET" {
|
|
loginTmpl, err := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")
|
|
if err != nil {
|
|
w.WriteHeader(500)
|
|
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 == "POST" {
|
|
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(405)
|
|
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 generateKeyPair(bits int) (*rsa.PrivateKey, error) {
|
|
priv, err := rsa.GenerateKey(rand.Reader, bits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return priv, nil
|
|
}
|
|
func exportPrivateKeyAsPEM(priv *rsa.PrivateKey) ([]byte, error) {
|
|
privKeyBytes := x509.MarshalPKCS1PrivateKey(priv)
|
|
block := &pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: privKeyBytes,
|
|
}
|
|
var buf bytes.Buffer
|
|
err := pem.Encode(&buf, block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
func exportPublicKeyAsPEM(pub *rsa.PublicKey) ([]byte, error) {
|
|
pubKeyBytes := x509.MarshalPKCS1PublicKey(pub)
|
|
block := &pem.Block{
|
|
Type: "RSA PUBLIC KEY",
|
|
Bytes: pubKeyBytes,
|
|
}
|
|
var buf bytes.Buffer
|
|
err := pem.Encode(&buf, block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
func importPrivateKeyFromPEM(privKeyPEM []byte) (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode(privKeyPEM)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("invalid PEM block")
|
|
}
|
|
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
}
|
|
func importPublicKeyFromPEM(pubKeyPEM []byte) (*rsa.PublicKey, error) {
|
|
block, _ := pem.Decode(pubKeyPEM)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("invalid PEM block")
|
|
}
|
|
return x509.ParsePKCS1PublicKey(block.Bytes)
|
|
}
|
|
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"},
|
|
},
|
|
},
|
|
})
|
|
}
|