diff options
Diffstat (limited to 'cmd/faucet/faucet.go')
-rw-r--r-- | cmd/faucet/faucet.go | 456 |
1 files changed, 456 insertions, 0 deletions
diff --git a/cmd/faucet/faucet.go b/cmd/faucet/faucet.go new file mode 100644 index 000000000..232f0ff9e --- /dev/null +++ b/cmd/faucet/faucet.go @@ -0,0 +1,456 @@ +// Copyright 2017 The go-ethereum Authors +// This file is part of go-ethereum. +// +// go-ethereum is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// go-ethereum is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>. + +// faucet is a Ether faucet backed by a light client. +package main + +//go:generate go-bindata -nometadata -o website.go faucet.html + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "html/template" + "io/ioutil" + "math/big" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/ethstats" + "github.com/ethereum/go-ethereum/les" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p/discover" + "github.com/ethereum/go-ethereum/p2p/discv5" + "github.com/ethereum/go-ethereum/p2p/nat" + "github.com/ethereum/go-ethereum/params" + "golang.org/x/net/websocket" +) + +var ( + genesisFlag = flag.String("genesis", "", "Genesis json file to seed the chain with") + apiPortFlag = flag.Int("apiport", 8080, "Listener port for the HTTP API connection") + ethPortFlag = flag.Int("ethport", 30303, "Listener port for the devp2p connection") + bootFlag = flag.String("bootnodes", "", "Comma separated bootnode enode URLs to seed with") + netFlag = flag.Int("network", 0, "Network ID to use for the Ethereum protocol") + statsFlag = flag.String("ethstats", "", "Ethstats network monitoring auth string") + + 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") + minutesFlag = flag.Int("faucet.minutes", 1440, "Number of minutes to wait between funding rounds") + + accJSONFlag = flag.String("account.json", "", "Key json file to fund user requests with") + accPassFlag = flag.String("account.pass", "", "Decryption password to access faucet funds") + + githubUser = flag.String("github.user", "", "GitHub user to authenticate with for Gist access") + githubToken = flag.String("github.token", "", "GitHub personal token to access Gists with") + + logFlag = flag.Int("loglevel", 3, "Log level to use for Ethereum and the faucet") +) + +var ( + ether = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) +) + +func main() { + // Parse the flags and set up the logger to print everything requested + flag.Parse() + log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(*logFlag), log.StreamHandler(os.Stderr, log.TerminalFormat(true)))) + + // Load up and render the faucet website + tmpl, err := Asset("faucet.html") + if err != nil { + 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) + template.Must(template.New("").Parse(string(tmpl))).Execute(website, map[string]interface{}{ + "Network": *netnameFlag, + "Amount": *payoutFlag, + "Period": period, + }) + // Load and parse the genesis block requested by the user + blob, err := ioutil.ReadFile(*genesisFlag) + if err != nil { + log.Crit("Failed to read genesis block contents", "genesis", *genesisFlag, "err", err) + } + genesis := new(core.Genesis) + if err = json.Unmarshal(blob, genesis); err != nil { + log.Crit("Failed to parse genesis block json", "err", err) + } + // Convert the bootnodes to internal enode representations + var enodes []*discv5.Node + for _, boot := range strings.Split(*bootFlag, ",") { + if url, err := discv5.ParseNode(boot); err == nil { + enodes = append(enodes, url) + } else { + log.Error("Failed to parse bootnode URL", "url", boot, "err", err) + } + } + // Load up the account key and decrypt its password + if blob, err = ioutil.ReadFile(*accPassFlag); err != nil { + log.Crit("Failed to read account password contents", "file", *accPassFlag, "err", err) + } + pass := string(blob) + + ks := keystore.NewKeyStore(filepath.Join(os.Getenv("HOME"), ".faucet", "keys"), keystore.StandardScryptN, keystore.StandardScryptP) + if blob, err = ioutil.ReadFile(*accJSONFlag); err != nil { + log.Crit("Failed to read account key contents", "file", *accJSONFlag, "err", err) + } + acc, err := ks.Import(blob, pass, pass) + if err != nil { + log.Crit("Failed to import faucet signer account", "err", err) + } + ks.Unlock(acc, pass) + + // Assemble and start the faucet light service + faucet, err := newFaucet(genesis, *ethPortFlag, enodes, *netFlag, *statsFlag, ks, website.Bytes()) + if err != nil { + log.Crit("Failed to start faucet", "err", err) + } + defer faucet.close() + + if err := faucet.listenAndServe(*apiPortFlag); err != nil { + log.Crit("Failed to launch faucet API", "err", err) + } +} + +// request represents an accepted funding request. +type request struct { + Username string `json:"username"` // GitHub user for displaying an avatar + Account common.Address `json:"account"` // Ethereum address being funded + Time time.Time `json:"time"` // Timestamp when te request was accepted + Tx *types.Transaction `json:"tx"` // Transaction funding the account +} + +// faucet represents a crypto faucet backed by an Ethereum light client. +type faucet struct { + config *params.ChainConfig // Chain configurations for signing + stack *node.Node // Ethereum protocol stack + client *ethclient.Client // Client connection to the Ethereum chain + index []byte // Index page to serve up on the web + + keystore *keystore.KeyStore // Keystore containing the single signer + account accounts.Account // Account funding user faucet requests + nonce uint64 // Current pending nonce of the faucet + price *big.Int // Current gas price to issue funds with + + conns []*websocket.Conn // Currently live websocket connections + history map[string]time.Time // History of users and their funding requests + reqs []*request // Currently pending funding requests + update chan struct{} // Channel to signal request updates + + lock sync.RWMutex // Lock protecting the faucet's internals +} + +func newFaucet(genesis *core.Genesis, port int, enodes []*discv5.Node, network int, stats string, ks *keystore.KeyStore, index []byte) (*faucet, error) { + // Assemble the raw devp2p protocol stack + stack, err := node.New(&node.Config{ + Name: "geth", + Version: params.Version, + DataDir: filepath.Join(os.Getenv("HOME"), ".faucet"), + NAT: nat.Any(), + DiscoveryV5: true, + ListenAddr: fmt.Sprintf(":%d", port), + DiscoveryV5Addr: fmt.Sprintf(":%d", port+1), + MaxPeers: 25, + BootstrapNodesV5: enodes, + }) + if err != nil { + return nil, err + } + // Assemble the Ethereum light client protocol + if err := stack.Register(func(ctx *node.ServiceContext) (node.Service, error) { + return les.New(ctx, ð.Config{ + LightMode: true, + NetworkId: network, + Genesis: genesis, + GasPrice: big.NewInt(20 * params.Shannon), + GpoBlocks: 10, + GpoPercentile: 50, + EthashCacheDir: "ethash", + EthashCachesInMem: 2, + EthashCachesOnDisk: 3, + }) + }); err != nil { + return nil, err + } + // Assemble the ethstats monitoring and reporting service' + if stats != "" { + if err := stack.Register(func(ctx *node.ServiceContext) (node.Service, error) { + var serv *les.LightEthereum + ctx.Service(&serv) + return ethstats.New(stats, nil, serv) + }); err != nil { + return nil, err + } + } + // Boot up the client and ensure it connects to bootnodes + if err := stack.Start(); err != nil { + return nil, err + } + for _, boot := range enodes { + old, _ := discover.ParseNode(boot.String()) + stack.Server().AddPeer(old) + } + // Attach to the client and retrieve and interesting metadatas + api, err := stack.Attach() + if err != nil { + stack.Stop() + return nil, err + } + client := ethclient.NewClient(api) + + return &faucet{ + config: genesis.Config, + stack: stack, + client: client, + index: index, + keystore: ks, + account: ks.Accounts()[0], + history: make(map[string]time.Time), + update: make(chan struct{}, 1), + }, nil +} + +// close terminates the Ethereum connection and tears down the faucet. +func (f *faucet) close() error { + return f.stack.Stop() +} + +// listenAndServe registers the HTTP handlers for the faucet and boots it up +// for service user funding requests. +func (f *faucet) listenAndServe(port int) error { + go f.loop() + + http.HandleFunc("/", f.webHandler) + http.Handle("/api", websocket.Handler(f.apiHandler)) + + return http.ListenAndServe(fmt.Sprintf(":%d", port), nil) +} + +// webHandler handles all non-api requests, simply flattening and returning the +// faucet website. +func (f *faucet) webHandler(w http.ResponseWriter, r *http.Request) { + w.Write(f.index) +} + +// apiHandler handles requests for Ether grants and transaction statuses. +func (f *faucet) apiHandler(conn *websocket.Conn) { + // Start tracking the connection and drop at the end + f.lock.Lock() + f.conns = append(f.conns, conn) + f.lock.Unlock() + + defer func() { + f.lock.Lock() + for i, c := range f.conns { + if c == conn { + f.conns = append(f.conns[:i], f.conns[i+1:]...) + break + } + } + f.lock.Unlock() + }() + // Send a few initial stats to the client + balance, _ := f.client.BalanceAt(context.Background(), f.account.Address, nil) + nonce, _ := f.client.NonceAt(context.Background(), f.account.Address, nil) + + websocket.JSON.Send(conn, map[string]interface{}{ + "funds": balance.Div(balance, ether), + "funded": nonce, + "peers": f.stack.Server().PeerCount(), + "requests": f.reqs, + }) + header, _ := f.client.HeaderByNumber(context.Background(), nil) + websocket.JSON.Send(conn, header) + + // Keep reading requests from the websocket until the connection breaks + for { + // Fetch the next funding request and validate against github + var msg struct { + URL string `json:"url"` + } + if err := websocket.JSON.Receive(conn, &msg); err != nil { + return + } + if !strings.HasPrefix(msg.URL, "https://gist.github.com/") { + websocket.JSON.Send(conn, map[string]string{"error": "URL doesn't link to GitHub Gists"}) + continue + } + log.Info("Faucet funds requested", "gist", msg.URL) + + // Retrieve the gist from the GitHub Gist APIs + parts := strings.Split(msg.URL, "/") + req, _ := http.NewRequest("GET", "https://api.github.com/gists/"+parts[len(parts)-1], nil) + if *githubUser != "" { + req.SetBasicAuth(*githubUser, *githubToken) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + websocket.JSON.Send(conn, map[string]string{"error": err.Error()}) + continue + } + var gist struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Files map[string]struct { + Content string `json:"content"` + } `json:"files"` + } + err = json.NewDecoder(res.Body).Decode(&gist) + res.Body.Close() + if err != nil { + websocket.JSON.Send(conn, map[string]string{"error": err.Error()}) + continue + } + if gist.Owner.Login == "" { + websocket.JSON.Send(conn, map[string]string{"error": "Nice try ;)"}) + continue + } + // Iterate over all the files and look for Ethereum addresses + var address common.Address + for _, file := range gist.Files { + if len(file.Content) == 2+common.AddressLength*2 { + address = common.HexToAddress(file.Content) + } + } + if address == (common.Address{}) { + websocket.JSON.Send(conn, map[string]string{"error": "No Ethereum address found to fund"}) + continue + } + // Ensure the user didn't request funds too recently + f.lock.Lock() + var ( + fund bool + elapsed time.Duration + ) + if elapsed = time.Since(f.history[gist.Owner.Login]); elapsed > time.Duration(*minutesFlag)*time.Minute { + // 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) + signed, err := f.keystore.SignTx(f.account, tx, f.config.ChainId) + if err != nil { + websocket.JSON.Send(conn, map[string]string{"error": err.Error()}) + f.lock.Unlock() + continue + } + // Submit the transaction and mark as funded if successful + if err := f.client.SendTransaction(context.Background(), signed); err != nil { + websocket.JSON.Send(conn, map[string]string{"error": err.Error()}) + f.lock.Unlock() + continue + } + f.reqs = append(f.reqs, &request{ + Username: gist.Owner.Login, + Account: address, + Time: time.Now(), + Tx: signed, + }) + f.history[gist.Owner.Login] = time.Now() + fund = true + } + f.lock.Unlock() + + // Send an error if too frequent funding, othewise a success + if !fund { + websocket.JSON.Send(conn, map[string]string{"error": fmt.Sprintf("User already funded %s ago", common.PrettyDuration(elapsed))}) + continue + } + websocket.JSON.Send(conn, map[string]string{"success": fmt.Sprintf("Funding request accepted for %s into %s", gist.Owner.Login, address.Hex())}) + select { + case f.update <- struct{}{}: + default: + } + } +} + +// loop keeps waiting for interesting events and pushes them out to connected +// websockets. +func (f *faucet) loop() { + // Wait for chain events and push them to clients + heads := make(chan *types.Header, 16) + sub, err := f.client.SubscribeNewHead(context.Background(), heads) + if err != nil { + log.Crit("Failed to subscribe to head events", "err", err) + } + defer sub.Unsubscribe() + + for { + select { + case head := <-heads: + // New chain head arrived, query the current stats and stream to clients + balance, _ := f.client.BalanceAt(context.Background(), f.account.Address, nil) + balance = new(big.Int).Div(balance, ether) + + price, _ := f.client.SuggestGasPrice(context.Background()) + nonce, _ := f.client.NonceAt(context.Background(), f.account.Address, nil) + + f.lock.Lock() + f.price, f.nonce = price, nonce + for len(f.reqs) > 0 && f.reqs[0].Tx.Nonce() < f.nonce { + f.reqs = f.reqs[1:] + } + f.lock.Unlock() + + f.lock.RLock() + for _, conn := range f.conns { + if err := websocket.JSON.Send(conn, map[string]interface{}{ + "funds": balance, + "funded": f.nonce, + "peers": f.stack.Server().PeerCount(), + "requests": f.reqs, + }); err != nil { + log.Warn("Failed to send stats to client", "err", err) + conn.Close() + continue + } + if err := websocket.JSON.Send(conn, head); err != nil { + log.Warn("Failed to send header to client", "err", err) + conn.Close() + } + } + f.lock.RUnlock() + + case <-f.update: + // Pending requests updated, stream to clients + f.lock.RLock() + for _, conn := range f.conns { + if err := websocket.JSON.Send(conn, map[string]interface{}{"requests": f.reqs}); err != nil { + log.Warn("Failed to send requests to client", "err", err) + conn.Close() + } + } + f.lock.RUnlock() + } + } +} |