Files
2026-10-30 19:04:26 -04:00

656 lines
25 KiB
Go

package main
import (
"bufio"
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
flag "github.com/spf13/pflag"
"github.com/BishopFox/joro/internal/api"
"github.com/BishopFox/joro/internal/automation"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/config"
"github.com/BishopFox/joro/internal/jsruntime"
"github.com/BishopFox/joro/internal/notes"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/team"
"github.com/BishopFox/joro/internal/update"
"github.com/BishopFox/joro/internal/xsshunter"
)
var version = "v1.18.3"
var commit = "dev" // injected via -ldflags at build time
func main() {
cfg := config.Default()
flag.IntVar(&cfg.ProxyPort, "proxy-port", cfg.ProxyPort, "Port for the intercepting proxy")
flag.IntVar(&cfg.UIPort, "ui-port", cfg.UIPort, "Port for the web UI and API")
flag.StringVar(&cfg.DataDir, "data-dir", cfg.DataDir, "Directory for CA certs and data")
flag.BoolVar(&cfg.Dev, "dev", false, "Reverse-proxy UI requests to Vite dev server (enables HMR)")
flag.StringVar(&cfg.ViteURL, "vite-url", cfg.ViteURL, "Vite dev server URL (used with --dev)")
flag.BoolVar(&cfg.Listener, "listener", false, "Run in listener mode (callback server, no proxy)")
flag.IntVar(&cfg.CallbackDNSPort, "dns-port", cfg.CallbackDNSPort, "DNS listener port (listener mode)")
flag.IntVar(&cfg.CallbackHTTPPort, "http-port", cfg.CallbackHTTPPort, "HTTP callback listener port (listener mode)")
flag.IntVar(&cfg.CallbackHTTPSPort, "https-port", cfg.CallbackHTTPSPort, "HTTPS callback listener port (listener mode, 0 to disable)")
flag.IntVar(&cfg.CallbackSMTPPort, "smtp-port", cfg.CallbackSMTPPort, "SMTP callback listener port (listener mode, 0 to disable)")
flag.IntVar(&cfg.CallbackSMTPSPort, "smtps-port", cfg.CallbackSMTPSPort, "SMTPS (implicit TLS) callback listener port (listener mode, 0 to disable)")
flag.IntVar(&cfg.CallbackFTPPort, "ftp-port", cfg.CallbackFTPPort, "FTP callback listener port (listener mode, 0 to disable)")
flag.IntVar(&cfg.CallbackFTPSPort, "ftps-port", cfg.CallbackFTPSPort, "FTPS (implicit TLS) callback listener port (listener mode, 0 to disable)")
flag.IntVar(&cfg.CallbackLDAPPort, "ldap-port", cfg.CallbackLDAPPort, "LDAP callback listener port (listener mode, 0 to disable)")
flag.IntVar(&cfg.CallbackLDAPSPort, "ldaps-port", cfg.CallbackLDAPSPort, "LDAPS (implicit TLS) callback listener port (listener mode, 0 to disable)")
flag.StringVar(&cfg.TLSCertFile, "tls-cert", cfg.TLSCertFile, "Path to PEM-encoded TLS certificate for the HTTPS/SMTPS/FTPS/LDAPS callback listeners (listener mode). If set, replaces the auto-generated self-signed cert. Must be set together with --tls-key.")
flag.StringVar(&cfg.TLSKeyFile, "tls-key", cfg.TLSKeyFile, "Path to PEM-encoded TLS private key for the HTTPS/SMTPS/FTPS/LDAPS callback listeners (listener mode). Must be set together with --tls-cert.")
flag.StringVar(&cfg.CallbackDomain, "domain", cfg.CallbackDomain, "Callback domain (listener mode)")
flag.StringVar(&cfg.CallbackResponseIP, "response-ip", cfg.CallbackResponseIP, "IP address returned in DNS A responses (listener mode)")
flag.StringVar(&cfg.BindAddr, "bind", cfg.BindAddr, "Address to bind servers to (in proxy mode this governs the proxy port only; the UI/API is always loopback-only)")
flag.StringSliceVar(&cfg.AllowedHosts, "allowed-host", cfg.AllowedHosts, "Additional Host header value(s) accepted by the proxy-mode UI/API origin guard, beyond loopback (e.g. an SSH tunnel entry address). Comma-separated or repeatable. Same-origin CSRF checks still apply.")
flag.BoolVar(&cfg.TeamServer, "teamserver", false, "Enable team server mode (requires --listener)")
flag.BoolVar(&cfg.DisableUpdateChecks, "disable-update-checks", false, "Disable automatic update checks at startup and in the background (can also be toggled in Settings)")
flag.BoolVar(&cfg.NoAutomation, "no-automation", false, "Disable the automation API and MCP listener entirely (no routes, no token file, no second port)")
flag.BoolVar(&cfg.AutomationPrivileged, "automation-privileged", false, "Expose web shell execution and Sliver/Mythic C2 as automation capabilities (off by default; still requires an explicit per-capability grant)")
flag.BoolVar(&cfg.AutomationScripting, "automation-scripting", false, "Expose script.run, which executes submitted JavaScript in a sandboxed worker process against Joro's automation SDK (off by default; still requires an explicit grant)")
flag.BoolVar(&cfg.AutomationCommands, "automation-commands", false, "Allow installed command automations to run local operating-system commands, on a trigger or as a lens (off by default; operator-installed only, never reachable from an automation token)")
flag.BoolVar(&cfg.NoWebhooks, "no-webhooks", false, "Disable outbound webhooks entirely (no routes, no webhooks.json, no outbound requests)")
flag.BoolVar(&cfg.NoFeeds, "no-feeds", false, "Disable threat-intelligence feeds entirely (no routes, no feeds.json, no outbound requests to NVD/CISA)")
flag.BoolVar(&cfg.NoPlugins, "no-plugins", false, "Do not load plugins from ~/.joro/plugins/. They are still listed in Settings so one can be deleted — use this to start after a plugin the binary cannot load")
// scriptWorker is how a script sandbox re-execs this binary. Not an operator
// switch: it reads a job from stdin and speaks a private protocol on stdout.
scriptWorker := flag.Bool("script-worker", false, "Internal: run as a script sandbox worker")
_ = flag.CommandLine.MarkHidden("script-worker")
buildPlugin := flag.String("build-plugin", "", "Build a plugin from the given directory and exit")
installPlugin := flag.Bool("install", false, "Copy built plugin to ~/.joro/plugins/ (use with --build-plugin)")
outputPath := flag.StringP("output", "o", "", "Output path for built plugin (use with --build-plugin)")
showVersion := flag.BoolP("version", "v", false, "Print version and exit")
flag.Parse()
// Worker mode is checked before anything else can write to stdout, which is the
// parent's protocol channel and is corrupted by a single stray byte. It also
// exits before any of the ordinary startup below: a worker binds no port, opens
// no database, and reads no CA.
if *scriptWorker {
os.Exit(runScriptWorker())
}
// Listener mode defaults to 0.0.0.0 (needs external callbacks) unless --bind was explicitly set.
if cfg.Listener && !flag.CommandLine.Changed("bind") {
cfg.BindAddr = "0.0.0.0"
}
if cfg.BindAddr != "127.0.0.1" && cfg.BindAddr != "localhost" && cfg.BindAddr != "::1" {
what := "servers"
if !cfg.Listener {
// Proxy mode forces the UI/API to loopback; only the proxy port honors --bind.
what = "the proxy port"
}
fmt.Fprintf(os.Stderr, "WARNING: Binding to %s — %s will be accessible from the network.\n", cfg.BindAddr, what)
}
if *showVersion {
fmt.Printf("%s (%s)\n", version, commit)
os.Exit(0)
}
if *buildPlugin != "" {
os.Exit(runBuildPlugin(*buildPlugin, *outputPath, *installPlugin, cfg.DataDir))
}
ctx := context.Background()
if cfg.TeamServer && !cfg.Listener {
fmt.Fprintln(os.Stderr, "error: --teamserver requires --listener")
os.Exit(1)
}
if cfg.Listener {
runListenerMode(ctx, cfg)
} else {
runProxyMode(ctx, cfg)
}
}
// runScriptWorker is the child half of the script sandbox.
//
// The parent re-execs this binary with --script-worker, writes one job to stdin, and
// answers the capability calls the worker asks for. The worker holds no Joro state at
// all: it links a JavaScript engine and a pipe, so there is no capture store, token
// file or HTTP client in the process for a script to reach even in principle.
//
// It exists because goja has no memory ceiling. A script that allocates without bound
// has to kill something, and in a process holding an engagement's captured traffic that
// something must not be Joro.
func runScriptWorker() int {
// stdout carries the protocol. log already writes to stderr, but say so here
// rather than relying on it: one line on the wrong stream breaks every run.
log.SetOutput(os.Stderr)
log.SetFlags(0)
if err := jsruntime.RunWorker(context.Background(), os.Stdin, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "script worker:", err)
return 1
}
return 0
}
func runListenerMode(ctx context.Context, cfg config.Config) {
// Ensure data directory exists.
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
log.Fatalf("data dir: %v", err)
}
dbPath := filepath.Join(cfg.DataDir, "callbacks.db")
db, err := callback.OpenDB(dbPath)
if err != nil {
log.Fatalf("callback DB: %v", err)
}
defer db.Close()
cbStore := callback.NewStore(db)
xssStore := xsshunter.NewStore(db)
// Set config from flags (always overrides DB values).
if cfg.CallbackDomain != "" {
responseIP := cfg.CallbackResponseIP
if responseIP == "" {
responseIP = "127.0.0.1"
}
cbStore.SetConfig(&callback.CallbackConfig{ //nolint:errcheck
Domain: cfg.CallbackDomain,
ResponseIP: responseIP,
})
}
hub := api.NewHub()
go hub.Run()
// Start DNS server.
dnsSrv := callback.NewDNSServer(cbStore, hub.Broadcast(), cfg.BindAddr, cfg.CallbackDNSPort)
go func() {
fmt.Printf("DNS callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackDNSPort)
if err := dnsSrv.Start(ctx); err != nil {
log.Printf("DNS server: %v", err)
}
}()
// Build shared TLS config if any TLS-capable listener is enabled.
needsTLS := cfg.CallbackHTTPSPort > 0 || cfg.CallbackSMTPSPort > 0 ||
cfg.CallbackFTPSPort > 0 || cfg.CallbackLDAPSPort > 0 ||
cfg.TLSCertFile != "" || cfg.TLSKeyFile != ""
var tlsCfg *tls.Config
if needsTLS {
tlsCfg = buildCallbackTLSConfig(cfg)
}
if cfg.CallbackSMTPSPort > 0 && tlsCfg == nil {
log.Fatalf("--smtps-port requires TLS — supply --tls-cert/--tls-key or enable --https-port")
}
if cfg.CallbackFTPSPort > 0 && tlsCfg == nil {
log.Fatalf("--ftps-port requires TLS — supply --tls-cert/--tls-key or enable --https-port")
}
if cfg.CallbackLDAPSPort > 0 && tlsCfg == nil {
log.Fatalf("--ldaps-port requires TLS — supply --tls-cert/--tls-key or enable --https-port")
}
// Start HTTP callback server.
httpSrv := callback.NewHTTPServer(cbStore, xssStore, hub.Broadcast(), cfg.BindAddr, cfg.CallbackHTTPPort)
if cfg.CallbackHTTPSPort > 0 {
httpSrv.WithTLS(tlsCfg, cfg.CallbackHTTPSPort)
}
go func() {
fmt.Printf("HTTP callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackHTTPPort)
if cfg.CallbackHTTPSPort > 0 {
fmt.Printf("HTTPS callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackHTTPSPort)
}
if err := httpSrv.Start(ctx); err != nil {
log.Printf("HTTP(S) callback server: %v", err)
}
}()
// Start SMTP callback server.
if cfg.CallbackSMTPPort > 0 || cfg.CallbackSMTPSPort > 0 {
smtpSrv := callback.NewSMTPServer(cbStore, hub.Broadcast(), cfg.BindAddr,
cfg.CallbackSMTPPort, cfg.CallbackSMTPSPort, tlsCfg)
go func() {
if cfg.CallbackSMTPPort > 0 {
fmt.Printf("SMTP callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackSMTPPort)
}
if cfg.CallbackSMTPSPort > 0 {
fmt.Printf("SMTPS callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackSMTPSPort)
}
if err := smtpSrv.Start(ctx); err != nil {
log.Printf("SMTP server: %v", err)
}
}()
}
// Start FTP callback server.
if cfg.CallbackFTPPort > 0 || cfg.CallbackFTPSPort > 0 {
ftpSrv := callback.NewFTPServer(cbStore, hub.Broadcast(), cfg.BindAddr,
cfg.CallbackFTPPort, cfg.CallbackFTPSPort, tlsCfg)
go func() {
if cfg.CallbackFTPPort > 0 {
fmt.Printf("FTP callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackFTPPort)
}
if cfg.CallbackFTPSPort > 0 {
fmt.Printf("FTPS callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackFTPSPort)
}
if err := ftpSrv.Start(ctx); err != nil {
log.Printf("FTP server: %v", err)
}
}()
}
// Start LDAP callback server.
if cfg.CallbackLDAPPort > 0 || cfg.CallbackLDAPSPort > 0 {
ldapSrv := callback.NewLDAPServer(cbStore, hub.Broadcast(), cfg.BindAddr,
cfg.CallbackLDAPPort, cfg.CallbackLDAPSPort, tlsCfg)
go func() {
if cfg.CallbackLDAPPort > 0 {
fmt.Printf("LDAP callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackLDAPPort)
}
if cfg.CallbackLDAPSPort > 0 {
fmt.Printf("LDAPS callback listener on %s:%d\n", cfg.BindAddr, cfg.CallbackLDAPSPort)
}
if err := ldapSrv.Start(ctx); err != nil {
log.Printf("LDAP server: %v", err)
}
}()
}
// Generate auth token for listener API access.
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
log.Fatalf("generate auth token: %v", err)
}
token := hex.EncodeToString(tokenBytes)
fmt.Printf("[LISTENER] Auth token: %s\n", token)
// Start API server.
var apiSrv *api.APIServer
if cfg.TeamServer {
if err := team.MigrateDB(db); err != nil {
log.Fatalf("team DB migration: %v", err)
}
teamStore := team.NewStore(db)
apiSrv = api.NewTeamServerMode(cfg, cbStore, xssStore, hub, teamStore, token)
} else {
apiSrv = api.NewListenerMode(cfg, cbStore, xssStore, hub, token)
}
fmt.Printf("Callback API available at http://%s:%d\n", cfg.BindAddr, cfg.UIPort)
if err := apiSrv.Start(ctx); err != nil {
log.Fatalf("API server: %v", err)
}
}
// buildCallbackTLSConfig returns the TLS config shared by all callback
// listeners (HTTPS, SMTPS, STARTTLS). External cert+key via --tls-cert/--tls-key
// takes precedence; otherwise a leaf is generated from Joro's CA.
func buildCallbackTLSConfig(cfg config.Config) *tls.Config {
switch {
case cfg.TLSCertFile != "" && cfg.TLSKeyFile != "":
extCert, err := tls.LoadX509KeyPair(cfg.TLSCertFile, cfg.TLSKeyFile)
if err != nil {
log.Fatalf("load external TLS cert: %v", err)
}
fmt.Printf("Using external TLS cert: %s\n", cfg.TLSCertFile)
return &tls.Config{Certificates: []tls.Certificate{extCert}}
case cfg.TLSCertFile != "" || cfg.TLSKeyFile != "":
log.Fatalf("--tls-cert and --tls-key must be set together")
return nil
default:
ca, err := cert.LoadOrCreate(cfg.DataDir)
if err != nil {
log.Fatalf("CA init: %v", err)
}
names := []string{"localhost"}
if cfg.CallbackDomain != "" {
names = []string{cfg.CallbackDomain, "*." + cfg.CallbackDomain}
}
leafCert, err := cert.GenerateLeafMulti(ca, names)
if err != nil {
log.Fatalf("callback TLS cert: %v", err)
}
return &tls.Config{Certificates: []tls.Certificate{*leafCert}}
}
}
func runProxyMode(ctx context.Context, cfg config.Config) {
// Check for updates (proxy mode only, unless disabled).
var updateAvailable bool
var latestVersion string
if !cfg.DisableUpdateChecks {
fmt.Println("Checking for updates...")
latestVersion, updateAvailable = update.CheckForUpdate(version)
if updateAvailable {
// Skip the prompt if stdin is redirected (pipe, /dev/null, service
// manager) or if we're in the background process group — reading the
// controlling tty from a background group delivers SIGTTIN and stops
// the process, which would leave the proxy and UI unstarted.
stat, err := os.Stdin.Stat()
if err != nil || (stat.Mode()&os.ModeCharDevice) == 0 || !isForeground() {
fmt.Printf("An update is available (latest: %s). Run interactively to update, or use the web UI.\n", latestVersion)
} else {
fmt.Printf("An update is available (latest: %s). Update now? [Y/n] ", latestVersion)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "" || answer == "y" || answer == "yes" {
if err := update.RunUpdate(func(msg string) { fmt.Println(msg) }); err != nil {
log.Printf("Update failed: %v", err)
} else {
if notice := plugins.RebuildNotice(filepath.Join(cfg.DataDir, "plugins")); notice != "" {
fmt.Println(notice)
}
fmt.Println("Restarting...")
if err := update.Restart(); err != nil {
log.Fatalf("Restart failed: %v", err)
}
}
}
}
} else {
fmt.Println("Already up to date.")
}
}
// Ensure data directory exists.
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
log.Fatalf("data dir: %v", err)
}
// Load or generate the CA certificate.
ca, err := cert.LoadOrCreate(cfg.DataDir)
if err != nil {
log.Fatalf("CA init: %v", err)
}
// Open local DB for notes.
localDB, err := notes.OpenDB(filepath.Join(cfg.DataDir, "joro.db"))
if err != nil {
log.Fatalf("local DB: %v", err)
}
defer localDB.Close()
noteStore := notes.NewStore(localDB)
// Shared components.
certCache := cert.NewCache(ca)
store := proxy.NewStore(5000)
interceptQ := proxy.NewInterceptQueue(0) // default 60s timeout
scope := proxy.NewScope()
noise := proxy.NewNoiseFilter()
replace := proxy.NewMatchReplace()
customData := proxy.NewCustomData()
transportCfg := proxy.NewTransportConfig()
wsStore := proxy.NewWSStore(10000)
hub := api.NewHub()
go hub.Run()
// Load plugins from ~/.joro/plugins/. Under --no-plugins they are listed but
// never opened, which is the only way back from a plugin that stops the binary
// booting at all.
pluginMgr := plugins.NewManager(filepath.Join(cfg.DataDir, "plugins"), hub.Broadcast())
var pluginErr error
if cfg.NoPlugins {
pluginErr = pluginMgr.StartDisabled()
} else {
pluginErr = pluginMgr.Start(ctx)
}
if pluginErr != nil {
log.Printf("plugin manager: %v", pluginErr)
}
defer pluginMgr.Shutdown()
ctx, cancel := context.WithCancel(ctx)
proxyHandler := proxy.NewHandler(certCache, store, interceptQ, scope, noise, replace, customData, transportCfg, wsStore, hub.Broadcast())
if len(pluginMgr.ProxyHooks()) > 0 {
proxyHandler.SetHookRunner(pluginMgr)
}
proxyHandler.SetSelfAddr(cfg.BindAddr, cfg.ProxyPort)
proxySrv := proxy.NewServer(cfg.BindAddr, cfg.ProxyPort, proxyHandler)
apiSrv := api.New(cfg, store, interceptQ, scope, noise, replace, customData, transportCfg, wsStore, ca, hub, noteStore, pluginMgr, api.BuildInfo{
Version: version,
Commit: commit,
UpdateAvailable: updateAvailable,
LatestVersion: latestVersion,
}, cancel)
// Automation: bearer tokens with fine-grained capability grants, consumed by
// the MCP listener. Must be installed before Start, which registers the
// /api/v1/automation routes only when a store is present.
//
// A failure here is non-fatal and disables the feature rather than the proxy:
// an unreadable token file should not stop an operator from testing. It is
// logged loudly because a silently absent token store would look to the
// operator like their tokens had been revoked.
if !cfg.NoAutomation {
autoStore, err := automation.NewStore(filepath.Join(cfg.DataDir, "automation.json"))
if err != nil {
log.Printf("automation: %v — automation and the MCP listener are disabled this run", err)
} else {
apiSrv.SetAutomation(autoStore)
}
}
// Periodically save the active project when auto-save is enabled.
apiSrv.StartAutoSaveLoop(ctx)
apiSrv.StartDetectLoop(ctx)
apiSrv.StartAnomalyLoop(ctx)
apiSrv.StartEchoLoop(ctx)
apiSrv.StartTechLoop(ctx)
apiSrv.StartOASTLoop(ctx)
// Start proxy server.
proxyDone := make(chan struct{})
go func() {
defer close(proxyDone)
fmt.Printf("Proxy listening on %s:%d\n", cfg.BindAddr, cfg.ProxyPort)
if err := proxySrv.Start(ctx); err != nil {
log.Printf("proxy server: %v", err)
}
}()
// Start API + UI server (blocks).
// The proxy-mode UI/API always binds to loopback regardless of --bind.
fmt.Printf("UI available at http://127.0.0.1:%d\n", cfg.UIPort)
fmt.Printf("CA cert: %s/ca.crt (import into browser/OS trust store)\n", cfg.DataDir)
if err := apiSrv.Start(ctx); err != nil {
log.Fatalf("API server: %v", err)
}
// If the API server was shut down for a restart (update), re-exec.
if apiSrv.RestartRequested() {
// Wait for proxy to finish shutting down so the port is free.
<-proxyDone
if err := update.Restart(); err != nil {
log.Fatalf("Restart failed: %v", err)
}
}
}
// hostPluginBuildFlags returns build flags + env vars that must match the
// host binary so Go's plugin loader will accept the resulting .so/.dylib.
// Derived from runtime/debug.BuildInfo of the running binary. Without this,
// a host built with `-trimpath -tags netgo,osusergo` (release config) and a
// plugin built with bare `go build -buildmode=plugin` produce different
// stdlib package hashes, and dlopen rejects the plugin with
// "plugin was built with a different version of package internal/goarch".
func hostPluginBuildFlags() (flags []string, env []string) {
info, ok := debug.ReadBuildInfo()
if !ok {
return nil, nil
}
var tags string
for _, s := range info.Settings {
switch s.Key {
case "-trimpath":
if s.Value == "true" {
flags = append(flags, "-trimpath")
}
case "-tags":
tags = s.Value
case "CGO_ENABLED":
env = append(env, "CGO_ENABLED="+s.Value)
case "GOARM64":
env = append(env, "GOARM64="+s.Value)
case "GOAMD64":
env = append(env, "GOAMD64="+s.Value)
}
}
if tags != "" {
flags = append(flags, "-tags", tags)
}
return flags, env
}
// runBuildPlugin compiles a Go plugin from srcDir. Returns an exit code.
func runBuildPlugin(srcDir, output string, install bool, dataDir string) int {
// Go's plugin package and -buildmode=plugin are not supported on Windows.
if runtime.GOOS == "windows" {
fmt.Fprintln(os.Stderr, "error: plugins are not supported on Windows (Go's plugin package is Linux/macOS/FreeBSD only)")
return 1
}
srcDir, err := filepath.Abs(srcDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
info, err := os.Stat(srcDir)
if err != nil || !info.IsDir() {
fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", srcDir)
return 1
}
// Determine output filename.
base := filepath.Base(srcDir)
soName := base + ".so"
if runtime.GOOS == "darwin" {
soName = base + ".dylib"
}
outPath := filepath.Join(srcDir, soName)
if output != "" {
outPath, _ = filepath.Abs(output)
}
hostFlags, hostEnv := hostPluginBuildFlags()
fmt.Printf("Building plugin from %s\n", srcDir)
fmt.Printf(" Go version: %s\n", runtime.Version())
fmt.Printf(" OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Printf(" Output: %s\n", outPath)
if len(hostFlags) > 0 {
fmt.Printf(" Flags: %s\n", strings.Join(hostFlags, " "))
}
if len(hostEnv) > 0 {
fmt.Printf(" Env: %s\n", strings.Join(hostEnv, " "))
}
// Go's plugin loader rejects .so files built with a different Go toolchain
// version than the host binary, so building one is producing an artifact that
// cannot load — and an unloadable plugin left in ~/.joro/plugins/ is what
// stops the host booting. Refuse rather than warn: the file not existing is
// strictly better than the file existing and being installed.
// Run `go version` from srcDir so the plugin's go.mod (and any
// auto-toolchain directive) influences which version is reported.
if v, ok := localGoVersion(srcDir); ok && v != runtime.Version() {
fmt.Fprintf(os.Stderr, "error: local Go toolchain (%s) differs from this binary's Go version (%s).\n", v, runtime.Version())
fmt.Fprintf(os.Stderr, " The built plugin would not load. Match versions or rebuild joro from source.\n")
return 1
}
// Run go build -buildmode=plugin, forwarding the host's ABI-relevant
// flags + env (-trimpath, -tags, GOARM64, ...) so the plugin's stdlib
// hashes match and dlopen accepts it.
args := []string{"build", "-buildmode=plugin"}
args = append(args, hostFlags...)
args = append(args, "-o", outPath, ".")
cmd := exec.Command("go", args...)
cmd.Dir = srcDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if len(hostEnv) > 0 {
cmd.Env = append(os.Environ(), hostEnv...)
}
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "build failed: %v\n", err)
return 1
}
fmt.Printf(" Build successful\n")
// Optionally install to ~/.joro/plugins/.
if install {
pluginDir := filepath.Join(dataDir, "plugins")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
fmt.Fprintf(os.Stderr, "error creating plugin dir: %v\n", err)
return 1
}
destPath := filepath.Join(pluginDir, soName)
src, err := os.Open(outPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading built plugin: %v\n", err)
return 1
}
defer src.Close()
dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
fmt.Fprintf(os.Stderr, "error writing to plugin dir: %v\n", err)
return 1
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
fmt.Fprintf(os.Stderr, "error copying plugin: %v\n", err)
return 1
}
fmt.Printf(" Installed to %s\n", destPath)
fmt.Println(" Restart Joro to load the plugin")
}
return 0
}
// localGoVersion returns the version reported by `go version` when run from
// dir (e.g. "go1.25.0"). Running in dir lets a project-local go.mod with a
// `go` directive trigger Go's auto-toolchain download, so the reported
// version matches what `go build` will actually use. Returns ok=false if
// `go` is missing or the output is unparseable.
func localGoVersion(dir string) (string, bool) {
cmd := exec.Command("go", "version")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", false
}
fields := strings.Fields(string(out))
if len(fields) < 3 {
return "", false
}
return fields[2], true
}