package main import ( "crypto/sha256" "embed" "flag" "fmt" "io/fs" "log" "net/http" "os" "strconv" "strings" "github.com/bumi/lnme/ln" "github.com/bumi/lnme/lnurl" "github.com/didip/tollbooth/v6" "github.com/didip/tollbooth/v6/limiter" "github.com/knadh/koanf" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/basicflag" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) const DEFAULT_LISTEN = ":1323" // Middleware for request limited to prevent too many requests // TODO: move to file func LimitMiddleware(lmt *limiter.Limiter) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return echo.HandlerFunc(func(c echo.Context) error { httpError := tollbooth.LimitByRequest(lmt, c.Response(), c.Request()) if httpError != nil { return c.String(httpError.StatusCode, httpError.Message) } return next(c) }) } } var stdOutLogger = log.New(os.Stdout, "", log.LstdFlags) type Invoice struct { Value int64 `json:"value"` Memo string `json:"memo"` } //go:embed files/assets/* var embeddedAssets embed.FS //go:embed files/root/index.html var indexPage string func main() { cfg := LoadConfig() e := echo.New() // Serve static files if configured if cfg.String("static-path") != "" { e.Static("/", cfg.String("static-path")) // Serve default page } else if !cfg.Bool("disable-website") { stdOutLogger.Print("Running embedded page") e.GET("/", func(c echo.Context) error { return c.HTML(200, indexPage) }) } assetSubdir, err := fs.Sub(embeddedAssets, "files/assets") if err != nil { log.Fatal(err) } // Embed static files and serve those on /lnme (e.g. /lnme/lnme.js) assetHandler := http.FileServer(http.FS(assetSubdir)) e.GET("/lnme/*", echo.WrapHandler(http.StripPrefix("/lnme/", assetHandler))) // CORS settings if !cfg.Bool("disable-cors") { e.Use(middleware.CORS()) } // Recover middleware recovers from panics anywhere in the request chain e.Use(middleware.Recover()) // Request limit per second. DoS protection if cfg.Int("request-limit") > 0 { limiter := tollbooth.NewLimiter(cfg.Float64("request-limit"), nil) e.Use(LimitMiddleware(limiter)) } // Setup lightning client stdOutLogger.Printf("Connecting to %s", cfg.String("lnd-address")) lndOptions := ln.LNDoptions{ Address: cfg.String("lnd-address"), CertFile: cfg.String("lnd-cert-path"), CertHex: cfg.String("lnd-cert"), MacaroonFile: cfg.String("lnd-macaroon-path"), MacaroonHex: cfg.String("lnd-macaroon"), TorExePath: cfg.String("tor-exe-path"), } lnClient, err := ln.NewLNDclient(lndOptions) if err != nil { stdOutLogger.Print("Error initializing LND client:") panic(err) } // Endpoint URLs compatible to the LND REST API v1 // // Create new invoice e.POST("/v1/invoices", func(c echo.Context) error { i := new(Invoice) if err := c.Bind(i); err != nil { stdOutLogger.Printf("Bad request: %s", err) return c.JSON(http.StatusBadRequest, "Bad request") } invoice, err := lnClient.AddInvoice(i.Value, i.Memo, nil, cfg.Bool("enable-private-channels")) if err != nil { stdOutLogger.Printf("Error creating invoice: %s", err) return c.JSON(http.StatusInternalServerError, "Error adding invoice") } return c.JSON(http.StatusOK, invoice) }) // Get next BTC onchain address e.POST("/v1/newaddress", func(c echo.Context) error { address, err := lnClient.NewAddress() if err != nil { stdOutLogger.Printf("Error getting a new BTC address: %s", err) return c.JSON(http.StatusInternalServerError, "Error getting address") } return c.JSON(http.StatusOK, address) }) // Check invoice status e.GET("/v1/invoice/:paymentHash", func(c echo.Context) error { paymentHash := c.Param("paymentHash") invoice, err := lnClient.GetInvoice(paymentHash) if err != nil { stdOutLogger.Printf("Error looking up invoice: %s", err) return c.JSON(http.StatusInternalServerError, "Error fetching invoice") } return c.JSON(http.StatusOK, invoice) }) if !cfg.Bool("disable-ln-address") { lnurlHandler := func(c echo.Context) error { host := c.Request().Host proto := c.Scheme() // TODO: support RFC7239 Forwarded header if c.Request().Header.Get("X-Forwarded-Host") != "" { host = c.Request().Header.Get("X-Forwarded-Host") } if c.Request().Header.Get("X-Forwarded-Proto") != "" { proto = c.Request().Header.Get("X-Forwarded-Proto") } name := c.Param("name") lightningAddress := name + "@" + host lnurlMetadata := "[[\"text/identifier\", \"" + lightningAddress + "\"], [\"text/plain\", \"Sats for " + lightningAddress + "\"]]" lnurlpCommentAllowed := cfg.Int64("lnurlp-comment-allowed") if amount := c.QueryParam("amount"); amount == "" { lnurlPayResponse1 := lnurl.LNURLPayResponse1{ LNURLResponse: lnurl.LNURLResponse{Status: "OK"}, Callback: fmt.Sprintf("%s://%s%s", proto, host, c.Request().URL.Path), MinSendable: 1000, MaxSendable: 100000000, EncodedMetadata: lnurlMetadata, CommentAllowed: lnurlpCommentAllowed, Tag: "payRequest", } return c.JSON(http.StatusOK, lnurlPayResponse1) } else { stdOutLogger.Printf("New LightningAddress request amount: %s", amount) msats, err := strconv.ParseInt(amount, 10, 64) if err != nil || msats < 1000 { stdOutLogger.Printf("Invalid amount: %s", amount) return c.JSON(http.StatusOK, lnurl.LNURLErrorResponse{Status: "ERROR", Reason: "Invalid Amount"}) } sats := msats / 1000 // we need sats comment := c.QueryParam("comment") if commentLength := int64(len(comment)); commentLength > lnurlpCommentAllowed { stdOutLogger.Printf("Invalid comment length: %d", commentLength) return c.JSON(http.StatusOK, lnurl.LNURLErrorResponse{Status: "ERROR", Reason: "Invalid comment length"}) } metadataHash := sha256.Sum256([]byte(lnurlMetadata)) invoice, err := lnClient.AddInvoice(sats, comment, metadataHash[:], cfg.Bool("enable-private-channels")) if err != nil { stdOutLogger.Printf("Error creating invoice: %s", err) return c.JSON(http.StatusOK, lnurl.LNURLErrorResponse{Status: "ERROR", Reason: "Server Error"}) } lnurlPayResponse2 := lnurl.LNURLPayResponse2{ LNURLResponse: lnurl.LNURLResponse{Status: "OK"}, PR: invoice.PaymentRequest, Routes: make([][]lnurl.RouteInfo, 0), Disposable: false, SuccessAction: &lnurl.SuccessAction{Tag: "message", Message: "Thanks, payment received!"}, } return c.JSON(http.StatusOK, lnurlPayResponse2) } } e.GET("/.well-known/lnurlp/:name", lnurlHandler) e.GET("/lnurlp/:name", lnurlHandler) } // Debug test endpoint e.GET("/ping", func(c echo.Context) error { return c.JSON(http.StatusOK, "pong") }) port := cfg.String("port") // Special case for PORT instead of LNME_PORT due to cloud-providers if os.Getenv("PORT") != "" { port = os.Getenv("PORT") } listen := cfg.String("listen") if listen != "" && port != "" { log.Fatalf("Port and listen options are mutually exclusive, please just use listen.") } if listen == "" { if port != "" { stdOutLogger.Printf("Please use listen instead of deprecated port setting.") listen = fmt.Sprintf(":%s", port) } else { listen = DEFAULT_LISTEN } } e.Logger.Fatal(e.Start(listen)) } func LoadConfig() *koanf.Koanf { k := koanf.New(".") f := flag.NewFlagSet("LnMe", flag.ExitOnError) f.String("lnd-address", "localhost:10009", "The host and port of the LND gRPC server.") f.String("lnd-macaroon-path", "~/.lnd/data/chain/bitcoin/mainnet/invoice.macaroon", "Path to the LND macaroon file.") f.String("lnd-macaroon", "", "HEX string of LND macaroon file.") f.String("lnd-cert-path", "~/.lnd/tls.cert", "Path to the LND tls.cert file.") f.String("lnd-cert", "", "HEX string of LND tls cert file.") f.Int64("lnurlp-comment-allowed", 210, "Allowed length of LNURL-pay comments.") f.Bool("disable-website", false, "Disable default embedded website.") f.Bool("disable-ln-address", false, "Disable Lightning Address handling") f.Bool("disable-cors", false, "Disable CORS headers.") f.Bool("enable-private-channels", false, "Adds private routing hints to invoices") f.Float64("request-limit", 5, "Request limit per second.") f.String("static-path", "", "Path to a static assets directory.") f.String("port", "", "Port to bind on (deprecated - use listen).") f.String("listen", "", fmt.Sprintf("Address to bind on. (default \"%s\")", DEFAULT_LISTEN)) f.String("tor-exe-path", "tor", "Path to the Tor executable. Used when connecting through Tor. (default: tor)") var configPath string f.StringVar(&configPath, "config", "config.toml", "Path to a .toml config file.") f.Parse(os.Args[1:]) // Load config from flags, including defaults if err := k.Load(basicflag.Provider(f, "."), nil); err != nil { log.Fatalf("Error loading config: %v", err) } // Load config from environment variables k.Load(env.Provider("LNME_", ".", func(s string) string { return strings.Replace(strings.ToLower(strings.TrimPrefix(s, "LNME_")), "_", "-", -1) }), nil) // Load config from file if available if configPath != "" { if _, err := os.Stat(configPath); err == nil { if err := k.Load(file.Provider(configPath), toml.Parser()); err != nil { log.Fatalf("Error loading config file: %v", err) } } } return k }