// Command stepd provides an HTTP server that automatically executes STEP files. package main import "os" import "log" import "time" import "slices" import "errors" import "syscall" import "os/user" import "context" import "strings" import "strconv" import "net/http" import "unicode/utf8" import "path/filepath" import "git.tebibyte.media/sashakoshka/step" import "git.tebibyte.media/sashakoshka/go-cli" import "git.tebibyte.media/sashakoshka/step/providers" import "git.tebibyte.media/sashakoshka/goutil/container" import "git.tebibyte.media/sashakoshka/go-service/daemon" import "git.tebibyte.media/sashakoshka/go-service/rotate" import stephttp"git.tebibyte.media/sashakoshka/step/http" import "git.tebibyte.media/sashakoshka/go-service/routines" const configFileName = "step.meta" func main () { // parse command line arguments flagPidFile := cli.NewInputFlag ( 'p', "pid-file", "Write the PID to the specified file", "", cli.ValString) flagUser := cli.NewInputFlag ( 'u', "user", "The user:group to run as", "", cli.ValString) flagLogDirectory := cli.NewInputFlag ( 'l', "log-directory", "Write logs to the specified directory", "", cli.ValString) flagHTTPAddress := cli.NewInputFlag ( 'h', "http-address", "The address to host the HTTP server on", ":8080", cli.ValString) flagHTTPErrorDocument := cli.NewInputFlag ( 0, "http-error-document", "The document to use for displaying errors over http", "", cli.ValString) flagHTTPDirectoryDocument := cli.NewInputFlag ( 0, "http-directory-document", "The document to use for displaying directory listings over http", "", cli.ValString) flagHTTPRateLimit := cli.NewInputFlag ( 0, "http-rate-limit", "Seconds an HTTP client must wait per request", "", cli.ValString) flagDirectories := cli.NewFlag ( 'd', "directories", "Serve the contents of directories") flagUnsafePlugins := cli.NewFlag ( 0, "unsafe-plugins", "Load plugins not owned by the root user") cmd := cli.New ( "Run an HTTP server that automaticaly executes STEP files", flagPidFile, flagUser, flagLogDirectory, flagHTTPAddress, flagHTTPErrorDocument, flagHTTPDirectoryDocument, flagDirectories, flagUnsafePlugins, cli.NewHelp()) cmd.Syntax = "[OPTIONS]... [DIRECTORY]" cmd.ParseOrExit(os.Args) // get env variables pluginPath := filepath.SplitList(os.Getenv("STEP_PLUGIN_PATH")) if len(pluginPath) == 0 { pluginPath = []string { "/usr/lib/step/plugins", "/usr/local/lib/step/plugins", } } for index, pat := range pluginPath { pluginPath[index], _ = filepath.Abs(pat) } // manage start and end of program ctx, done := context.WithCancel(context.Background()) defer done() daemon.OnSigint(done) if flagPidFile.Value != "" { pidFileAbs, err := filepath.Abs(flagPidFile.Value) if err != nil { log.Fatalln("XXX", err) } pidFile := daemon.PidFile(pidFileAbs) err = pidFile.Start() if err != nil { log.Fatalln("XXX could not write pid:", err) } } // drop privelege if flagUser.Value != "" { log.Println("... dropping privelege to", flagUser.Value) user, group, _ := strings.Cut(flagUser.Value, ":") err := dropPrivelege(user, group) if err != nil { log.Fatalln("XXX could not drop privelege:", err) } } // set up logging if flagLogDirectory.Value != "" { directory := flagLogDirectory.Value log.Println("(i) logging to", directory) directory, err := filepath.Abs(directory) if err != nil { log.Fatalln("XXX", err) } logger, err := rotate.New(directory) if err != nil { log.Fatalln("XXX", err) } defer logger.Close() log.SetOutput(logger) } log.Println(`==========| STEP |===========`) log.Println(`Scriptable Template Processor`) log.Println(`... initializing`) // the single argument is for the directory to serve. we actually cd // there. if len(cmd.Args) == 1 { err := os.Chdir(cmd.Args[0]) if err != nil { log.Fatalln("XXX", err) } } else if len(cmd.Args) > 1 { cmd.Usage() os.Exit(1) } // read the config file var config step.Meta if configFile, err := os.Open(configFileName); err == nil { defer configFile.Close() config, err = step.DecodeMeta(configFile) configFile.Close() if err != nil { log.Fatalln("XXX", err) } } else { config = make(step.Meta) log.Printf ( "(i) could not open %s, using default configuration", configFileName) } // override the config file with explicitly specified options if flagHTTPAddress.Value != "" { config.Set("http.address", flagHTTPAddress.Value) } if flagHTTPErrorDocument.Value != "" { config.Set("http.error-document", flagHTTPErrorDocument.Value) } if flagHTTPRateLimit.Value != "" { config.Set("http.rate-limit", flagHTTPRateLimit.Value) } if flagDirectories.Value != "" { config.Set("http.serve-directories", flagDirectories.Value) } // set up the environment environment := step.Environment { Config: config, } environment.Providers = providers.All() // load plugins var err error var plugins []step.Provider if flagUnsafePlugins.Value == "true" { plugins, err = step.LoadAllProviderPluginsUnsafe(pluginPath...) } else { plugins, err = step.LoadAllProviderPlugins(pluginPath...) } if err != nil { if errs, ok := err.(step.Errors); ok { for _, err := range errs.Unwrap() { log.Println("!!!", err) } } else { log.Println("!!!", err) } } environment.Providers = append(environment.Providers, plugins...) logProviders(environment.Providers) // initialize the environment err = environment.Init(context.Background()) if err != nil { log.Fatal("XXX:", err) } // set up the HTTP handler rateLimit := 0.0 if rateLimitStr := config.Get("http.rate-limit"); rateLimitStr != "" { rateLimit, err = strconv.ParseFloat(rateLimitStr, 64) if err != nil { log.Fatal("XXX bad value for rate limit", err) } } handler := stephttp.Handler { Environment: &environment, Directories: config.Get("http.serve-directories") == "true", StepExt: ucontainer.NewSet(slices.Clone(config["http.step-extension"])...), Index: slices.Clone(config["http.index-file"]), ErrorDocument: config.Get("http.error-document"), DirectoryDocument: config.Get("http.directory-document"), DenyAll: ucontainer.NewSet(configFileName), RateLimit: time.Duration(rateLimit * float64(time.Second)), TrustXForwardedFor: config.Get("http.trust-x-forwarded-for") == "true", TrustCFConnectingIP: config.Get("http.trust-cf-connecting-ip") == "true", } if len(handler.StepExt) == 0 { handler.StepExt.Add(".step") } if len(handler.Index) == 0 { handler.Index = []string { "index.step", "index.html", "index" } } err = handler.Init(ctx) if err != nil { log.Println("XXX", err) } // set up the HTTP server httpServer := httpServerRoutine { Addr: config.Get("http.address"), Handler: &handler, } // set up the trimming routine trimmer := trimmerRoutine { Trimmers: []step.Trimmer { &handler, }, } // set up the routine manager manager := routines.Manager { Routines: []routines.Routine { &httpServer, &trimmer, }, } log.Println(`.// initialized.`) log.Printf("(i) listening on %s\n", httpServer.Addr) if err := manager.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { log.Fatalln("XXX", err) } } type httpServerRoutine http.Server func (this *httpServerRoutine) Run (ctx context.Context) error { ctx, done := context.WithCancel(ctx) defer done() server := http.Server(*this) go func () { <- ctx.Done() shutdownCtx, done := context.WithTimeout ( context.Background(), 16 * time.Second) defer done() server.Shutdown(shutdownCtx) } () err := server.ListenAndServe() if ctx.Err() != nil { return ctx.Err() } return err } type trimmerRoutine struct { Trimmers []step.Trimmer } func (this *trimmerRoutine) Run (ctx context.Context) error { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <- ticker.C: for _, trimmer := range this.Trimmers { trimmer.Trim() } case <- ctx.Done(): return ctx.Err() } } } func logProviders (providers []step.Provider) { output := "providers: " x := utf8.RuneCountInString(output) first := true line := func () { if output == "" { return } if first { first = false log.Println("(i)", output) } else { log.Println(" ", output) } output = "" x = 0 } for index, provider := range providers { packag := provider.Package() if index != len(providers) - 1 { packag += ", " x += 2 } packageLen := utf8.RuneCountInString(packag) if x + packageLen >= 60 { line() } output += packag x += packageLen } line() } func dropPrivelege (usr, group string) error { if group != "" { groupInfo, err := user.LookupGroup(group) if err != nil { return err } gid, err := strconv.Atoi(groupInfo.Gid) if err != nil { return err } err = syscall.Setgid(gid) if err != nil { return err } } if usr != "" { usrInfo, err := user.Lookup(usr) if err != nil { return err } uid, err := strconv.Atoi(usrInfo.Uid) if err != nil { return err } err = syscall.Setuid(uid) if err != nil { return err } } return nil }