Allow multiple comma-separated domains

This commit is contained in:
Gregor Pogacnik 2022-01-19 00:50:47 +01:00
parent e0f240a3d9
commit 15794baee6
10 changed files with 231 additions and 56 deletions

View File

@ -6,6 +6,8 @@ Federated Lightning Address Server
1. Download the binary from the releases page (or compile with `go build` or `go get`)
2. Set the following environment variables somehow (using example values from bitmia.com):
(note that DOMAIN can be a comma-seperated list or a single domain, when using multiple domains
you need to make sure "Host" HTTP header is forwarded to satdress process if you have some reverse-proxy)
```
PORT=17422

42
api.go
View File

@ -18,6 +18,7 @@ type Response struct {
type SuccessClaim struct {
Name string `json:"name"`
Domain string `json:"domain"`
PIN string `json:"pin"`
Invoice string `json:"invoice"`
}
@ -25,7 +26,7 @@ type SuccessClaim struct {
// not authenticated, if correct pin is provided call returns the SuccessClaim
func ClaimAddress(w http.ResponseWriter, r *http.Request) {
params := parseParams(r)
pin, inv, err := SaveName(params.Name, params, params.Pin)
pin, inv, err := SaveName(params.Name, params.Domain, params, params.Pin)
if err != nil {
sendError(w, 400, "could not register name: %s", err.Error())
return
@ -33,8 +34,8 @@ func ClaimAddress(w http.ResponseWriter, r *http.Request) {
response := Response{
Ok: true,
Message: fmt.Sprintf("claimed %v@%v", params.Name, s.Domain),
Data: SuccessClaim{params.Name, pin, inv},
Message: fmt.Sprintf("claimed %v@%v", params.Name, params.Domain),
Data: SuccessClaim{params.Name, params.Domain, pin, inv},
}
// TODO: middleware for responses that adds this header
@ -45,18 +46,19 @@ func ClaimAddress(w http.ResponseWriter, r *http.Request) {
func GetUser(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
params, err := GetName(name)
domain := mux.Vars(r)["domain"]
params, err := GetName(name, domain)
if err != nil {
sendError(w, 400, err.Error())
return
}
// add pin to response because sometimes not saved in database; after first call to /api/v1/claim
params.Pin = ComputePIN(name)
params.Pin = ComputePIN(name, domain)
response := Response{
Ok: true,
Message: fmt.Sprintf("%v@%v found", params.Name, s.Domain),
Message: fmt.Sprintf("%v@%v found", params.Name, domain),
Data: params,
}
@ -68,6 +70,7 @@ func GetUser(w http.ResponseWriter, r *http.Request) {
func UpdateUser(w http.ResponseWriter, r *http.Request) {
params := parseParams(r)
name := mux.Vars(r)["name"]
domain := mux.Vars(r)["domain"]
// if pin not in json request body get it from header
if params.Pin == "" {
@ -75,12 +78,12 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
params.Pin = r.Header.Get("X-Pin")
}
if _, _, err := SaveName(name, params, params.Pin); err != nil {
if _, _, err := SaveName(name, domain, params, params.Pin); err != nil {
sendError(w, 500, err.Error())
return
}
updatedParams, err := GetName(name)
updatedParams, err := GetName(name, domain)
if err != nil {
sendError(w, 500, err.Error())
return
@ -89,7 +92,7 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
// return the updated values or just http.StatusCreated?
response := Response{
Ok: true,
Message: fmt.Sprintf("updated %v@%v parameters", params.Name, s.Domain),
Message: fmt.Sprintf("updated %v@%v parameters", params.Name, domain),
Data: updatedParams,
}
@ -100,14 +103,15 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
func DeleteUser(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
if err := DeleteName(name); err != nil {
domain := mux.Vars(r)["domain"]
if err := DeleteName(name, domain); err != nil {
sendError(w, 500, err.Error())
return
}
response := Response{
Ok: true,
Message: fmt.Sprintf("deleted %v@%v", name, s.Domain),
Message: fmt.Sprintf("deleted %v@%v", name, domain),
Data: nil,
}
@ -119,6 +123,20 @@ func DeleteUser(w http.ResponseWriter, r *http.Request) {
// authentication middleware
func authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// check domain
domain := mux.Vars(r)["domain"]
available := getDomains(s.Domain)
found := false
for _, one := range available {
if one == domain {
found = true
}
}
if !found {
sendError(w, 400, "could not use domain: %s", domain)
return
}
// exempt /claim from authentication check;
if strings.HasPrefix(r.URL.Path, "/api/v1/claim") {
next.ServeHTTP(w, r)
@ -136,7 +154,7 @@ func authenticate(next http.Handler) http.Handler {
providedPin = parseParams(r).Pin
}
if providedPin != ComputePIN(name) {
if providedPin != ComputePIN(name, domain) {
err = fmt.Errorf("wrong pin")
}

81
db.go
View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/cockroachdb/pebble"
@ -14,6 +15,7 @@ import (
type Params struct {
Name string `json:"name"`
Domain string `json:"domain,omitempty"`
Kind string `json:"kind"`
Host string `json:"host"`
Key string `json:"key"`
@ -26,13 +28,16 @@ type Params struct {
func SaveName(
name string,
domain string,
params *Params,
providedPin string,
) (pin string, inv string, err error) {
name = strings.ToLower(name)
key := []byte(name)
domain = strings.ToLower(domain)
pin = ComputePIN(name)
key := []byte(getID(name, domain))
pin = ComputePIN(name, domain)
if _, closer, err := db.Get(key); err == nil {
defer closer.Close()
@ -45,6 +50,7 @@ func SaveName(
}
params.Name = name
params.Domain = domain
// check if the given data works
if inv, err = makeInvoice(params, 1000, &pin); err != nil {
@ -60,10 +66,8 @@ func SaveName(
return pin, inv, nil
}
func GetName(name string) (*Params, error) {
name = strings.ToLower(name)
val, closer, err := db.Get([]byte(name))
func GetName(name, domain string) (*Params, error) {
val, closer, err := db.Get([]byte(getID(name, domain)))
if err != nil {
return nil, err
}
@ -75,12 +79,12 @@ func GetName(name string) (*Params, error) {
}
params.Name = name
params.Domain = domain
return &params, nil
}
func DeleteName(name string) error {
name = strings.ToLower(name)
key := []byte(name)
func DeleteName(name, domain string) error {
key := []byte(getID(name, domain))
if err := db.Delete(key, pebble.Sync); err != nil {
return err
@ -89,9 +93,62 @@ func DeleteName(name string) error {
return nil
}
func ComputePIN(name string) string {
name = strings.ToLower(name)
func ComputePIN(name, domain string) string {
mac := hmac.New(sha256.New, []byte(s.Secret))
mac.Write([]byte(name + "@" + s.Domain))
mac.Write([]byte(getID(name, domain)))
return hex.EncodeToString(mac.Sum(nil))
}
func getID(name, domain string) string {
if s.GlobalUsers {
return strings.ToLower(name)
} else {
return strings.ToLower(fmt.Sprintf("%s@%s", name, domain))
}
}
func tryMigrate(old, new string) {
if _, err := os.Stat(old); os.IsNotExist(err) {
return
}
log.Info().Str("db", old).Msg("Migrating db")
newDb, err := pebble.Open(new, nil)
if err != nil {
log.Fatal().Err(err).Str("path", new).Msg("failed to open db.")
}
defer newDb.Close()
oldDb, err := pebble.Open(old, nil)
if err != nil {
log.Fatal().Err(err).Str("path", old).Msg("failed to open db.")
}
defer oldDb.Close()
iter := oldDb.NewIter(nil)
defer iter.Close()
for iter.First(); iter.Valid(); iter.Next() {
log.Debug().Str("db", string(iter.Key())).Msg("Migrating key")
var params Params
if err := json.Unmarshal(iter.Value(), &params); err != nil {
log.Debug().Err(err).Msg("Unmarshal error")
continue
}
params.Domain = old // old database name was domain
// save it
data, err := json.Marshal(params)
if err != nil {
log.Debug().Err(err).Msg("Marshal error")
continue
}
if err := newDb.Set([]byte(getID(params.Name, params.Domain)), data, pebble.Sync); err != nil {
log.Debug().Err(err).Msg("Set error")
continue
}
}
}

View File

@ -13,7 +13,7 @@
<div class="title">Success!</div>
<div class="card">
<div class="description">
<b>{{ name }}@{{ domain }}</b> is your new Lightning Address!
<b>{{ name }}@{{ actual_domain }}</b> is your new Lightning Address!
</div>
<div class="bold-small">
In order to edit the configuration of this address in the future you

10
html.go
View File

@ -8,18 +8,24 @@ import (
)
type BaseData struct {
Domain string `json:"domain"`
Domains []string `json:"domains"`
SiteOwnerName string `json:"siteOwnerName"`
SiteOwnerURL string `json:"siteOwnerURL"`
SiteName string `json:"siteName"`
UsernameInfo string `json:"usernameInfo"`
}
func renderHTML(w http.ResponseWriter, html string, extraData interface{}) {
info := "Desired Username"
if s.GlobalUsers {
info = "Desired Username (unique across all domains)"
}
base, _ := json.Marshal(BaseData{
Domain: s.Domain,
Domains: getDomains(s.Domain),
SiteOwnerName: s.SiteOwnerName,
SiteOwnerURL: s.SiteOwnerURL,
SiteName: s.SiteName,
UsernameInfo: info,
})
extra, _ := json.Marshal(extraData)

View File

@ -24,11 +24,15 @@
<form action="/grab" method="post">
<div class="field">
<div class="row">
<label for="name"> Desired Username </label>
<label for="name"> {{usernameInfo}} </label>
</div>
<div class="domain-wrapper">
<input class="input" name="name" id="name" />
<span class="suffix">@{{ domain }}</span>
<span class="suffix" v-if="domains.length == 1">@{{ domains[0] }}</span>
<span class="suffix-hack" v-if="domains.length > 1">@</span>
<select name="domain" id="domain" id="domain" v-if="domains.length > 1">
<option v-for="domain in domains" :value="domain">{{ domain }}</option>
</select>
</div>
</div>
<div class="field">

View File

@ -5,23 +5,47 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/fiatjaf/go-lnurl"
"github.com/gorilla/mux"
)
func handleLNURL(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
username := mux.Vars(r)["user"]
params, err := GetName(username)
domains := getDomains(s.Domain)
domain := ""
if len(domains) == 1 {
domain = domains[0]
} else {
hostname := r.URL.Host
if hostname == "" {
hostname = r.Host
}
for _, one := range getDomains(s.Domain) {
if strings.Contains(hostname, one) {
domain = one
break
}
}
if domain == "" {
json.NewEncoder(w).Encode(lnurl.ErrorResponse("incorrect domain"))
return
}
}
params, err := GetName(username, domain)
if err != nil {
log.Error().Err(err).Str("name", username).Msg("failed to get name")
log.Error().Err(err).Str("name", username).Str("domain", domain).Msg("failed to get name")
json.NewEncoder(w).Encode(lnurl.ErrorResponse(fmt.Sprintf(
"failed to get name %s", username)))
"failed to get name %s@%s", username, domain)))
return
}
log.Info().Str("username", username).Msg("got lnurl request")
log.Info().Str("username", username).Str("domain", domain).Msg("got lnurl request")
if amount := r.URL.Query().Get("amount"); amount == "" {
// check if the receiver accepts comments
@ -41,7 +65,7 @@ func handleLNURL(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(lnurl.LNURLPayResponse1{
LNURLResponse: lnurl.LNURLResponse{Status: "OK"},
Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s", s.Domain, username),
Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s", domain, username),
MinSendable: minSendable,
MaxSendable: maxSendable,
EncodedMetadata: makeMetadata(params),

52
main.go
View File

@ -20,11 +20,15 @@ type Settings struct {
Host string `envconfig:"HOST" default:"0.0.0.0"`
Port string `envconfig:"PORT" required:"true"`
Domain string `envconfig:"DOMAIN" required:"true"`
// GlobalUsers means that user@ part is globally unique across all domains
// WARNING: if you toggle this existing users won't work anymore for safety reasons!
GlobalUsers bool `envconfig:"GLOBAL_USERS" required:"false" default:false`
Secret string `envconfig:"SECRET" required:"true"`
SiteOwnerName string `envconfig:"SITE_OWNER_NAME" required:"true"`
SiteOwnerURL string `envconfig:"SITE_OWNER_URL" required:"true"`
SiteName string `envconfig:"SITE_NAME" required:"true"`
ForceMigrate bool `envconfig:"FORCE_MIGRATE" required:"false" default:false`
TorProxyURL string `envconfig:"TOR_PROXY_URL"`
}
@ -57,12 +61,19 @@ func main() {
makeinvoice.TorProxyURL = s.TorProxyURL
}
db, err = pebble.Open(s.Domain, nil)
if err != nil {
log.Fatal().Err(err).Str("path", s.Domain).Msg("failed to open db.")
dbName := fmt.Sprintf("%v-multiple.db", s.SiteName)
if _, err := os.Stat(dbName); os.IsNotExist(err) || s.ForceMigrate {
for _, one := range getDomains(s.Domain) {
tryMigrate(one, dbName)
}
}
router.Path("/.well-known/lnurlp/{username}").Methods("GET").
db, err = pebble.Open(dbName, nil)
if err != nil {
log.Fatal().Err(err).Str("path", dbName).Msg("failed to open db.")
}
router.Path("/.well-known/lnurlp/{user}").Methods("GET").
HandlerFunc(handleLNURL)
router.Path("/").HandlerFunc(
@ -76,8 +87,23 @@ func main() {
router.Path("/grab").HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
if name == "" || r.FormValue("kind") == "" {
sendError(w, 500, "internal error")
return
}
pin, inv, err := SaveName(name, &Params{
// might not get domain back
domain := r.FormValue("domain")
if domain == "" {
if !strings.Contains(s.Domain, ",") {
domain = s.Domain
} else {
sendError(w, 500, "internal error")
return
}
}
pin, inv, err := SaveName(name, domain, &Params{
Kind: r.FormValue("kind"),
Host: r.FormValue("host"),
Key: r.FormValue("key"),
@ -94,7 +120,8 @@ func main() {
PIN string `json:"pin"`
Invoice string `json:"invoice"`
Name string `json:"name"`
}{pin, inv, name})
ActualDomain string `json:"actual_domain"`
}{pin, inv, name, domain})
},
)
@ -105,9 +132,9 @@ func main() {
api.HandleFunc("/claim", ClaimAddress).Methods("POST")
// authenticated routes; X-Pin in header or in json request body
api.HandleFunc("/users/{name}", GetUser).Methods("GET")
api.HandleFunc("/users/{name}", UpdateUser).Methods("PUT")
api.HandleFunc("/users/{name}", DeleteUser).Methods("DELETE")
api.HandleFunc("/users/{name}@{domain}", GetUser).Methods("GET")
api.HandleFunc("/users/{name}@{domain}", UpdateUser).Methods("PUT")
api.HandleFunc("/users/{name}@{domain}", DeleteUser).Methods("DELETE")
srv := &http.Server{
Handler: router,
@ -118,3 +145,10 @@ func main() {
log.Debug().Str("addr", srv.Addr).Msg("listening")
srv.ListenAndServe()
}
func getDomains(s string) []string {
splitFn := func(c rune) bool {
return c == ','
}
return strings.FieldsFunc(s, splitFn)
}

View File

@ -12,10 +12,10 @@ import (
func makeMetadata(params *Params) string {
metadata, _ := sjson.Set("[]", "0.0", "text/identifier")
metadata, _ = sjson.Set(metadata, "0.1", params.Name+"@"+s.Domain)
metadata, _ = sjson.Set(metadata, "0.1", params.Name+"@"+params.Domain)
metadata, _ = sjson.Set(metadata, "1.0", "text/plain")
metadata, _ = sjson.Set(metadata, "1.1", "Satoshis to "+params.Name+"@"+s.Domain+".")
metadata, _ = sjson.Set(metadata, "1.1", "Satoshis to "+params.Name+"@"+params.Domain+".")
// TODO support image, custom description

View File

@ -128,6 +128,19 @@ label {
background-color: #f3f3f3;
}
select#domain {
width: 50%;
height: 35px;
outline: none;
padding: 0 10px;
font-size: 14px;
border-radius: 5px;
margin-bottom: 25px;
letter-spacing: 0.5px;
border: 1px solid #999;
background-color: #f3f3f3;
}
.input.full-width {
width: calc(100% - 20px);
}
@ -145,6 +158,18 @@ label {
letter-spacing: 0.5px;
}
.suffix-hack {
height: 35px;
padding: 0 10px;
margin-bottom: 25px;
display: inline-flex;
font-size: 16px;
font-weight: 600;
align-items: center;
word-break: keep-all;
letter-spacing: 0.5px;
}
select {
height: 35px;
outline: none;
@ -260,4 +285,9 @@ select {
padding: 0;
margin-bottom: 25px;
}
.suffix-hack {
padding: 0;
margin-bottom: 0px;
}
}