go-micro.dev/v5@v5.12.0/cmd/micro/server/server.go (about)

     1  package server
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/rand"
     6  	"crypto/rsa"
     7  	"crypto/x509"
     8  	"encoding/base64"
     9  	"encoding/json"
    10  	"encoding/pem"
    11  	"fmt"
    12  	"io"
    13  	"io/fs"
    14  	"log"
    15  	"net/http"
    16  	"os"
    17  	"path/filepath"
    18  	"sort"
    19  	"strconv"
    20  	"strings"
    21  	"sync"
    22  	"syscall"
    23  	"text/template"
    24  	"time"
    25  
    26  	"github.com/urfave/cli/v2"
    27  	"go-micro.dev/v5/cmd"
    28  	"go-micro.dev/v5/registry"
    29  	"go-micro.dev/v5/store"
    30  	"golang.org/x/crypto/bcrypt"
    31  )
    32  
    33  // HTML is the embedded filesystem for templates and static files, set by main.go
    34  var HTML fs.FS
    35  
    36  var (
    37  	apiCache struct {
    38  		sync.Mutex
    39  		data map[string]any
    40  		time time.Time
    41  	}
    42  )
    43  
    44  type templates struct {
    45  	api        *template.Template
    46  	service    *template.Template
    47  	form       *template.Template
    48  	home       *template.Template
    49  	logs       *template.Template
    50  	log        *template.Template
    51  	status     *template.Template
    52  	authTokens *template.Template
    53  	authLogin  *template.Template
    54  	authUsers  *template.Template
    55  }
    56  
    57  type TemplateUser struct {
    58  	ID string
    59  }
    60  
    61  // Define a local Account struct to replace auth.Account
    62  // (matches fields used in the code)
    63  type Account struct {
    64  	ID       string            `json:"id"`
    65  	Type     string            `json:"type"`
    66  	Scopes   []string          `json:"scopes"`
    67  	Metadata map[string]string `json:"metadata"`
    68  }
    69  
    70  func parseTemplates() *templates {
    71  	return &templates{
    72  		api:        template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/api.html")),
    73  		service:    template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/service.html")),
    74  		form:       template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/form.html")),
    75  		home:       template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/home.html")),
    76  		logs:       template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/logs.html")),
    77  		log:        template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/log.html")),
    78  		status:     template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/status.html")),
    79  		authTokens: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_tokens.html")),
    80  		authLogin:  template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")),
    81  		authUsers:  template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_users.html")),
    82  	}
    83  }
    84  
    85  // Helper to render templates
    86  func render(w http.ResponseWriter, tmpl *template.Template, data any) error {
    87  	return tmpl.Execute(w, data)
    88  }
    89  
    90  // Helper to extract user info from JWT cookie
    91  func getUser(r *http.Request) string {
    92  	cookie, err := r.Cookie("micro_token")
    93  	if err != nil || cookie.Value == "" {
    94  		return ""
    95  	}
    96  	// Parse JWT claims (just decode, don't verify)
    97  	parts := strings.Split(cookie.Value, ".")
    98  	if len(parts) != 3 {
    99  		return ""
   100  	}
   101  	payload, err := decodeSegment(parts[1])
   102  	if err != nil {
   103  		return ""
   104  	}
   105  	var claims map[string]any
   106  	if err := json.Unmarshal(payload, &claims); err != nil {
   107  		return ""
   108  	}
   109  	if sub, ok := claims["sub"].(string); ok {
   110  		return sub
   111  	}
   112  	if id, ok := claims["id"].(string); ok {
   113  		return id
   114  	}
   115  	return ""
   116  }
   117  
   118  // Helper to decode JWT base64url segment
   119  func decodeSegment(seg string) ([]byte, error) {
   120  	// JWT uses base64url, no padding
   121  	missing := len(seg) % 4
   122  	if missing != 0 {
   123  		seg += strings.Repeat("=", 4-missing)
   124  	}
   125  	return decodeBase64Url(seg)
   126  }
   127  
   128  func decodeBase64Url(s string) ([]byte, error) {
   129  	return base64.URLEncoding.DecodeString(s)
   130  }
   131  
   132  // Helper: store JWT token
   133  func storeJWTToken(storeInst store.Store, token, userID string) {
   134  	storeInst.Write(&store.Record{Key: "jwt/" + token, Value: []byte(userID)})
   135  }
   136  
   137  // Helper: check if JWT token is revoked (not present in store)
   138  func isTokenRevoked(storeInst store.Store, token string) bool {
   139  	recs, _ := storeInst.Read("jwt/" + token)
   140  	return len(recs) == 0
   141  }
   142  
   143  // Helper: delete all JWT tokens for a user
   144  func deleteUserTokens(storeInst store.Store, userID string) {
   145  	recs, _ := storeInst.Read("jwt/", store.ReadPrefix())
   146  	for _, rec := range recs {
   147  		if string(rec.Value) == userID {
   148  			storeInst.Delete(rec.Key)
   149  		}
   150  	}
   151  }
   152  
   153  // Updated authRequired to accept storeInst as argument
   154  func authRequired(storeInst store.Store) func(http.HandlerFunc) http.HandlerFunc {
   155  	return func(next http.HandlerFunc) http.HandlerFunc {
   156  		return func(w http.ResponseWriter, r *http.Request) {
   157  			var token string
   158  			// 1. Check Authorization: Bearer header
   159  			authz := r.Header.Get("Authorization")
   160  			if strings.HasPrefix(authz, "Bearer ") {
   161  				token = strings.TrimPrefix(authz, "Bearer ")
   162  				token = strings.TrimSpace(token)
   163  			}
   164  			// 2. Fallback to micro_token cookie if no header
   165  			if token == "" {
   166  				cookie, err := r.Cookie("micro_token")
   167  				if err == nil && cookie.Value != "" {
   168  					token = cookie.Value
   169  				}
   170  			}
   171  			if token == "" {
   172  				if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" {
   173  					w.Header().Set("Content-Type", "application/json")
   174  					w.WriteHeader(http.StatusUnauthorized)
   175  					w.Write([]byte(`{"error":"missing or invalid token"}`))
   176  					return
   177  				}
   178  				// For API endpoints, return 401. For UI, redirect to login.
   179  				if strings.HasPrefix(r.URL.Path, "/api/") {
   180  					w.WriteHeader(http.StatusUnauthorized)
   181  					w.Write([]byte("Unauthorized: missing token"))
   182  					return
   183  				}
   184  				http.Redirect(w, r, "/auth/login", http.StatusFound)
   185  				return
   186  			}
   187  			claims, err := ParseJWT(token)
   188  			if err != nil {
   189  				if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" {
   190  					w.Header().Set("Content-Type", "application/json")
   191  					w.WriteHeader(http.StatusUnauthorized)
   192  					w.Write([]byte(`{"error":"invalid token"}`))
   193  					return
   194  				}
   195  				if strings.HasPrefix(r.URL.Path, "/api/") {
   196  					w.WriteHeader(http.StatusUnauthorized)
   197  					w.Write([]byte("Unauthorized: invalid token"))
   198  					return
   199  				}
   200  				http.Redirect(w, r, "/auth/login", http.StatusFound)
   201  				return
   202  			}
   203  			if exp, ok := claims["exp"].(float64); ok {
   204  				if int64(exp) < time.Now().Unix() {
   205  					if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" {
   206  						w.Header().Set("Content-Type", "application/json")
   207  						w.WriteHeader(http.StatusUnauthorized)
   208  						w.Write([]byte(`{"error":"token expired"}`))
   209  						return
   210  					}
   211  					if strings.HasPrefix(r.URL.Path, "/api/") {
   212  						w.WriteHeader(http.StatusUnauthorized)
   213  						w.Write([]byte("Unauthorized: token expired"))
   214  						return
   215  					}
   216  					http.Redirect(w, r, "/auth/login", http.StatusFound)
   217  					return
   218  				}
   219  			}
   220  			// Check for token revocation
   221  			if isTokenRevoked(storeInst, token) {
   222  				if strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" && r.URL.Path != "/api/" {
   223  					w.Header().Set("Content-Type", "application/json")
   224  					w.WriteHeader(http.StatusUnauthorized)
   225  					w.Write([]byte(`{"error":"token revoked"}`))
   226  					return
   227  				}
   228  				if strings.HasPrefix(r.URL.Path, "/api/") {
   229  					w.WriteHeader(http.StatusUnauthorized)
   230  					w.Write([]byte("Unauthorized: token revoked"))
   231  					return
   232  				}
   233  				http.Redirect(w, r, "/auth/login", http.StatusFound)
   234  				return
   235  			}
   236  			next(w, r)
   237  		}
   238  	}
   239  }
   240  
   241  func wrapAuth(authRequired func(http.HandlerFunc) http.HandlerFunc) func(http.HandlerFunc) http.HandlerFunc {
   242  	return func(h http.HandlerFunc) http.HandlerFunc {
   243  		return func(w http.ResponseWriter, r *http.Request) {
   244  			path := r.URL.Path
   245  			if strings.HasPrefix(path, "/auth/login") || strings.HasPrefix(path, "/auth/logout") ||
   246  				path == "/styles.css" || path == "/main.js" {
   247  				h(w, r)
   248  				return
   249  			}
   250  			authRequired(h)(w, r)
   251  		}
   252  	}
   253  }
   254  
   255  func getDashboardData() (serviceCount, runningCount, stoppedCount int, statusDot string) {
   256  	homeDir, err := os.UserHomeDir()
   257  	if err != nil {
   258  		return
   259  	}
   260  	pidDir := homeDir + "/micro/run"
   261  	dirEntries, err := os.ReadDir(pidDir)
   262  	if err != nil {
   263  		return
   264  	}
   265  	for _, entry := range dirEntries {
   266  		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") || strings.HasPrefix(entry.Name(), ".") {
   267  			continue
   268  		}
   269  		pidFile := pidDir + "/" + entry.Name()
   270  		pidBytes, err := os.ReadFile(pidFile)
   271  		if err != nil {
   272  			continue
   273  		}
   274  		lines := strings.Split(string(pidBytes), "\n")
   275  		pid := "-"
   276  		if len(lines) > 0 && len(lines[0]) > 0 {
   277  			pid = lines[0]
   278  		}
   279  		serviceCount++
   280  		if pid != "-" {
   281  			if _, err := os.FindProcess(parsePid(pid)); err == nil {
   282  				if processRunning(pid) {
   283  					runningCount++
   284  				} else {
   285  					stoppedCount++
   286  				}
   287  			} else {
   288  				stoppedCount++
   289  			}
   290  		} else {
   291  			stoppedCount++
   292  		}
   293  	}
   294  	if serviceCount > 0 && runningCount == serviceCount {
   295  		statusDot = "green"
   296  	} else if serviceCount > 0 && runningCount > 0 {
   297  		statusDot = "yellow"
   298  	} else {
   299  		statusDot = "red"
   300  	}
   301  	return
   302  }
   303  
   304  func getSidebarEndpoints() ([]map[string]string, error) {
   305  	apiCache.Lock()
   306  	defer apiCache.Unlock()
   307  	if apiCache.data != nil && time.Since(apiCache.time) < 30*time.Second {
   308  		if v, ok := apiCache.data["SidebarEndpoints"]; ok {
   309  			if endpoints, ok := v.([]map[string]string); ok {
   310  				return endpoints, nil
   311  			}
   312  		}
   313  	}
   314  	services, err := registry.ListServices()
   315  	if err != nil {
   316  		return nil, err
   317  	}
   318  	var sidebarEndpoints []map[string]string
   319  	for _, srv := range services {
   320  		anchor := strings.ReplaceAll(srv.Name, ".", "-")
   321  		sidebarEndpoints = append(sidebarEndpoints, map[string]string{"Name": srv.Name, "Anchor": anchor})
   322  	}
   323  	sort.Slice(sidebarEndpoints, func(i, j int) bool {
   324  		return sidebarEndpoints[i]["Name"] < sidebarEndpoints[j]["Name"]
   325  	})
   326  	return sidebarEndpoints, nil
   327  }
   328  
   329  func registerHandlers(tmpls *templates, storeInst store.Store) {
   330  	authMw := authRequired(storeInst)
   331  	wrap := wrapAuth(authMw)
   332  
   333  	// Serve static files from root (not /html/) with correct Content-Type
   334  	http.HandleFunc("/styles.css", func(w http.ResponseWriter, r *http.Request) {
   335  		w.Header().Set("Content-Type", "text/css; charset=utf-8")
   336  		f, err := HTML.Open("html/styles.css")
   337  		if err != nil {
   338  			w.WriteHeader(404)
   339  			return
   340  		}
   341  		defer f.Close()
   342  		io.Copy(w, f)
   343  	})
   344  
   345  	http.HandleFunc("/main.js", func(w http.ResponseWriter, r *http.Request) {
   346  		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
   347  		f, err := HTML.Open("html/main.js")
   348  		if err != nil {
   349  			w.WriteHeader(404)
   350  			return
   351  		}
   352  		defer f.Close()
   353  		io.Copy(w, f)
   354  	})
   355  
   356  	// Serve /html/styles.css and /html/main.js for compatibility
   357  	http.HandleFunc("/html/styles.css", func(w http.ResponseWriter, r *http.Request) {
   358  		w.Header().Set("Content-Type", "text/css; charset=utf-8")
   359  		f, err := HTML.Open("html/styles.css")
   360  		if err != nil {
   361  			w.WriteHeader(404)
   362  			return
   363  		}
   364  		defer f.Close()
   365  		io.Copy(w, f)
   366  	})
   367  	http.HandleFunc("/html/main.js", func(w http.ResponseWriter, r *http.Request) {
   368  		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
   369  		f, err := HTML.Open("html/main.js")
   370  		if err != nil {
   371  			w.WriteHeader(404)
   372  			return
   373  		}
   374  		defer f.Close()
   375  		io.Copy(w, f)
   376  	})
   377  
   378  	http.HandleFunc("/", wrap(func(w http.ResponseWriter, r *http.Request) {
   379  		path := r.URL.Path
   380  		if strings.HasPrefix(path, "/auth/") {
   381  			// Let the dedicated /auth/* handlers process this
   382  			return
   383  		}
   384  		userID := getUser(r)
   385  		var user any
   386  		if userID != "" {
   387  			user = &TemplateUser{ID: userID}
   388  		} else {
   389  			user = nil
   390  		}
   391  		if path == "/" {
   392  			serviceCount, runningCount, stoppedCount, statusDot := getDashboardData()
   393  			// Do NOT include SidebarEndpoints on home page
   394  			err := tmpls.home.Execute(w, map[string]any{
   395  				"Title":        "Micro Dashboard",
   396  				"WebLink":      "/",
   397  				"ServiceCount": serviceCount,
   398  				"RunningCount": runningCount,
   399  				"StoppedCount": stoppedCount,
   400  				"StatusDot":    statusDot,
   401  				"User":         user,
   402  				// No SidebarEndpoints or SidebarEndpointsEnabled here
   403  			})
   404  			if err != nil {
   405  				log.Printf("[TEMPLATE ERROR] home: %v", err)
   406  			}
   407  			return
   408  		}
   409  		if path == "/api" || path == "/api/" {
   410  			apiCache.Lock()
   411  			useCache := false
   412  			if apiCache.data != nil && time.Since(apiCache.time) < 30*time.Second {
   413  				useCache = true
   414  			}
   415  			var apiData map[string]any
   416  			var sidebarEndpoints []map[string]string
   417  			if useCache {
   418  				apiData = apiCache.data
   419  				if v, ok := apiData["SidebarEndpoints"]; ok {
   420  					sidebarEndpoints, _ = v.([]map[string]string)
   421  				}
   422  			} else {
   423  				services, _ := registry.ListServices()
   424  				var apiServices []map[string]any
   425  				for _, srv := range services {
   426  					srvs, err := registry.GetService(srv.Name)
   427  					if err != nil || len(srvs) == 0 {
   428  						continue
   429  					}
   430  					s := srvs[0]
   431  					if len(s.Endpoints) == 0 {
   432  						continue
   433  					}
   434  					endpoints := []map[string]any{}
   435  					for _, ep := range s.Endpoints {
   436  						parts := strings.Split(ep.Name, ".")
   437  						if len(parts) != 2 {
   438  							continue
   439  						}
   440  						apiPath := fmt.Sprintf("/api/%s/%s/%s", s.Name, parts[0], parts[1])
   441  						var params, response string
   442  						if ep.Request != nil && len(ep.Request.Values) > 0 {
   443  							params += "<ul class=no-bullets>"
   444  							for _, v := range ep.Request.Values {
   445  								params += fmt.Sprintf("<li><b>%s</b> <span style='color:#888;'>%s</span></li>", v.Name, v.Type)
   446  							}
   447  							params += "</ul>"
   448  						} else {
   449  							params = "<i style='color:#888;'>No parameters</i>"
   450  						}
   451  						if ep.Response != nil && len(ep.Response.Values) > 0 {
   452  							response += "<ul class=no-bullets>"
   453  							for _, v := range ep.Response.Values {
   454  								response += fmt.Sprintf("<li><b>%s</b> <span style='color:#888;'>%s</span></li>", v.Name, v.Type)
   455  							}
   456  							response += "</ul>"
   457  						} else {
   458  							response = "<i style='color:#888;'>No response fields</i>"
   459  						}
   460  						endpoints = append(endpoints, map[string]any{
   461  							"Name":     ep.Name,
   462  							"Path":     apiPath,
   463  							"Params":   params,
   464  							"Response": response,
   465  						})
   466  					}
   467  					anchor := strings.ReplaceAll(s.Name, ".", "-")
   468  					apiServices = append(apiServices, map[string]any{
   469  						"Name":      s.Name,
   470  						"Anchor":    anchor,
   471  						"Endpoints": endpoints,
   472  					})
   473  					sidebarEndpoints = append(sidebarEndpoints, map[string]string{"Name": s.Name, "Anchor": anchor})
   474  				}
   475  				sort.Slice(sidebarEndpoints, func(i, j int) bool {
   476  					return sidebarEndpoints[i]["Name"] < sidebarEndpoints[j]["Name"]
   477  				})
   478  				apiData = map[string]any{"Title": "API", "WebLink": "/", "Services": apiServices, "SidebarEndpoints": sidebarEndpoints, "SidebarEndpointsEnabled": true, "User": user}
   479  				apiCache.data = apiData
   480  				apiCache.time = time.Now()
   481  			}
   482  			apiCache.Unlock()
   483  			// Add API auth doc at the top
   484  			apiData["ApiAuthDoc"] = `<div style='background:#f8f8e8; border:1px solid #e0e0b0; padding:1em; margin-bottom:2em; font-size:1.08em;'>
   485  <b>API Authentication Required:</b> All API calls to <code>/api/...</code> endpoints (except this page) must include an <b>Authorization: Bearer &lt;token&gt;</b> header. <br>
   486  You can generate tokens on the <a href='/auth/tokens'>Tokens page</a>.
   487  </div>`
   488  			_ = render(w, tmpls.api, apiData)
   489  			return
   490  		}
   491  		if path == "/services" {
   492  			// Do NOT include SidebarEndpoints on this page
   493  			services, _ := registry.ListServices()
   494  			var serviceNames []string
   495  			for _, service := range services {
   496  				serviceNames = append(serviceNames, service.Name)
   497  			}
   498  			sort.Strings(serviceNames)
   499  			_ = render(w, tmpls.service, map[string]any{"Title": "Services", "WebLink": "/", "Services": serviceNames, "User": user})
   500  			return
   501  		}
   502  		if path == "/logs" || path == "/logs/" {
   503  			// Do NOT include SidebarEndpoints on this page
   504  			homeDir, err := os.UserHomeDir()
   505  			if err != nil {
   506  				w.WriteHeader(500)
   507  				w.Write([]byte("Could not get home directory"))
   508  				return
   509  			}
   510  			logsDir := homeDir + "/micro/logs"
   511  			dirEntries, err := os.ReadDir(logsDir)
   512  			if err != nil {
   513  				w.WriteHeader(500)
   514  				w.Write([]byte("Could not list logs directory: " + err.Error()))
   515  				return
   516  			}
   517  			serviceNames := []string{}
   518  			for _, entry := range dirEntries {
   519  				name := entry.Name()
   520  				if !entry.IsDir() && strings.HasSuffix(name, ".log") && !strings.HasPrefix(name, ".") {
   521  					serviceNames = append(serviceNames, strings.TrimSuffix(name, ".log"))
   522  				}
   523  			}
   524  			_ = render(w, tmpls.logs, map[string]any{"Title": "Logs", "WebLink": "/", "Services": serviceNames, "User": user})
   525  			return
   526  		}
   527  		if strings.HasPrefix(path, "/logs/") {
   528  			// Do NOT include SidebarEndpoints on this page
   529  			service := strings.TrimPrefix(path, "/logs/")
   530  			if service == "" {
   531  				w.WriteHeader(404)
   532  				w.Write([]byte("Service not specified"))
   533  				return
   534  			}
   535  			homeDir, err := os.UserHomeDir()
   536  			if err != nil {
   537  				w.WriteHeader(500)
   538  				w.Write([]byte("Could not get home directory"))
   539  				return
   540  			}
   541  			logFilePath := homeDir + "/micro/logs/" + service + ".log"
   542  			f, err := os.Open(logFilePath)
   543  			if err != nil {
   544  				w.WriteHeader(404)
   545  				w.Write([]byte("Could not open log file for service: " + service))
   546  				return
   547  			}
   548  			defer f.Close()
   549  			logBytes, err := io.ReadAll(f)
   550  			if err != nil {
   551  				w.WriteHeader(500)
   552  				w.Write([]byte("Could not read log file for service: " + service))
   553  				return
   554  			}
   555  			logText := string(logBytes)
   556  			_ = render(w, tmpls.log, map[string]any{"Title": "Logs for " + service, "WebLink": "/logs", "Service": service, "Log": logText, "User": user})
   557  			return
   558  		}
   559  		if path == "/status" {
   560  			// Do NOT include SidebarEndpoints on this page
   561  			homeDir, err := os.UserHomeDir()
   562  			if err != nil {
   563  				w.WriteHeader(500)
   564  				w.Write([]byte("Could not get home directory"))
   565  				return
   566  			}
   567  			pidDir := homeDir + "/micro/run"
   568  			dirEntries, err := os.ReadDir(pidDir)
   569  			if err != nil {
   570  				w.WriteHeader(500)
   571  				w.Write([]byte("Could not list pid directory: " + err.Error()))
   572  				return
   573  			}
   574  			statuses := []map[string]string{}
   575  			for _, entry := range dirEntries {
   576  				if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") || strings.HasPrefix(entry.Name(), ".") {
   577  					continue
   578  				}
   579  				pidFile := pidDir + "/" + entry.Name()
   580  				pidBytes, err := os.ReadFile(pidFile)
   581  				if err != nil {
   582  					statuses = append(statuses, map[string]string{
   583  						"Service": entry.Name(),
   584  						"Dir":     "-",
   585  						"Status":  "unknown",
   586  						"PID":     "-",
   587  						"Uptime":  "-",
   588  						"ID":      strings.TrimSuffix(entry.Name(), ".pid"),
   589  					})
   590  					continue
   591  				}
   592  				lines := strings.Split(string(pidBytes), "\n")
   593  				pid := "-"
   594  				dir := "-"
   595  				service := "-"
   596  				start := "-"
   597  				if len(lines) > 0 && len(lines[0]) > 0 {
   598  					pid = lines[0]
   599  				}
   600  				if len(lines) > 1 && len(lines[1]) > 0 {
   601  					dir = lines[1]
   602  				}
   603  				if len(lines) > 2 && len(lines[2]) > 0 {
   604  					service = lines[2]
   605  				}
   606  				if len(lines) > 3 && len(lines[3]) > 0 {
   607  					start = lines[3]
   608  				}
   609  				status := "stopped"
   610  				if pid != "-" {
   611  					if _, err := os.FindProcess(parsePid(pid)); err == nil {
   612  						if processRunning(pid) {
   613  							status = "running"
   614  						}
   615  					} else {
   616  						status = "stopped"
   617  					}
   618  				}
   619  				uptime := "-"
   620  				if start != "-" {
   621  					if t, err := parseStartTime(start); err == nil {
   622  						uptime = time.Since(t).Truncate(time.Second).String()
   623  					}
   624  				}
   625  				statuses = append(statuses, map[string]string{
   626  					"Service": service,
   627  					"Dir":     dir,
   628  					"Status":  status,
   629  					"PID":     pid,
   630  					"Uptime":  uptime,
   631  					"ID":      strings.TrimSuffix(entry.Name(), ".pid"),
   632  				})
   633  			}
   634  			_ = render(w, tmpls.status, map[string]any{"Title": "Service Status", "WebLink": "/", "Statuses": statuses, "User": user})
   635  			return
   636  		}
   637  		// Match /{service} and /{service}/{endpoint}
   638  		parts := strings.Split(strings.Trim(path, "/"), "/")
   639  		if len(parts) >= 1 && parts[0] != "api" && parts[0] != "html" && parts[0] != "services" {
   640  			service := parts[0]
   641  			if len(parts) == 1 {
   642  				s, err := registry.GetService(service)
   643  				if err != nil || len(s) == 0 {
   644  					w.WriteHeader(404)
   645  					w.Write([]byte(fmt.Sprintf("Service not found: %s", service)))
   646  					return
   647  				}
   648  				endpoints := []map[string]string{}
   649  				for _, ep := range s[0].Endpoints {
   650  					endpoints = append(endpoints, map[string]string{
   651  						"Name": ep.Name,
   652  						"Path": fmt.Sprintf("/%s/%s", service, ep.Name),
   653  					})
   654  				}
   655  				b, _ := json.MarshalIndent(s[0], "", "    ")
   656  				_ = render(w, tmpls.service, map[string]any{
   657  					"Title":       "Service: " + service,
   658  					"WebLink":     "/",
   659  					"ServiceName": service,
   660  					"Endpoints":   endpoints,
   661  					"Description": string(b),
   662  					"User":        user,
   663  				})
   664  				return
   665  			}
   666  			if len(parts) == 2 {
   667  				service := parts[0]
   668  				endpoint := parts[1] // Use the actual endpoint name from the URL, e.g. Foo.Bar
   669  				s, err := registry.GetService(service)
   670  				if err != nil || len(s) == 0 {
   671  					w.WriteHeader(404)
   672  					w.Write([]byte("Service not found: " + service))
   673  					return
   674  				}
   675  				var ep *registry.Endpoint
   676  				for _, eps := range s[0].Endpoints {
   677  					if eps.Name == endpoint {
   678  						ep = eps
   679  						break
   680  					}
   681  				}
   682  				if ep == nil {
   683  					w.WriteHeader(404)
   684  					w.Write([]byte("Endpoint not found"))
   685  					return
   686  				}
   687  				if r.Method == "GET" {
   688  					// Build form fields from endpoint request values
   689  					var inputs []map[string]string
   690  					if ep.Request != nil && len(ep.Request.Values) > 0 {
   691  						for _, input := range ep.Request.Values {
   692  							inputs = append(inputs, map[string]string{
   693  								"Label":       input.Name,
   694  								"Name":        input.Name,
   695  								"Placeholder": input.Name,
   696  								"Value":       "",
   697  							})
   698  						}
   699  					}
   700  					_ = render(w, tmpls.form, map[string]any{
   701  						"Title":        "Service: " + service,
   702  						"WebLink":      "/",
   703  						"ServiceName":  service,
   704  						"EndpointName": ep.Name,
   705  						"Inputs":       inputs,
   706  						"Action":       service + "/" + endpoint,
   707  						"User":         user,
   708  					})
   709  					return
   710  				}
   711  				if r.Method == "POST" {
   712  					// Parse form values into a map
   713  					var reqBody map[string]interface{}
   714  					if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
   715  						defer r.Body.Close()
   716  						json.NewDecoder(r.Body).Decode(&reqBody)
   717  					} else {
   718  						reqBody = map[string]interface{}{}
   719  						r.ParseForm()
   720  						for k, v := range r.Form {
   721  							if len(v) == 1 {
   722  								if len(v[0]) == 0 {
   723  									continue
   724  								}
   725  								reqBody[k] = v[0]
   726  							} else {
   727  								reqBody[k] = v
   728  							}
   729  						}
   730  					}
   731  					// For now, just echo the request body as JSON
   732  					w.Header().Set("Content-Type", "application/json")
   733  					b, _ := json.MarshalIndent(reqBody, "", "  ")
   734  					w.Write(b)
   735  					return
   736  				}
   737  			}
   738  		}
   739  		w.WriteHeader(404)
   740  		w.Write([]byte("Not found"))
   741  	}))
   742  	http.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
   743  		http.SetCookie(w, &http.Cookie{Name: "micro_token", Value: "", Path: "/", Expires: time.Now().Add(-1 * time.Hour), HttpOnly: true})
   744  		http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
   745  	})
   746  	http.HandleFunc("/auth/tokens", authMw(func(w http.ResponseWriter, r *http.Request) {
   747  		userID := getUser(r)
   748  		var user any
   749  		if userID != "" {
   750  			user = &TemplateUser{ID: userID}
   751  		} else {
   752  			user = nil
   753  		}
   754  		if r.Method == "POST" {
   755  			id := r.FormValue("id")
   756  			typeStr := r.FormValue("type")
   757  			scopesStr := r.FormValue("scopes")
   758  			accType := "user"
   759  			if typeStr == "admin" {
   760  				accType = "admin"
   761  			} else if typeStr == "service" {
   762  				accType = "service"
   763  			}
   764  			scopes := []string{"*"}
   765  			if scopesStr != "" {
   766  				scopes = strings.Split(scopesStr, ",")
   767  				for i := range scopes {
   768  					scopes[i] = strings.TrimSpace(scopes[i])
   769  				}
   770  			}
   771  			acc := &Account{
   772  				ID:       id,
   773  				Type:     accType,
   774  				Scopes:   scopes,
   775  				Metadata: map[string]string{"created": time.Now().Format(time.RFC3339)},
   776  			}
   777  			// Service tokens do not require a password, generate a JWT directly
   778  			tok, _ := GenerateJWT(acc.ID, acc.Type, acc.Scopes, 24*time.Hour)
   779  			acc.Metadata["token"] = tok
   780  			b, _ := json.Marshal(acc)
   781  			storeInst.Write(&store.Record{Key: "auth/" + id, Value: b})
   782  			storeJWTToken(storeInst, tok, acc.ID) // Store the JWT token
   783  			http.Redirect(w, r, "/auth/tokens", http.StatusSeeOther)
   784  			return
   785  		}
   786  		recs, _ := storeInst.Read("auth/", store.ReadPrefix())
   787  		var tokens []map[string]any
   788  		for _, rec := range recs {
   789  			var acc Account
   790  			if err := json.Unmarshal(rec.Value, &acc); err == nil {
   791  				tok := ""
   792  				if t, ok := acc.Metadata["token"]; ok {
   793  					tok = t
   794  				}
   795  				var tokenPrefix, tokenSuffix string
   796  				if len(tok) > 12 {
   797  					tokenPrefix = tok[:4]
   798  					tokenSuffix = tok[len(tok)-4:]
   799  				} else {
   800  					tokenPrefix = tok
   801  					tokenSuffix = ""
   802  				}
   803  				tokens = append(tokens, map[string]any{
   804  					"ID":          acc.ID,
   805  					"Type":        acc.Type,
   806  					"Scopes":      acc.Scopes,
   807  					"Metadata":    acc.Metadata,
   808  					"Token":       tok,
   809  					"TokenPrefix": tokenPrefix,
   810  					"TokenSuffix": tokenSuffix,
   811  				})
   812  			}
   813  		}
   814  		_ = tmpls.authTokens.Execute(w, map[string]any{"Title": "Auth Tokens", "Tokens": tokens, "User": user, "Sub": userID})
   815  	}))
   816  
   817  	http.HandleFunc("/auth/users", authMw(func(w http.ResponseWriter, r *http.Request) {
   818  		userID := getUser(r)
   819  		var user any
   820  		if userID != "" {
   821  			user = &TemplateUser{ID: userID}
   822  		} else {
   823  			user = nil
   824  		}
   825  		if r.Method == "POST" {
   826  			if del := r.FormValue("delete"); del != "" {
   827  				// Delete user
   828  				storeInst.Delete("auth/" + del)
   829  				deleteUserTokens(storeInst, del) // Delete all JWT tokens for this user
   830  				http.Redirect(w, r, "/auth/users", http.StatusSeeOther)
   831  				return
   832  			}
   833  			id := r.FormValue("id")
   834  			if id == "" {
   835  				http.Redirect(w, r, "/auth/users", http.StatusSeeOther)
   836  				return
   837  			}
   838  			pass := r.FormValue("password")
   839  			typeStr := r.FormValue("type")
   840  			accType := "user"
   841  			if typeStr == "admin" {
   842  				accType = "admin"
   843  			}
   844  			hash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
   845  			acc := &Account{
   846  				ID:       id,
   847  				Type:     accType,
   848  				Scopes:   []string{"*"},
   849  				Metadata: map[string]string{"created": time.Now().Format(time.RFC3339), "password_hash": string(hash)},
   850  			}
   851  			b, _ := json.Marshal(acc)
   852  			storeInst.Write(&store.Record{Key: "auth/" + id, Value: b})
   853  			http.Redirect(w, r, "/auth/users", http.StatusSeeOther)
   854  			return
   855  		}
   856  		recs, _ := storeInst.Read("auth/", store.ReadPrefix())
   857  		var users []Account
   858  		for _, rec := range recs {
   859  			var acc Account
   860  			if err := json.Unmarshal(rec.Value, &acc); err == nil {
   861  				if acc.Type == "user" || acc.Type == "admin" {
   862  					users = append(users, acc)
   863  				}
   864  			}
   865  		}
   866  		_ = tmpls.authUsers.Execute(w, map[string]any{"Title": "User Accounts", "Users": users, "User": user})
   867  	}))
   868  	http.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
   869  		if r.Method == "GET" {
   870  			loginTmpl, err := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")
   871  			if err != nil {
   872  				w.WriteHeader(500)
   873  				w.Write([]byte("Template error: " + err.Error()))
   874  				return
   875  			}
   876  			_ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "", "User": getUser(r), "HideSidebar": true})
   877  			return
   878  		}
   879  		if r.Method == "POST" {
   880  			id := r.FormValue("id")
   881  			pass := r.FormValue("password")
   882  			recKey := "auth/" + id
   883  			recs, _ := storeInst.Read(recKey)
   884  			if len(recs) == 0 {
   885  				loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")
   886  				_ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Invalid credentials", "User": "", "HideSidebar": true})
   887  				return
   888  			}
   889  			var acc Account
   890  			if err := json.Unmarshal(recs[0].Value, &acc); err != nil {
   891  				loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")
   892  				_ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Invalid credentials", "User": "", "HideSidebar": true})
   893  				return
   894  			}
   895  			hash, ok := acc.Metadata["password_hash"]
   896  			if !ok || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil {
   897  				loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")
   898  				_ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Invalid credentials", "User": "", "HideSidebar": true})
   899  				return
   900  			}
   901  			tok, err := GenerateJWT(acc.ID, acc.Type, acc.Scopes, 24*time.Hour)
   902  			if err != nil {
   903  				log.Printf("[LOGIN ERROR] Token generation failed: %v\nAccount: %+v", err, acc)
   904  				loginTmpl, _ := template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")
   905  				_ = loginTmpl.Execute(w, map[string]any{"Title": "Login", "Error": "Token error", "User": "", "HideSidebar": true})
   906  				return
   907  			}
   908  			storeJWTToken(storeInst, tok, acc.ID) // Store the JWT token
   909  			http.SetCookie(w, &http.Cookie{
   910  				Name:     "micro_token",
   911  				Value:    tok,
   912  				Path:     "/",
   913  				Expires:  time.Now().Add(time.Hour * 24),
   914  				HttpOnly: true,
   915  			})
   916  			http.Redirect(w, r, "/", http.StatusSeeOther)
   917  			return
   918  		}
   919  		w.WriteHeader(405)
   920  		w.Write([]byte("Method not allowed"))
   921  	})
   922  }
   923  
   924  func Run(c *cli.Context) error {
   925  	if err := initAuth(); err != nil {
   926  		log.Fatalf("Failed to initialize auth: %v", err)
   927  	}
   928  	homeDir, _ := os.UserHomeDir()
   929  	keyDir := filepath.Join(homeDir, "micro", "keys")
   930  	privPath := filepath.Join(keyDir, "private.pem")
   931  	pubPath := filepath.Join(keyDir, "public.pem")
   932  	if err := InitJWTKeys(privPath, pubPath); err != nil {
   933  		log.Fatalf("Failed to init JWT keys: %v", err)
   934  	}
   935  	storeInst := store.DefaultStore
   936  	tmpls := parseTemplates()
   937  	registerHandlers(tmpls, storeInst)
   938  	addr := c.String("address")
   939  	if addr == "" {
   940  		addr = ":8080"
   941  	}
   942  	log.Printf("[micro-server] Web/API listening on %s", addr)
   943  	if err := http.ListenAndServe(addr, nil); err != nil {
   944  		log.Fatalf("Web/API server error: %v", err)
   945  	}
   946  	return nil
   947  }
   948  
   949  // --- PID FILES ---
   950  // --- PID FILES ---
   951  func parsePid(pidStr string) int {
   952  	pid, _ := strconv.Atoi(pidStr)
   953  	return pid
   954  }
   955  func processRunning(pid string) bool {
   956  	proc, err := os.FindProcess(parsePid(pid))
   957  	if err != nil {
   958  		return false
   959  	}
   960  	// On unix, sending syscall.Signal(0) checks if process exists
   961  	return proc.Signal(syscall.Signal(0)) == nil
   962  }
   963  
   964  func generateKeyPair(bits int) (*rsa.PrivateKey, error) {
   965  	priv, err := rsa.GenerateKey(rand.Reader, bits)
   966  	if err != nil {
   967  		return nil, err
   968  	}
   969  	return priv, nil
   970  }
   971  func exportPrivateKeyAsPEM(priv *rsa.PrivateKey) ([]byte, error) {
   972  	privKeyBytes := x509.MarshalPKCS1PrivateKey(priv)
   973  	block := &pem.Block{
   974  		Type:  "RSA PRIVATE KEY",
   975  		Bytes: privKeyBytes,
   976  	}
   977  	var buf bytes.Buffer
   978  	err := pem.Encode(&buf, block)
   979  	if err != nil {
   980  		return nil, err
   981  	}
   982  	return buf.Bytes(), nil
   983  }
   984  func exportPublicKeyAsPEM(pub *rsa.PublicKey) ([]byte, error) {
   985  	pubKeyBytes := x509.MarshalPKCS1PublicKey(pub)
   986  	block := &pem.Block{
   987  		Type:  "RSA PUBLIC KEY",
   988  		Bytes: pubKeyBytes,
   989  	}
   990  	var buf bytes.Buffer
   991  	err := pem.Encode(&buf, block)
   992  	if err != nil {
   993  		return nil, err
   994  	}
   995  	return buf.Bytes(), nil
   996  }
   997  func importPrivateKeyFromPEM(privKeyPEM []byte) (*rsa.PrivateKey, error) {
   998  	block, _ := pem.Decode(privKeyPEM)
   999  	if block == nil {
  1000  		return nil, fmt.Errorf("invalid PEM block")
  1001  	}
  1002  	return x509.ParsePKCS1PrivateKey(block.Bytes)
  1003  }
  1004  func importPublicKeyFromPEM(pubKeyPEM []byte) (*rsa.PublicKey, error) {
  1005  	block, _ := pem.Decode(pubKeyPEM)
  1006  	if block == nil {
  1007  		return nil, fmt.Errorf("invalid PEM block")
  1008  	}
  1009  	return x509.ParsePKCS1PublicKey(block.Bytes)
  1010  }
  1011  func initAuth() error {
  1012  	// --- AUTH SETUP ---
  1013  	homeDir, _ := os.UserHomeDir()
  1014  	keyDir := filepath.Join(homeDir, "micro", "keys")
  1015  	privPath := filepath.Join(keyDir, "private.pem")
  1016  	pubPath := filepath.Join(keyDir, "public.pem")
  1017  	os.MkdirAll(keyDir, 0700)
  1018  	// Generate keypair if not exist
  1019  	if _, err := os.Stat(privPath); os.IsNotExist(err) {
  1020  		priv, _ := rsa.GenerateKey(rand.Reader, 2048)
  1021  		privBytes := x509.MarshalPKCS1PrivateKey(priv)
  1022  		privPem := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes})
  1023  		os.WriteFile(privPath, privPem, 0600)
  1024  		// Use PKIX format for public key
  1025  		pubBytes, _ := x509.MarshalPKIXPublicKey(&priv.PublicKey)
  1026  		pubPem := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
  1027  		os.WriteFile(pubPath, pubPem, 0644)
  1028  	}
  1029  	_, _ = os.ReadFile(privPath)
  1030  	_, _ = os.ReadFile(pubPath)
  1031  	storeInst := store.DefaultStore
  1032  	// --- Ensure default admin account exists ---
  1033  	adminID := "admin"
  1034  	adminPass := "micro"
  1035  	adminKey := "auth/" + adminID
  1036  	if recs, _ := storeInst.Read(adminKey); len(recs) == 0 {
  1037  		// Hash the admin password with bcrypt
  1038  		hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
  1039  		if err != nil {
  1040  			return err
  1041  		}
  1042  		acc := &Account{
  1043  			ID:       adminID,
  1044  			Type:     "admin",
  1045  			Scopes:   []string{"*"},
  1046  			Metadata: map[string]string{"created": time.Now().Format(time.RFC3339), "password_hash": string(hash)},
  1047  		}
  1048  		b, _ := json.Marshal(acc)
  1049  		storeInst.Write(&store.Record{Key: adminKey, Value: b})
  1050  	}
  1051  	return nil
  1052  }
  1053  
  1054  // parseStartTime parses a string as RFC3339 time
  1055  func parseStartTime(s string) (time.Time, error) {
  1056  	return time.Parse(time.RFC3339, s)
  1057  }
  1058  func init() {
  1059  	cmd.Register(&cli.Command{
  1060  		Name:   "server",
  1061  		Usage:  "Run the micro server",
  1062  		Action: Run,
  1063  		Flags: []cli.Flag{
  1064  			&cli.StringFlag{
  1065  				Name:    "address",
  1066  				Usage:   "Address to listen on",
  1067  				EnvVars: []string{"MICRO_SERVER_ADDRESS"},
  1068  				Value:   ":8080",
  1069  			},
  1070  		},
  1071  	})
  1072  }