Merge pull request #14402 from karalabe/tiered-faucet

cmd/faucet, cmd/puppeth: support multi-tiered faucet
This commit is contained in:
Péter Szilágyi 2017-05-04 13:07:41 +03:00 committed by GitHub
commit 1c2f6f5597
5 changed files with 93 additions and 30 deletions

@ -27,11 +27,13 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io/ioutil" "io/ioutil"
"math"
"math/big" "math/big"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -67,6 +69,7 @@ var (
netnameFlag = flag.String("faucet.name", "", "Network name to assign to the faucet") netnameFlag = flag.String("faucet.name", "", "Network name to assign to the faucet")
payoutFlag = flag.Int("faucet.amount", 1, "Number of Ethers to pay out per user request") payoutFlag = flag.Int("faucet.amount", 1, "Number of Ethers to pay out per user request")
minutesFlag = flag.Int("faucet.minutes", 1440, "Number of minutes to wait between funding rounds") minutesFlag = flag.Int("faucet.minutes", 1440, "Number of minutes to wait between funding rounds")
tiersFlag = flag.Int("faucet.tiers", 3, "Number of funding tiers to enable (x3 time, x2.5 funds)")
accJSONFlag = flag.String("account.json", "", "Key json file to fund user requests with") accJSONFlag = flag.String("account.json", "", "Key json file to fund user requests with")
accPassFlag = flag.String("account.pass", "", "Decryption password to access faucet funds") accPassFlag = flag.String("account.pass", "", "Decryption password to access faucet funds")
@ -89,22 +92,47 @@ func main() {
flag.Parse() flag.Parse()
log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(*logFlag), log.StreamHandler(os.Stderr, log.TerminalFormat(true)))) log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(*logFlag), log.StreamHandler(os.Stderr, log.TerminalFormat(true))))
// Construct the payout tiers
amounts := make([]string, *tiersFlag)
periods := make([]string, *tiersFlag)
for i := 0; i < *tiersFlag; i++ {
// Calculate the amount for the next tier and format it
amount := float64(*payoutFlag) * math.Pow(2.5, float64(i))
amounts[i] = fmt.Sprintf("%s Ethers", strconv.FormatFloat(amount, 'f', -1, 64))
if amount == 1 {
amounts[i] = strings.TrimSuffix(amounts[i], "s")
}
// Calcualte the period for th enext tier and format it
period := *minutesFlag * int(math.Pow(3, float64(i)))
periods[i] = fmt.Sprintf("%d mins", period)
if period%60 == 0 {
period /= 60
periods[i] = fmt.Sprintf("%d hours", period)
if period%24 == 0 {
period /= 24
periods[i] = fmt.Sprintf("%d days", period)
}
}
if period == 1 {
periods[i] = strings.TrimSuffix(periods[i], "s")
}
}
// Load up and render the faucet website // Load up and render the faucet website
tmpl, err := Asset("faucet.html") tmpl, err := Asset("faucet.html")
if err != nil { if err != nil {
log.Crit("Failed to load the faucet template", "err", err) log.Crit("Failed to load the faucet template", "err", err)
} }
period := fmt.Sprintf("%d minute(s)", *minutesFlag)
if *minutesFlag%60 == 0 {
period = fmt.Sprintf("%d hour(s)", *minutesFlag/60)
}
website := new(bytes.Buffer) website := new(bytes.Buffer)
template.Must(template.New("").Parse(string(tmpl))).Execute(website, map[string]interface{}{ err = template.Must(template.New("").Parse(string(tmpl))).Execute(website, map[string]interface{}{
"Network": *netnameFlag, "Network": *netnameFlag,
"Amount": *payoutFlag, "Amounts": amounts,
"Period": period, "Periods": periods,
"Recaptcha": *captchaToken, "Recaptcha": *captchaToken,
}) })
if err != nil {
log.Crit("Failed to render the faucet template", "err", err)
}
// Load and parse the genesis block requested by the user // Load and parse the genesis block requested by the user
blob, err := ioutil.ReadFile(*genesisFlag) blob, err := ioutil.ReadFile(*genesisFlag)
if err != nil { if err != nil {
@ -171,10 +199,10 @@ type faucet struct {
nonce uint64 // Current pending nonce of the faucet nonce uint64 // Current pending nonce of the faucet
price *big.Int // Current gas price to issue funds with price *big.Int // Current gas price to issue funds with
conns []*websocket.Conn // Currently live websocket connections conns []*websocket.Conn // Currently live websocket connections
history map[string]time.Time // History of users and their funding requests timeouts map[string]time.Time // History of users and their funding timeouts
reqs []*request // Currently pending funding requests reqs []*request // Currently pending funding requests
update chan struct{} // Channel to signal request updates update chan struct{} // Channel to signal request updates
lock sync.RWMutex // Lock protecting the faucet's internals lock sync.RWMutex // Lock protecting the faucet's internals
} }
@ -241,7 +269,7 @@ func newFaucet(genesis *core.Genesis, port int, enodes []*discv5.Node, network u
index: index, index: index,
keystore: ks, keystore: ks,
account: ks.Accounts()[0], account: ks.Accounts()[0],
history: make(map[string]time.Time), timeouts: make(map[string]time.Time),
update: make(chan struct{}, 1), update: make(chan struct{}, 1),
}, nil }, nil
} }
@ -295,14 +323,22 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
"peers": f.stack.Server().PeerCount(), "peers": f.stack.Server().PeerCount(),
"requests": f.reqs, "requests": f.reqs,
}) })
header, _ := f.client.HeaderByNumber(context.Background(), nil) // Send the initial block to the client
websocket.JSON.Send(conn, header) ctx, cancel := context.WithTimeout(context.Background(), time.Second)
header, err := f.client.HeaderByNumber(ctx, nil)
cancel()
if err != nil {
log.Error("Failed to retrieve latest header", "err", err)
} else {
websocket.JSON.Send(conn, header)
}
// Keep reading requests from the websocket until the connection breaks // Keep reading requests from the websocket until the connection breaks
for { for {
// Fetch the next funding request and validate against github // Fetch the next funding request and validate against github
var msg struct { var msg struct {
URL string `json:"url"` URL string `json:"url"`
Tier uint `json:"tier"`
Captcha string `json:"captcha"` Captcha string `json:"captcha"`
} }
if err := websocket.JSON.Receive(conn, &msg); err != nil { if err := websocket.JSON.Receive(conn, &msg); err != nil {
@ -312,7 +348,11 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
websocket.JSON.Send(conn, map[string]string{"error": "URL doesn't link to GitHub Gists"}) websocket.JSON.Send(conn, map[string]string{"error": "URL doesn't link to GitHub Gists"})
continue continue
} }
log.Info("Faucet funds requested", "gist", msg.URL) if msg.Tier >= uint(*tiersFlag) {
websocket.JSON.Send(conn, map[string]string{"error": "Invalid funding tier requested"})
continue
}
log.Info("Faucet funds requested", "gist", msg.URL, "tier", msg.Tier)
// If captcha verifications are enabled, make sure we're not dealing with a robot // If captcha verifications are enabled, make sure we're not dealing with a robot
if *captchaToken != "" { if *captchaToken != "" {
@ -337,7 +377,7 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
} }
if !result.Success { if !result.Success {
log.Warn("Captcha verification failed", "err", string(result.Errors)) log.Warn("Captcha verification failed", "err", string(result.Errors))
websocket.JSON.Send(conn, map[string]string{"error": "Beep-boop, you're a robot!"}) websocket.JSON.Send(conn, map[string]string{"error": "Beep-bop, you're a robot!"})
continue continue
} }
} }
@ -396,11 +436,15 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
f.lock.Lock() f.lock.Lock()
var ( var (
fund bool fund bool
elapsed time.Duration timeout time.Time
) )
if elapsed = time.Since(f.history[gist.Owner.Login]); elapsed > time.Duration(*minutesFlag)*time.Minute { if timeout = f.timeouts[gist.Owner.Login]; time.Now().After(timeout) {
// User wasn't funded recently, create the funding transaction // User wasn't funded recently, create the funding transaction
tx := types.NewTransaction(f.nonce+uint64(len(f.reqs)), address, new(big.Int).Mul(big.NewInt(int64(*payoutFlag)), ether), big.NewInt(21000), f.price, nil) amount := new(big.Int).Mul(big.NewInt(int64(*payoutFlag)), ether)
amount = new(big.Int).Mul(amount, new(big.Int).Exp(big.NewInt(5), big.NewInt(int64(msg.Tier)), nil))
amount = new(big.Int).Div(amount, new(big.Int).Exp(big.NewInt(2), big.NewInt(int64(msg.Tier)), nil))
tx := types.NewTransaction(f.nonce+uint64(len(f.reqs)), address, amount, big.NewInt(21000), f.price, nil)
signed, err := f.keystore.SignTx(f.account, tx, f.config.ChainId) signed, err := f.keystore.SignTx(f.account, tx, f.config.ChainId)
if err != nil { if err != nil {
websocket.JSON.Send(conn, map[string]string{"error": err.Error()}) websocket.JSON.Send(conn, map[string]string{"error": err.Error()})
@ -419,14 +463,14 @@ func (f *faucet) apiHandler(conn *websocket.Conn) {
Time: time.Now(), Time: time.Now(),
Tx: signed, Tx: signed,
}) })
f.history[gist.Owner.Login] = time.Now() f.timeouts[gist.Owner.Login] = time.Now().Add(time.Duration(*minutesFlag*int(math.Pow(3, float64(msg.Tier)))) * time.Minute)
fund = true fund = true
} }
f.lock.Unlock() f.lock.Unlock()
// Send an error if too frequent funding, othewise a success // Send an error if too frequent funding, othewise a success
if !fund { if !fund {
websocket.JSON.Send(conn, map[string]string{"error": fmt.Sprintf("User already funded %s ago", common.PrettyDuration(elapsed))}) websocket.JSON.Send(conn, map[string]string{"error": fmt.Sprintf("%s left until next allowance", common.PrettyDuration(timeout.Sub(time.Now())))})
continue continue
} }
websocket.JSON.Send(conn, map[string]string{"success": fmt.Sprintf("Funding request accepted for %s into %s", gist.Owner.Login, address.Hex())}) websocket.JSON.Send(conn, map[string]string{"success": fmt.Sprintf("Funding request accepted for %s into %s", gist.Owner.Login, address.Hex())})

@ -51,7 +51,10 @@
<div class="input-group"> <div class="input-group">
<input id="gist" type="text" class="form-control" placeholder="GitHub Gist URL containing your Ethereum address..."> <input id="gist" type="text" class="form-control" placeholder="GitHub Gist URL containing your Ethereum address...">
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn btn-default" type="button" onclick="{{if .Recaptcha}}grecaptcha.execute(){{else}}submit(){{end}}">Give me Ether!</button> <button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Give me Ether <i class="fa fa-caret-down" aria-hidden="true"></i></button>
<ul class="dropdown-menu dropdown-menu-right">{{range $idx, $amount := .Amounts}}
<li><a style="text-align: center;" onclick="tier={{$idx}}; {{if $.Recaptcha}}grecaptcha.execute(){{else}}submit({{$idx}}){{end}}">{{$amount}} / {{index $.Periods $idx}}</a></li>{{end}}
</ul>
</span> </span>
</div>{{if .Recaptcha}} </div>{{if .Recaptcha}}
<div class="g-recaptcha" data-sitekey="{{.Recaptcha}}" data-callback="submit" data-size="invisible"></div>{{end}} <div class="g-recaptcha" data-sitekey="{{.Recaptcha}}" data-callback="submit" data-size="invisible"></div>{{end}}
@ -77,8 +80,9 @@
<div class="row" style="margin-top: 32px;"> <div class="row" style="margin-top: 32px;">
<div class="col-lg-12"> <div class="col-lg-12">
<h3>How does this work?</h3> <h3>How does this work?</h3>
<p>This Ether faucet is running on the {{.Network}} network. To prevent malicious actors from exhausting all available funds or accumulating enough Ether to mount long running spam attacks, requests are tied to GitHub accounts. Anyone having a GitHub account may request funds within the permitted limit of <strong>{{.Amount}} Ether(s) / {{.Period}}</strong>.{{if .Recaptcha}} The faucet is running invisible reCaptcha protection against bots.{{end}}</p> <p>This Ether faucet is running on the {{.Network}} network. To prevent malicious actors from exhausting all available funds or accumulating enough Ether to mount long running spam attacks, requests are tied to GitHub accounts. Anyone having a GitHub account may request funds within the permitted limits.</p>
<p>To request funds, simply create a <a href="https://gist.github.com/" target="_about:blank">GitHub Gist</a> with your Ethereum address pasted into the contents (the file name doesn't matter), copy paste the gists URL into the above input box and fire away! You can track the current pending requests below the input field to see how much you have to wait until your turn comes.</p> <p>To request funds, simply create a <a href="https://gist.github.com/" target="_about:blank">GitHub Gist</a> with your Ethereum address pasted into the contents (the file name doesn't matter), copy paste the gists URL into the above input box and fire away! You can track the current pending requests below the input field to see how much you have to wait until your turn comes.</p>
{{if .Recaptcha}}<em>The faucet is running invisible reCaptcha protection against bots.</em>{{end}}
</div> </div>
</div> </div>
</div> </div>
@ -88,10 +92,11 @@
// Global variables to hold the current status of the faucet // Global variables to hold the current status of the faucet
var attempt = 0; var attempt = 0;
var server; var server;
var tier = 0;
// Define the function that submits a gist url to the server // Define the function that submits a gist url to the server
var submit = function({{if .Recaptcha}}captcha{{end}}) { var submit = function({{if .Recaptcha}}captcha{{end}}) {
server.send(JSON.stringify({url: $("#gist")[0].value{{if .Recaptcha}}, captcha: captcha{{end}}}));{{if .Recaptcha}} server.send(JSON.stringify({url: $("#gist")[0].value, tier: tier{{if .Recaptcha}}, captcha: captcha{{end}}}));{{if .Recaptcha}}
grecaptcha.reset();{{end}} grecaptcha.reset();{{end}}
}; };
// Define a method to reconnect upon server loss // Define a method to reconnect upon server loss

File diff suppressed because one or more lines are too long

@ -51,10 +51,10 @@ ADD account.pass /account.pass
EXPOSE 8080 EXPOSE 8080
CMD [ \ CMD [ \
"/faucet", "--genesis", "/genesis.json", "--network", "{{.NetworkID}}", "--bootnodes", "{{.Bootnodes}}", "--ethstats", "{{.Ethstats}}", \ "/faucet", "--genesis", "/genesis.json", "--network", "{{.NetworkID}}", "--bootnodes", "{{.Bootnodes}}", "--ethstats", "{{.Ethstats}}", "--ethport", "{{.EthPort}}", \
"--ethport", "{{.EthPort}}", "--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", \ "--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", "--faucet.tiers", "{{.FaucetTiers}}", \
"--github.user", "{{.GitHubUser}}", "--github.token", "{{.GitHubToken}}", "--account.json", "/account.json", "--account.pass", "/account.pass" \ "--github.user", "{{.GitHubUser}}", "--github.token", "{{.GitHubToken}}", "--account.json", "/account.json", "--account.pass", "/account.pass" \
{{if .CaptchaToken}}, "--captcha.token", "{{.CaptchaToken}}", "--captcha.secret", "{{.CaptchaSecret}}"{{end}} \ {{if .CaptchaToken}}, "--captcha.token", "{{.CaptchaToken}}", "--captcha.secret", "{{.CaptchaSecret}}"{{end}} \
]` ]`
// faucetComposefile is the docker-compose.yml file required to deploy and maintain // faucetComposefile is the docker-compose.yml file required to deploy and maintain
@ -75,6 +75,7 @@ services:
- ETH_NAME={{.EthName}} - ETH_NAME={{.EthName}}
- FAUCET_AMOUNT={{.FaucetAmount}} - FAUCET_AMOUNT={{.FaucetAmount}}
- FAUCET_MINUTES={{.FaucetMinutes}} - FAUCET_MINUTES={{.FaucetMinutes}}
- FAUCET_TIERS={{.FaucetTiers}}
- GITHUB_USER={{.GitHubUser}} - GITHUB_USER={{.GitHubUser}}
- GITHUB_TOKEN={{.GitHubToken}} - GITHUB_TOKEN={{.GitHubToken}}
- CAPTCHA_TOKEN={{.CaptchaToken}} - CAPTCHA_TOKEN={{.CaptchaToken}}
@ -105,6 +106,7 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
"FaucetName": strings.Title(network), "FaucetName": strings.Title(network),
"FaucetAmount": config.amount, "FaucetAmount": config.amount,
"FaucetMinutes": config.minutes, "FaucetMinutes": config.minutes,
"FaucetTiers": config.tiers,
}) })
files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes() files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes()
@ -122,6 +124,7 @@ func deployFaucet(client *sshClient, network string, bootnodes []string, config
"CaptchaSecret": config.captchaSecret, "CaptchaSecret": config.captchaSecret,
"FaucetAmount": config.amount, "FaucetAmount": config.amount,
"FaucetMinutes": config.minutes, "FaucetMinutes": config.minutes,
"FaucetTiers": config.tiers,
}) })
files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes() files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes()
@ -147,6 +150,7 @@ type faucetInfos struct {
port int port int
amount int amount int
minutes int minutes int
tiers int
githubUser string githubUser string
githubToken string githubToken string
captchaToken string captchaToken string
@ -155,7 +159,7 @@ type faucetInfos struct {
// String implements the stringer interface. // String implements the stringer interface.
func (info *faucetInfos) String() string { func (info *faucetInfos) String() string {
return fmt.Sprintf("host=%s, api=%d, eth=%d, amount=%d, minutes=%d, github=%s, captcha=%v, ethstats=%s", info.host, info.port, info.node.portFull, info.amount, info.minutes, info.githubUser, info.captchaToken != "", info.node.ethstats) return fmt.Sprintf("host=%s, api=%d, eth=%d, amount=%d, minutes=%d, tiers=%d, github=%s, captcha=%v, ethstats=%s", info.host, info.port, info.node.portFull, info.amount, info.minutes, info.tiers, info.githubUser, info.captchaToken != "", info.node.ethstats)
} }
// checkFaucet does a health-check against an faucet server to verify whether // checkFaucet does a health-check against an faucet server to verify whether
@ -186,6 +190,7 @@ func checkFaucet(client *sshClient, network string) (*faucetInfos, error) {
} }
amount, _ := strconv.Atoi(infos.envvars["FAUCET_AMOUNT"]) amount, _ := strconv.Atoi(infos.envvars["FAUCET_AMOUNT"])
minutes, _ := strconv.Atoi(infos.envvars["FAUCET_MINUTES"]) minutes, _ := strconv.Atoi(infos.envvars["FAUCET_MINUTES"])
tiers, _ := strconv.Atoi(infos.envvars["FAUCET_TIERS"])
// Retrieve the funding account informations // Retrieve the funding account informations
var out []byte var out []byte
@ -213,6 +218,7 @@ func checkFaucet(client *sshClient, network string) (*faucetInfos, error) {
port: port, port: port,
amount: amount, amount: amount,
minutes: minutes, minutes: minutes,
tiers: tiers,
githubUser: infos.envvars["GITHUB_USER"], githubUser: infos.envvars["GITHUB_USER"],
githubToken: infos.envvars["GITHUB_TOKEN"], githubToken: infos.envvars["GITHUB_TOKEN"],
captchaToken: infos.envvars["CAPTCHA_TOKEN"], captchaToken: infos.envvars["CAPTCHA_TOKEN"],

@ -44,6 +44,7 @@ func (w *wizard) deployFaucet() {
host: client.server, host: client.server,
amount: 1, amount: 1,
minutes: 1440, minutes: 1440,
tiers: 3,
} }
} }
infos.node.genesis, _ = json.MarshalIndent(w.conf.genesis, "", " ") infos.node.genesis, _ = json.MarshalIndent(w.conf.genesis, "", " ")
@ -68,6 +69,13 @@ func (w *wizard) deployFaucet() {
fmt.Printf("How many minutes to enforce between requests? (default = %d)\n", infos.minutes) fmt.Printf("How many minutes to enforce between requests? (default = %d)\n", infos.minutes)
infos.minutes = w.readDefaultInt(infos.minutes) infos.minutes = w.readDefaultInt(infos.minutes)
fmt.Println()
fmt.Printf("How many funding tiers to feature (x2.5 amounts, x3 timeout)? (default = %d)\n", infos.tiers)
infos.tiers = w.readDefaultInt(infos.tiers)
if infos.tiers == 0 {
log.Error("At least one funding tier must be set")
return
}
// Accessing GitHub gists requires API authorization, retrieve it // Accessing GitHub gists requires API authorization, retrieve it
if infos.githubUser != "" { if infos.githubUser != "" {
fmt.Println() fmt.Println()