cmd/puppeth: your Ethereum private network manager (#13854)
This commit is contained in:
committed by
Felix Lange
parent
18bbe12425
commit
706a1e552c
456
cmd/faucet/faucet.go
Normal file
456
cmd/faucet/faucet.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
143
cmd/faucet/faucet.html
Normal file
143
cmd/faucet/faucet.html
Normal file
@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>{{.Network}}: GitHub Faucet</title>
|
||||
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" />
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-noty/2.4.1/packaged/jquery.noty.packaged.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.0/moment.min.js"></script>
|
||||
|
||||
<style>
|
||||
.vertical-center {
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.progress {
|
||||
position: relative;
|
||||
}
|
||||
.progress span {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: white;
|
||||
}
|
||||
pre {
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="vertical-center">
|
||||
<div class="container">
|
||||
<div class="row" style="margin-bottom: 16px;">
|
||||
<div class="col-lg-12">
|
||||
<h1 style="text-align: center;"><i class="fa fa-bath" aria-hidden="true"></i> {{.Network}} GitHub Authenticated Faucet <i class="fa fa-github-alt" aria-hidden="true"></i></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-8 col-lg-offset-2">
|
||||
<div class="input-group">
|
||||
<input id="gist" type="text" class="form-control" placeholder="GitHub Gist URL containing your Ethereum address...">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-default" type="button" onclick="submit()">Give me Ether!</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 32px;">
|
||||
<div class="col-lg-6 col-lg-offset-3">
|
||||
<div class="panel panel-small panel-default">
|
||||
<div class="panel-body" style="padding: 0; overflow: auto; max-height: 300px;">
|
||||
<table id="requests" class="table table-condensed" style="margin: 0;"></table>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<table style="width: 100%"><tr>
|
||||
<td style="text-align: center;"><i class="fa fa-rss" aria-hidden="true"></i> <span id="peers"></span> peers</td>
|
||||
<td style="text-align: center;"><i class="fa fa-database" aria-hidden="true"></i> <span id="block"></span> blocks</td>
|
||||
<td style="text-align: center;"><i class="fa fa-heartbeat" aria-hidden="true"></i> <span id="funds"></span> Ethers</td>
|
||||
<td style="text-align: center;"><i class="fa fa-university" aria-hidden="true"></i> <span id="funded"></span> funded</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 32px;">
|
||||
<div class="col-lg-12">
|
||||
<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>.</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables to hold the current status of the faucet
|
||||
var attempt = 0;
|
||||
var server;
|
||||
|
||||
// Define the function that submits a gist url to the server
|
||||
var submit = function() {
|
||||
server.send(JSON.stringify({url: $("#gist")[0].value}));
|
||||
};
|
||||
// Define a method to reconnect upon server loss
|
||||
var reconnect = function() {
|
||||
if (attempt % 2 == 0) {
|
||||
server = new WebSocket("wss://" + location.host + "/api");
|
||||
} else {
|
||||
server = new WebSocket("ws://" + location.host + "/api");
|
||||
}
|
||||
attempt++;
|
||||
|
||||
server.onmessage = function(event) {
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.funds !== undefined) {
|
||||
$("#funds").text(msg.funds);
|
||||
}
|
||||
if (msg.funded !== undefined) {
|
||||
$("#funded").text(msg.funded);
|
||||
}
|
||||
if (msg.peers !== undefined) {
|
||||
$("#peers").text(msg.peers);
|
||||
}
|
||||
if (msg.number !== undefined) {
|
||||
$("#block").text(parseInt(msg.number, 16));
|
||||
}
|
||||
if (msg.error !== undefined) {
|
||||
noty({layout: 'topCenter', text: msg.error, type: 'error'});
|
||||
}
|
||||
if (msg.success !== undefined) {
|
||||
noty({layout: 'topCenter', text: msg.success, type: 'success'});
|
||||
}
|
||||
if (msg.requests !== undefined && msg.requests !== null) {
|
||||
var content = "";
|
||||
for (var i=0; i<msg.requests.length; i++) {
|
||||
content += "<tr><td><div style=\"background: url('https://github.com/" + msg.requests[i].username + ".png?size=64'); background-size: cover; width:32px; height: 32px; border-radius: 4px;\"></div></td><td><pre>" + msg.requests[i].account + "</pre></td><td style=\"width: 100%; text-align: center; vertical-align: middle;\">" + moment.duration(moment(msg.requests[i].time).unix()-moment().unix(), 'seconds').humanize(true) + "</td></tr>";
|
||||
}
|
||||
$("#requests").html("<tbody>" + content + "</tbody>");
|
||||
}
|
||||
}
|
||||
server.onclose = function() { setTimeout(reconnect, 3000); };
|
||||
server.onerror = function() { setTimeout(reconnect, 3000); };
|
||||
}
|
||||
// Establish a websocket connection to the API server
|
||||
reconnect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
235
cmd/faucet/website.go
Normal file
235
cmd/faucet/website.go
Normal file
File diff suppressed because one or more lines are too long
152
cmd/puppeth/module.go
Normal file
152
cmd/puppeth/module.go
Normal file
@ -0,0 +1,152 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceUnknown is returned when a service container doesn't exist.
|
||||
ErrServiceUnknown = errors.New("service unknown")
|
||||
|
||||
// ErrServiceOffline is returned when a service container exists, but it is not
|
||||
// running.
|
||||
ErrServiceOffline = errors.New("service offline")
|
||||
|
||||
// ErrServiceUnreachable is returned when a service container is running, but
|
||||
// seems to not respond to communication attempts.
|
||||
ErrServiceUnreachable = errors.New("service unreachable")
|
||||
|
||||
// ErrNotExposed is returned if a web-service doesn't have an exposed port, nor
|
||||
// a reverse-proxy in front of it to forward requests.
|
||||
ErrNotExposed = errors.New("service not exposed, nor proxied")
|
||||
)
|
||||
|
||||
// containerInfos is a heavily reduced version of the huge inspection dataset
|
||||
// returned from docker inspect, parsed into a form easily usable by puppeth.
|
||||
type containerInfos struct {
|
||||
running bool // Flag whether the container is running currently
|
||||
envvars map[string]string // Collection of environmental variables set on the container
|
||||
portmap map[string]int // Port mapping from internal port/proto combos to host binds
|
||||
volumes map[string]string // Volume mount points from container to host directories
|
||||
}
|
||||
|
||||
// inspectContainer runs docker inspect against a running container
|
||||
func inspectContainer(client *sshClient, container string) (*containerInfos, error) {
|
||||
// Check whether there's a container running for the service
|
||||
out, err := client.Run(fmt.Sprintf("docker inspect %s", container))
|
||||
if err != nil {
|
||||
return nil, ErrServiceUnknown
|
||||
}
|
||||
// If yes, extract various configuration options
|
||||
type inspection struct {
|
||||
State struct {
|
||||
Running bool
|
||||
}
|
||||
Mounts []struct {
|
||||
Source string
|
||||
Destination string
|
||||
}
|
||||
Config struct {
|
||||
Env []string
|
||||
}
|
||||
HostConfig struct {
|
||||
PortBindings map[string][]map[string]string
|
||||
}
|
||||
}
|
||||
var inspects []inspection
|
||||
if err = json.Unmarshal(out, &inspects); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inspect := inspects[0]
|
||||
|
||||
// Infos retrieved, parse the above into something meaningful
|
||||
infos := &containerInfos{
|
||||
running: inspect.State.Running,
|
||||
envvars: make(map[string]string),
|
||||
portmap: make(map[string]int),
|
||||
volumes: make(map[string]string),
|
||||
}
|
||||
for _, envvar := range inspect.Config.Env {
|
||||
if parts := strings.Split(envvar, "="); len(parts) == 2 {
|
||||
infos.envvars[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
for portname, details := range inspect.HostConfig.PortBindings {
|
||||
if len(details) > 0 {
|
||||
port, _ := strconv.Atoi(details[0]["HostPort"])
|
||||
infos.portmap[portname] = port
|
||||
}
|
||||
}
|
||||
for _, mount := range inspect.Mounts {
|
||||
infos.volumes[mount.Destination] = mount.Source
|
||||
}
|
||||
return infos, err
|
||||
}
|
||||
|
||||
// tearDown connects to a remote machine via SSH and terminates docker containers
|
||||
// running with the specified name in the specified network.
|
||||
func tearDown(client *sshClient, network string, service string, purge bool) ([]byte, error) {
|
||||
// Tear down the running (or paused) container
|
||||
out, err := client.Run(fmt.Sprintf("docker rm -f %s_%s_1", network, service))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// If requested, purge the associated docker image too
|
||||
if purge {
|
||||
return client.Run(fmt.Sprintf("docker rmi %s/%s", network, service))
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// resolve retrieves the hostname a service is running on either by returning the
|
||||
// actual server name and port, or preferably an nginx virtual host if available.
|
||||
func resolve(client *sshClient, network string, service string, port int) (string, error) {
|
||||
// Inspect the service to get various configurations from it
|
||||
infos, err := inspectContainer(client, fmt.Sprintf("%s_%s_1", network, service))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !infos.running {
|
||||
return "", ErrServiceOffline
|
||||
}
|
||||
// Container online, extract any environmental variables
|
||||
if vhost := infos.envvars["VIRTUAL_HOST"]; vhost != "" {
|
||||
return vhost, nil
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", client.server, port), nil
|
||||
}
|
||||
|
||||
// checkPort tries to connect to a remote host on a given
|
||||
func checkPort(host string, port int) error {
|
||||
log.Trace("Verifying remote TCP connectivity", "server", host, "port", port)
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
537
cmd/puppeth/module_dashboard.go
Normal file
537
cmd/puppeth/module_dashboard.go
Normal file
File diff suppressed because one or more lines are too long
159
cmd/puppeth/module_ethstats.go
Normal file
159
cmd/puppeth/module_ethstats.go
Normal file
@ -0,0 +1,159 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// ethstatsDockerfile is the Dockerfile required to build an ethstats backend
|
||||
// and associated monitoring site.
|
||||
var ethstatsDockerfile = `
|
||||
FROM mhart/alpine-node:latest
|
||||
|
||||
RUN \
|
||||
apk add --update git && \
|
||||
git clone --depth=1 https://github.com/karalabe/eth-netstats && \
|
||||
apk del git && rm -rf /var/cache/apk/* && \
|
||||
\
|
||||
cd /eth-netstats && npm install && npm install -g grunt-cli && grunt
|
||||
|
||||
WORKDIR /eth-netstats
|
||||
EXPOSE 3000
|
||||
|
||||
RUN echo 'module.exports = {trusted: [{{.Trusted}}], banned: []};' > lib/utils/config.js
|
||||
|
||||
CMD ["npm", "start"]
|
||||
`
|
||||
|
||||
// ethstatsComposefile is the docker-compose.yml file required to deploy and
|
||||
// maintain an ethstats monitoring site.
|
||||
var ethstatsComposefile = `
|
||||
version: '2'
|
||||
services:
|
||||
ethstats:
|
||||
build: .
|
||||
image: {{.Network}}/ethstats{{if not .VHost}}
|
||||
ports:
|
||||
- "{{.Port}}:3000"{{end}}
|
||||
environment:
|
||||
- WS_SECRET={{.Secret}}{{if .VHost}}
|
||||
- VIRTUAL_HOST={{.VHost}}{{end}}
|
||||
restart: always
|
||||
`
|
||||
|
||||
// deployEthstats deploys a new ethstats container to a remote machine via SSH,
|
||||
// docker and docker-compose. If an instance with the specified network name
|
||||
// already exists there, it will be overwritten!
|
||||
func deployEthstats(client *sshClient, network string, port int, secret string, vhost string, trusted []string) ([]byte, error) {
|
||||
// Generate the content to upload to the server
|
||||
workdir := fmt.Sprintf("%d", rand.Int63())
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for i, address := range trusted {
|
||||
trusted[i] = fmt.Sprintf("\"%s\"", address)
|
||||
}
|
||||
|
||||
dockerfile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(ethstatsDockerfile)).Execute(dockerfile, map[string]interface{}{
|
||||
"Trusted": strings.Join(trusted, ", "),
|
||||
})
|
||||
files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes()
|
||||
|
||||
composefile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(ethstatsComposefile)).Execute(composefile, map[string]interface{}{
|
||||
"Network": network,
|
||||
"Port": port,
|
||||
"Secret": secret,
|
||||
"VHost": vhost,
|
||||
})
|
||||
files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes()
|
||||
|
||||
// Upload the deployment files to the remote server (and clean up afterwards)
|
||||
if out, err := client.Upload(files); err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer client.Run("rm -rf " + workdir)
|
||||
|
||||
// Build and deploy the ethstats service
|
||||
return nil, client.Stream(fmt.Sprintf("cd %s && docker-compose -p %s up -d --build", workdir, network))
|
||||
}
|
||||
|
||||
// ethstatsInfos is returned from an ethstats status check to allow reporting
|
||||
// various configuration parameters.
|
||||
type ethstatsInfos struct {
|
||||
host string
|
||||
port int
|
||||
secret string
|
||||
config string
|
||||
}
|
||||
|
||||
// String implements the stringer interface.
|
||||
func (info *ethstatsInfos) String() string {
|
||||
return fmt.Sprintf("host=%s, port=%d, secret=%s", info.host, info.port, info.secret)
|
||||
}
|
||||
|
||||
// checkEthstats does a health-check against an ethstats server to verify whether
|
||||
// it's running, and if yes, gathering a collection of useful infos about it.
|
||||
func checkEthstats(client *sshClient, network string) (*ethstatsInfos, error) {
|
||||
// Inspect a possible ethstats container on the host
|
||||
infos, err := inspectContainer(client, fmt.Sprintf("%s_ethstats_1", network))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !infos.running {
|
||||
return nil, ErrServiceOffline
|
||||
}
|
||||
// Resolve the port from the host, or the reverse proxy
|
||||
port := infos.portmap["3000/tcp"]
|
||||
if port == 0 {
|
||||
if proxy, _ := checkNginx(client, network); proxy != nil {
|
||||
port = proxy.port
|
||||
}
|
||||
}
|
||||
if port == 0 {
|
||||
return nil, ErrNotExposed
|
||||
}
|
||||
// Resolve the host from the reverse-proxy and configure the connection string
|
||||
host := infos.envvars["VIRTUAL_HOST"]
|
||||
if host == "" {
|
||||
host = client.server
|
||||
}
|
||||
secret := infos.envvars["WS_SECRET"]
|
||||
config := fmt.Sprintf("%s@%s", secret, host)
|
||||
if port != 80 && port != 443 {
|
||||
config += fmt.Sprintf(":%d", port)
|
||||
}
|
||||
// Run a sanity check to see if the port is reachable
|
||||
if err = checkPort(host, port); err != nil {
|
||||
log.Warn("Ethstats service seems unreachable", "server", host, "port", port, "err", err)
|
||||
}
|
||||
// Container available, assemble and return the useful infos
|
||||
return ðstatsInfos{
|
||||
host: host,
|
||||
port: port,
|
||||
secret: secret,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
210
cmd/puppeth/module_faucet.go
Normal file
210
cmd/puppeth/module_faucet.go
Normal file
@ -0,0 +1,210 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// faucetDockerfile is the Dockerfile required to build an faucet container to
|
||||
// grant crypto tokens based on GitHub authentications.
|
||||
var faucetDockerfile = `
|
||||
FROM alpine:latest
|
||||
|
||||
RUN mkdir /go
|
||||
ENV GOPATH /go
|
||||
|
||||
RUN \
|
||||
apk add --update git go make gcc musl-dev ca-certificates linux-headers && \
|
||||
mkdir -p $GOPATH/src/github.com/ethereum && \
|
||||
(cd $GOPATH/src/github.com/ethereum && git clone --depth=1 https://github.com/ethereum/go-ethereum) && \
|
||||
go build -v github.com/ethereum/go-ethereum/cmd/faucet && \
|
||||
apk del git go make gcc musl-dev linux-headers && \
|
||||
rm -rf $GOPATH && rm -rf /var/cache/apk/*
|
||||
|
||||
ADD genesis.json /genesis.json
|
||||
ADD account.json /account.json
|
||||
ADD account.pass /account.pass
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD [ \
|
||||
"/faucet", "--genesis", "/genesis.json", "--network", "{{.NetworkID}}", "--bootnodes", "{{.Bootnodes}}", "--ethstats", "{{.Ethstats}}", \
|
||||
"--ethport", "{{.EthPort}}", "--faucet.name", "{{.FaucetName}}", "--faucet.amount", "{{.FaucetAmount}}", "--faucet.minutes", "{{.FaucetMinutes}}", \
|
||||
"--github.user", "{{.GitHubUser}}", "--github.token", "{{.GitHubToken}}", "--account.json", "/account.json", "--account.pass", "/account.pass" \
|
||||
]`
|
||||
|
||||
// faucetComposefile is the docker-compose.yml file required to deploy and maintain
|
||||
// a crypto faucet.
|
||||
var faucetComposefile = `
|
||||
version: '2'
|
||||
services:
|
||||
faucet:
|
||||
build: .
|
||||
image: {{.Network}}/faucet
|
||||
ports:
|
||||
- "{{.EthPort}}:{{.EthPort}}"{{if not .VHost}}
|
||||
- "{{.ApiPort}}:8080"{{end}}
|
||||
volumes:
|
||||
- {{.Datadir}}:/root/.faucet
|
||||
environment:
|
||||
- ETH_PORT={{.EthPort}}
|
||||
- ETH_NAME={{.EthName}}
|
||||
- FAUCET_AMOUNT={{.FaucetAmount}}
|
||||
- FAUCET_MINUTES={{.FaucetMinutes}}
|
||||
- GITHUB_USER={{.GitHubUser}}
|
||||
- GITHUB_TOKEN={{.GitHubToken}}{{if .VHost}}
|
||||
- VIRTUAL_HOST={{.VHost}}
|
||||
- VIRTUAL_PORT=8080{{end}}
|
||||
restart: always
|
||||
`
|
||||
|
||||
// deployFaucet deploys a new faucet container to a remote machine via SSH,
|
||||
// docker and docker-compose. If an instance with the specified network name
|
||||
// already exists there, it will be overwritten!
|
||||
func deployFaucet(client *sshClient, network string, bootnodes []string, config *faucetInfos) ([]byte, error) {
|
||||
// Generate the content to upload to the server
|
||||
workdir := fmt.Sprintf("%d", rand.Int63())
|
||||
files := make(map[string][]byte)
|
||||
|
||||
dockerfile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(faucetDockerfile)).Execute(dockerfile, map[string]interface{}{
|
||||
"NetworkID": config.node.network,
|
||||
"Bootnodes": strings.Join(bootnodes, ","),
|
||||
"Ethstats": config.node.ethstats,
|
||||
"EthPort": config.node.portFull,
|
||||
"GitHubUser": config.githubUser,
|
||||
"GitHubToken": config.githubToken,
|
||||
"FaucetName": strings.Title(network),
|
||||
"FaucetAmount": config.amount,
|
||||
"FaucetMinutes": config.minutes,
|
||||
})
|
||||
files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes()
|
||||
|
||||
composefile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(faucetComposefile)).Execute(composefile, map[string]interface{}{
|
||||
"Network": network,
|
||||
"Datadir": config.node.datadir,
|
||||
"VHost": config.host,
|
||||
"ApiPort": config.port,
|
||||
"EthPort": config.node.portFull,
|
||||
"EthName": config.node.ethstats[:strings.Index(config.node.ethstats, ":")],
|
||||
"GitHubUser": config.githubUser,
|
||||
"GitHubToken": config.githubToken,
|
||||
"FaucetAmount": config.amount,
|
||||
"FaucetMinutes": config.minutes,
|
||||
})
|
||||
files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes()
|
||||
|
||||
files[filepath.Join(workdir, "genesis.json")] = []byte(config.node.genesis)
|
||||
files[filepath.Join(workdir, "account.json")] = []byte(config.node.keyJSON)
|
||||
files[filepath.Join(workdir, "account.pass")] = []byte(config.node.keyPass)
|
||||
|
||||
// Upload the deployment files to the remote server (and clean up afterwards)
|
||||
if out, err := client.Upload(files); err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer client.Run("rm -rf " + workdir)
|
||||
|
||||
// Build and deploy the faucet service
|
||||
return nil, client.Stream(fmt.Sprintf("cd %s && docker-compose -p %s up -d --build", workdir, network))
|
||||
}
|
||||
|
||||
// faucetInfos is returned from an faucet status check to allow reporting various
|
||||
// configuration parameters.
|
||||
type faucetInfos struct {
|
||||
node *nodeInfos
|
||||
host string
|
||||
port int
|
||||
amount int
|
||||
minutes int
|
||||
githubUser string
|
||||
githubToken string
|
||||
}
|
||||
|
||||
// String implements the stringer interface.
|
||||
func (info *faucetInfos) String() string {
|
||||
return fmt.Sprintf("host=%s, api=%d, eth=%d, amount=%d, minutes=%d, github=%s, ethstats=%s", info.host, info.port, info.node.portFull, info.amount, info.minutes, info.githubUser, info.node.ethstats)
|
||||
}
|
||||
|
||||
// checkFaucet does a health-check against an faucet server to verify whether
|
||||
// it's running, and if yes, gathering a collection of useful infos about it.
|
||||
func checkFaucet(client *sshClient, network string) (*faucetInfos, error) {
|
||||
// Inspect a possible faucet container on the host
|
||||
infos, err := inspectContainer(client, fmt.Sprintf("%s_faucet_1", network))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !infos.running {
|
||||
return nil, ErrServiceOffline
|
||||
}
|
||||
// Resolve the port from the host, or the reverse proxy
|
||||
port := infos.portmap["8080/tcp"]
|
||||
if port == 0 {
|
||||
if proxy, _ := checkNginx(client, network); proxy != nil {
|
||||
port = proxy.port
|
||||
}
|
||||
}
|
||||
if port == 0 {
|
||||
return nil, ErrNotExposed
|
||||
}
|
||||
// Resolve the host from the reverse-proxy and the config values
|
||||
host := infos.envvars["VIRTUAL_HOST"]
|
||||
if host == "" {
|
||||
host = client.server
|
||||
}
|
||||
amount, _ := strconv.Atoi(infos.envvars["FAUCET_AMOUNT"])
|
||||
minutes, _ := strconv.Atoi(infos.envvars["FAUCET_MINUTES"])
|
||||
|
||||
// Retrieve the funding account informations
|
||||
var out []byte
|
||||
keyJSON, keyPass := "", ""
|
||||
if out, err = client.Run(fmt.Sprintf("docker exec %s_faucet_1 cat /account.json", network)); err == nil {
|
||||
keyJSON = string(bytes.TrimSpace(out))
|
||||
}
|
||||
if out, err = client.Run(fmt.Sprintf("docker exec %s_faucet_1 cat /account.pass", network)); err == nil {
|
||||
keyPass = string(bytes.TrimSpace(out))
|
||||
}
|
||||
// Run a sanity check to see if the port is reachable
|
||||
if err = checkPort(host, port); err != nil {
|
||||
log.Warn("Faucet service seems unreachable", "server", host, "port", port, "err", err)
|
||||
}
|
||||
// Container available, assemble and return the useful infos
|
||||
return &faucetInfos{
|
||||
node: &nodeInfos{
|
||||
datadir: infos.volumes["/root/.faucet"],
|
||||
portFull: infos.portmap[infos.envvars["ETH_PORT"]+"/tcp"],
|
||||
ethstats: infos.envvars["ETH_NAME"],
|
||||
keyJSON: keyJSON,
|
||||
keyPass: keyPass,
|
||||
},
|
||||
host: host,
|
||||
port: port,
|
||||
amount: amount,
|
||||
minutes: minutes,
|
||||
githubUser: infos.envvars["GITHUB_USER"],
|
||||
githubToken: infos.envvars["GITHUB_TOKEN"],
|
||||
}, nil
|
||||
}
|
106
cmd/puppeth/module_nginx.go
Normal file
106
cmd/puppeth/module_nginx.go
Normal file
@ -0,0 +1,106 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// nginxDockerfile is theis the Dockerfile required to build an nginx reverse-
|
||||
// proxy.
|
||||
var nginxDockerfile = `FROM jwilder/nginx-proxy`
|
||||
|
||||
// nginxComposefile is the docker-compose.yml file required to deploy and maintain
|
||||
// an nginx reverse-proxy. The proxy is responsible for exposing one or more HTTP
|
||||
// services running on a single host.
|
||||
var nginxComposefile = `
|
||||
version: '2'
|
||||
services:
|
||||
nginx:
|
||||
build: .
|
||||
image: {{.Network}}/nginx
|
||||
ports:
|
||||
- "{{.Port}}:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
restart: always
|
||||
`
|
||||
|
||||
// deployNginx deploys a new nginx reverse-proxy container to expose one or more
|
||||
// HTTP services running on a single host. If an instance with the specified
|
||||
// network name already exists there, it will be overwritten!
|
||||
func deployNginx(client *sshClient, network string, port int) ([]byte, error) {
|
||||
log.Info("Deploying nginx reverse-proxy", "server", client.server, "port", port)
|
||||
|
||||
// Generate the content to upload to the server
|
||||
workdir := fmt.Sprintf("%d", rand.Int63())
|
||||
files := make(map[string][]byte)
|
||||
|
||||
dockerfile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(nginxDockerfile)).Execute(dockerfile, nil)
|
||||
files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes()
|
||||
|
||||
composefile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(nginxComposefile)).Execute(composefile, map[string]interface{}{
|
||||
"Network": network,
|
||||
"Port": port,
|
||||
})
|
||||
files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes()
|
||||
|
||||
// Upload the deployment files to the remote server (and clean up afterwards)
|
||||
if out, err := client.Upload(files); err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer client.Run("rm -rf " + workdir)
|
||||
|
||||
// Build and deploy the ethstats service
|
||||
return nil, client.Stream(fmt.Sprintf("cd %s && docker-compose -p %s up -d --build", workdir, network))
|
||||
}
|
||||
|
||||
// nginxInfos is returned from an nginx reverse-proxy status check to allow
|
||||
// reporting various configuration parameters.
|
||||
type nginxInfos struct {
|
||||
port int
|
||||
}
|
||||
|
||||
// String implements the stringer interface.
|
||||
func (info *nginxInfos) String() string {
|
||||
return fmt.Sprintf("port=%d", info.port)
|
||||
}
|
||||
|
||||
// checkNginx does a health-check against an nginx reverse-proxy to verify whether
|
||||
// it's running, and if yes, gathering a collection of useful infos about it.
|
||||
func checkNginx(client *sshClient, network string) (*nginxInfos, error) {
|
||||
// Inspect a possible nginx container on the host
|
||||
infos, err := inspectContainer(client, fmt.Sprintf("%s_nginx_1", network))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !infos.running {
|
||||
return nil, ErrServiceOffline
|
||||
}
|
||||
// Container available, assemble and return the useful infos
|
||||
return &nginxInfos{
|
||||
port: infos.portmap["80/tcp"],
|
||||
}, nil
|
||||
}
|
222
cmd/puppeth/module_node.go
Normal file
222
cmd/puppeth/module_node.go
Normal file
@ -0,0 +1,222 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// nodeDockerfile is the Dockerfile required to run an Ethereum node.
|
||||
var nodeDockerfile = `
|
||||
FROM ethereum/client-go:alpine-develop
|
||||
|
||||
ADD genesis.json /genesis.json
|
||||
{{if .Unlock}}
|
||||
ADD signer.json /signer.json
|
||||
ADD signer.pass /signer.pass
|
||||
{{end}}
|
||||
RUN \
|
||||
echo '/geth init /genesis.json' > geth.sh && \{{if .Unlock}}
|
||||
echo 'mkdir -p /root/.ethereum/keystore/ && cp /signer.json /root/.ethereum/keystore/' >> geth.sh && \{{end}}
|
||||
echo $'/geth --networkid {{.NetworkID}} --cache 512 --port {{.Port}} --maxpeers {{.Peers}} {{.LightFlag}} --ethstats \'{{.Ethstats}}\' {{if .Bootnodes}}--bootnodes {{.Bootnodes}}{{end}} {{if .Etherbase}}--etherbase {{.Etherbase}} --mine{{end}}{{if .Unlock}}--unlock 0 --password /signer.pass --mine{{end}}' >> geth.sh
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "geth.sh"]
|
||||
`
|
||||
|
||||
// nodeComposefile is the docker-compose.yml file required to deploy and maintain
|
||||
// an Ethereum node (bootnode or miner for now).
|
||||
var nodeComposefile = `
|
||||
version: '2'
|
||||
services:
|
||||
{{.Type}}:
|
||||
build: .
|
||||
image: {{.Network}}/{{.Type}}
|
||||
ports:
|
||||
- "{{.FullPort}}:{{.FullPort}}"
|
||||
- "{{.FullPort}}:{{.FullPort}}/udp"{{if .Light}}
|
||||
- "{{.LightPort}}:{{.LightPort}}/udp"{{end}}
|
||||
volumes:
|
||||
- {{.Datadir}}:/root/.ethereum
|
||||
environment:
|
||||
- FULL_PORT={{.FullPort}}/tcp
|
||||
- LIGHT_PORT={{.LightPort}}/udp
|
||||
- TOTAL_PEERS={{.TotalPeers}}
|
||||
- LIGHT_PEERS={{.LightPeers}}
|
||||
- STATS_NAME={{.Ethstats}}
|
||||
- MINER_NAME={{.Etherbase}}
|
||||
restart: always
|
||||
`
|
||||
|
||||
// deployNode deploys a new Ethereum node container to a remote machine via SSH,
|
||||
// docker and docker-compose. If an instance with the specified network name
|
||||
// already exists there, it will be overwritten!
|
||||
func deployNode(client *sshClient, network string, bootnodes []string, config *nodeInfos) ([]byte, error) {
|
||||
kind := "sealnode"
|
||||
if config.keyJSON == "" && config.etherbase == "" {
|
||||
kind = "bootnode"
|
||||
bootnodes = make([]string, 0)
|
||||
}
|
||||
// Generate the content to upload to the server
|
||||
workdir := fmt.Sprintf("%d", rand.Int63())
|
||||
files := make(map[string][]byte)
|
||||
|
||||
lightFlag := ""
|
||||
if config.peersLight > 0 {
|
||||
lightFlag = fmt.Sprintf("--lightpeers=%d --lightserv=50", config.peersLight)
|
||||
}
|
||||
dockerfile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(nodeDockerfile)).Execute(dockerfile, map[string]interface{}{
|
||||
"NetworkID": config.network,
|
||||
"Port": config.portFull,
|
||||
"Peers": config.peersTotal,
|
||||
"LightFlag": lightFlag,
|
||||
"Bootnodes": strings.Join(bootnodes, ","),
|
||||
"Ethstats": config.ethstats,
|
||||
"Etherbase": config.etherbase,
|
||||
"Unlock": config.keyJSON != "",
|
||||
})
|
||||
files[filepath.Join(workdir, "Dockerfile")] = dockerfile.Bytes()
|
||||
|
||||
composefile := new(bytes.Buffer)
|
||||
template.Must(template.New("").Parse(nodeComposefile)).Execute(composefile, map[string]interface{}{
|
||||
"Type": kind,
|
||||
"Datadir": config.datadir,
|
||||
"Network": network,
|
||||
"FullPort": config.portFull,
|
||||
"TotalPeers": config.peersTotal,
|
||||
"Light": config.peersLight > 0,
|
||||
"LightPort": config.portFull + 1,
|
||||
"LightPeers": config.peersLight,
|
||||
"Ethstats": config.ethstats[:strings.Index(config.ethstats, ":")],
|
||||
"Etherbase": config.etherbase,
|
||||
})
|
||||
files[filepath.Join(workdir, "docker-compose.yaml")] = composefile.Bytes()
|
||||
|
||||
//genesisfile, _ := json.MarshalIndent(config.genesis, "", " ")
|
||||
files[filepath.Join(workdir, "genesis.json")] = []byte(config.genesis)
|
||||
|
||||
if config.keyJSON != "" {
|
||||
files[filepath.Join(workdir, "signer.json")] = []byte(config.keyJSON)
|
||||
files[filepath.Join(workdir, "signer.pass")] = []byte(config.keyPass)
|
||||
}
|
||||
// Upload the deployment files to the remote server (and clean up afterwards)
|
||||
if out, err := client.Upload(files); err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer client.Run("rm -rf " + workdir)
|
||||
|
||||
// Build and deploy the bootnode service
|
||||
return nil, client.Stream(fmt.Sprintf("cd %s && docker-compose -p %s up -d --build", workdir, network))
|
||||
}
|
||||
|
||||
// nodeInfos is returned from a boot or seal node status check to allow reporting
|
||||
// various configuration parameters.
|
||||
type nodeInfos struct {
|
||||
genesis []byte
|
||||
network int64
|
||||
datadir string
|
||||
ethstats string
|
||||
portFull int
|
||||
portLight int
|
||||
enodeFull string
|
||||
enodeLight string
|
||||
peersTotal int
|
||||
peersLight int
|
||||
etherbase string
|
||||
keyJSON string
|
||||
keyPass string
|
||||
}
|
||||
|
||||
// String implements the stringer interface.
|
||||
func (info *nodeInfos) String() string {
|
||||
discv5 := ""
|
||||
if info.peersLight > 0 {
|
||||
discv5 = fmt.Sprintf(", portv5=%d", info.portLight)
|
||||
}
|
||||
return fmt.Sprintf("port=%d%s, datadir=%s, peers=%d, lights=%d, ethstats=%s", info.portFull, discv5, info.datadir, info.peersTotal, info.peersLight, info.ethstats)
|
||||
}
|
||||
|
||||
// checkNode does a health-check against an boot or seal node server to verify
|
||||
// whether it's running, and if yes, whether it's responsive.
|
||||
func checkNode(client *sshClient, network string, boot bool) (*nodeInfos, error) {
|
||||
kind := "bootnode"
|
||||
if !boot {
|
||||
kind = "sealnode"
|
||||
}
|
||||
// Inspect a possible bootnode container on the host
|
||||
infos, err := inspectContainer(client, fmt.Sprintf("%s_%s_1", network, kind))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !infos.running {
|
||||
return nil, ErrServiceOffline
|
||||
}
|
||||
// Resolve a few types from the environmental variables
|
||||
totalPeers, _ := strconv.Atoi(infos.envvars["TOTAL_PEERS"])
|
||||
lightPeers, _ := strconv.Atoi(infos.envvars["LIGHT_PEERS"])
|
||||
|
||||
// Container available, retrieve its node ID and its genesis json
|
||||
var out []byte
|
||||
if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 /geth --exec admin.nodeInfo.id attach", network, kind)); err != nil {
|
||||
return nil, ErrServiceUnreachable
|
||||
}
|
||||
id := bytes.Trim(bytes.TrimSpace(out), "\"")
|
||||
|
||||
if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 cat /genesis.json", network, kind)); err != nil {
|
||||
return nil, ErrServiceUnreachable
|
||||
}
|
||||
genesis := bytes.TrimSpace(out)
|
||||
|
||||
keyJSON, keyPass := "", ""
|
||||
if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 cat /signer.json", network, kind)); err == nil {
|
||||
keyJSON = string(bytes.TrimSpace(out))
|
||||
}
|
||||
if out, err = client.Run(fmt.Sprintf("docker exec %s_%s_1 cat /signer.pass", network, kind)); err == nil {
|
||||
keyPass = string(bytes.TrimSpace(out))
|
||||
}
|
||||
// Run a sanity check to see if the devp2p is reachable
|
||||
port := infos.portmap[infos.envvars["FULL_PORT"]]
|
||||
if err = checkPort(client.server, port); err != nil {
|
||||
log.Warn(fmt.Sprintf("%s devp2p port seems unreachable", strings.Title(kind)), "server", client.server, "port", port, "err", err)
|
||||
}
|
||||
// Assemble and return the useful infos
|
||||
stats := &nodeInfos{
|
||||
genesis: genesis,
|
||||
datadir: infos.volumes["/root/.ethereum"],
|
||||
portFull: infos.portmap[infos.envvars["FULL_PORT"]],
|
||||
portLight: infos.portmap[infos.envvars["LIGHT_PORT"]],
|
||||
peersTotal: totalPeers,
|
||||
peersLight: lightPeers,
|
||||
ethstats: infos.envvars["STATS_NAME"],
|
||||
etherbase: infos.envvars["MINER_NAME"],
|
||||
keyJSON: keyJSON,
|
||||
keyPass: keyPass,
|
||||
}
|
||||
stats.enodeFull = fmt.Sprintf("enode://%s@%s:%d", id, client.address, stats.portFull)
|
||||
if stats.portLight != 0 {
|
||||
stats.enodeLight = fmt.Sprintf("enode://%s@%s:%d?discport=%d", id, client.address, stats.portFull, stats.portLight)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
55
cmd/puppeth/puppeth.go
Normal file
55
cmd/puppeth/puppeth.go
Normal file
@ -0,0 +1,55 @@
|
||||
// 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/>.
|
||||
|
||||
// puppeth is a command to assemble and maintain private networks.
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
"gopkg.in/urfave/cli.v1"
|
||||
)
|
||||
|
||||
// main is just a boring entry point to set up the CLI app.
|
||||
func main() {
|
||||
app := cli.NewApp()
|
||||
app.Name = "puppeth"
|
||||
app.Usage = "assemble and maintain private Ethereum networks"
|
||||
app.Flags = []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "network",
|
||||
Usage: "name of the network to administer",
|
||||
},
|
||||
cli.IntFlag{
|
||||
Name: "loglevel",
|
||||
Value: 4,
|
||||
Usage: "log level to emit to the screen",
|
||||
},
|
||||
}
|
||||
app.Action = func(c *cli.Context) error {
|
||||
// Set up the logger to print everything and the random generator
|
||||
log.Root().SetHandler(log.LvlFilterHandler(log.Lvl(c.Int("loglevel")), log.StreamHandler(os.Stdout, log.TerminalFormat(true))))
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
// Start the wizard and relinquish control
|
||||
makeWizard(c.String("network")).run()
|
||||
return nil
|
||||
}
|
||||
app.Run(os.Args)
|
||||
}
|
195
cmd/puppeth/ssh.go
Normal file
195
cmd/puppeth/ssh.go
Normal file
@ -0,0 +1,195 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
)
|
||||
|
||||
// sshClient is a small wrapper around Go's SSH client with a few utility methods
|
||||
// implemented on top.
|
||||
type sshClient struct {
|
||||
server string // Server name or IP without port number
|
||||
address string // IP address of the remote server
|
||||
client *ssh.Client
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// dial establishes an SSH connection to a remote node using the current user and
|
||||
// the user's configured private RSA key.
|
||||
func dial(server string) (*sshClient, error) {
|
||||
// Figure out a label for the server and a logger
|
||||
label := server
|
||||
if strings.Contains(label, ":") {
|
||||
label = label[:strings.Index(label, ":")]
|
||||
}
|
||||
logger := log.New("server", label)
|
||||
logger.Debug("Attempting to establish SSH connection")
|
||||
|
||||
user, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Configure the supported authentication methods (private key and password)
|
||||
var auths []ssh.AuthMethod
|
||||
|
||||
path := filepath.Join(user.HomeDir, ".ssh", "id_rsa")
|
||||
if buf, err := ioutil.ReadFile(path); err != nil {
|
||||
log.Warn("No SSH key, falling back to passwords", "path", path, "err", err)
|
||||
} else {
|
||||
key, err := ssh.ParsePrivateKey(buf)
|
||||
if err != nil {
|
||||
log.Warn("Bad SSH key, falling back to passwords", "path", path, "err", err)
|
||||
} else {
|
||||
auths = append(auths, ssh.PublicKeys(key))
|
||||
}
|
||||
}
|
||||
auths = append(auths, ssh.PasswordCallback(func() (string, error) {
|
||||
fmt.Printf("What's the login password for %s at %s? (won't be echoed)\n> ", user.Username, server)
|
||||
blob, err := terminal.ReadPassword(int(syscall.Stdin))
|
||||
|
||||
fmt.Println()
|
||||
return string(blob), err
|
||||
}))
|
||||
// Resolve the IP address of the remote server
|
||||
addr, err := net.LookupHost(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(addr) == 0 {
|
||||
return nil, errors.New("no IPs associated with domain")
|
||||
}
|
||||
// Try to dial in to the remote server
|
||||
logger.Trace("Dialing remote SSH server", "user", user.Username, "key", path)
|
||||
if !strings.Contains(server, ":") {
|
||||
server += ":22"
|
||||
}
|
||||
client, err := ssh.Dial("tcp", server, &ssh.ClientConfig{User: user.Username, Auth: auths})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Connection established, return our utility wrapper
|
||||
c := &sshClient{
|
||||
server: label,
|
||||
address: addr[0],
|
||||
client: client,
|
||||
logger: logger,
|
||||
}
|
||||
if err := c.init(); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// init runs some initialization commands on the remote server to ensure it's
|
||||
// capable of acting as puppeth target.
|
||||
func (client *sshClient) init() error {
|
||||
client.logger.Debug("Verifying if docker is available")
|
||||
if out, err := client.Run("docker version"); err != nil {
|
||||
if len(out) == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("docker configured incorrectly: %s", out)
|
||||
}
|
||||
client.logger.Debug("Verifying if docker-compose is available")
|
||||
if out, err := client.Run("docker-compose version"); err != nil {
|
||||
if len(out) == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("docker-compose configured incorrectly: %s", out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close terminates the connection to an SSH server.
|
||||
func (client *sshClient) Close() error {
|
||||
return client.client.Close()
|
||||
}
|
||||
|
||||
// Run executes a command on the remote server and returns the combined output
|
||||
// along with any error status.
|
||||
func (client *sshClient) Run(cmd string) ([]byte, error) {
|
||||
// Establish a single command session
|
||||
session, err := client.client.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Execute the command and return any output
|
||||
client.logger.Trace("Running command on remote server", "cmd", cmd)
|
||||
return session.CombinedOutput(cmd)
|
||||
}
|
||||
|
||||
// Stream executes a command on the remote server and streams all outputs into
|
||||
// the local stdout and stderr streams.
|
||||
func (client *sshClient) Stream(cmd string) error {
|
||||
// Establish a single command session
|
||||
session, err := client.client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
session.Stdout = os.Stdout
|
||||
session.Stderr = os.Stderr
|
||||
|
||||
// Execute the command and return any output
|
||||
client.logger.Trace("Streaming command on remote server", "cmd", cmd)
|
||||
return session.Run(cmd)
|
||||
}
|
||||
|
||||
// Upload copied the set of files to a remote server via SCP, creating any non-
|
||||
// existing folder in te mean time.
|
||||
func (client *sshClient) Upload(files map[string][]byte) ([]byte, error) {
|
||||
// Establish a single command session
|
||||
session, err := client.client.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Create a goroutine that streams the SCP content
|
||||
go func() {
|
||||
out, _ := session.StdinPipe()
|
||||
defer out.Close()
|
||||
|
||||
for file, content := range files {
|
||||
client.logger.Trace("Uploading file to server", "file", file, "bytes", len(content))
|
||||
|
||||
fmt.Fprintln(out, "D0755", 0, filepath.Dir(file)) // Ensure the folder exists
|
||||
fmt.Fprintln(out, "C0644", len(content), filepath.Base(file)) // Create the actual file
|
||||
out.Write(content) // Stream the data content
|
||||
fmt.Fprint(out, "\x00") // Transfer end with \x00
|
||||
fmt.Fprintln(out, "E") // Leave directory (simpler)
|
||||
}
|
||||
}()
|
||||
return session.CombinedOutput("/usr/bin/scp -v -tr ./")
|
||||
}
|
229
cmd/puppeth/wizard.go
Normal file
229
cmd/puppeth/wizard.go
Normal file
@ -0,0 +1,229 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core"
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
)
|
||||
|
||||
// config contains all the configurations needed by puppeth that should be saved
|
||||
// between sessions.
|
||||
type config struct {
|
||||
path string // File containing the configuration values
|
||||
genesis *core.Genesis // Genesis block to cache for node deploys
|
||||
bootFull []string // Bootnodes to always connect to by full nodes
|
||||
bootLight []string // Bootnodes to always connect to by light nodes
|
||||
ethstats string // Ethstats settings to cache for node deploys
|
||||
|
||||
Servers []string `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
// flush dumps the contents of config to disk.
|
||||
func (c config) flush() {
|
||||
os.MkdirAll(filepath.Dir(c.path), 0755)
|
||||
|
||||
sort.Strings(c.Servers)
|
||||
out, _ := json.MarshalIndent(c, "", " ")
|
||||
if err := ioutil.WriteFile(c.path, out, 0644); err != nil {
|
||||
log.Warn("Failed to save puppeth configs", "file", c.path, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
type wizard struct {
|
||||
network string // Network name to manage
|
||||
conf config // Configurations from previous runs
|
||||
|
||||
servers map[string]*sshClient // SSH connections to servers to administer
|
||||
services map[string][]string // Ethereum services known to be running on servers
|
||||
|
||||
in *bufio.Reader // Wrapper around stdin to allow reading user input
|
||||
}
|
||||
|
||||
// read reads a single line from stdin, trimming if from spaces.
|
||||
func (w *wizard) read() string {
|
||||
fmt.Printf("> ")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// readString reads a single line from stdin, trimming if from spaces, enforcing
|
||||
// non-emptyness.
|
||||
func (w *wizard) readString() string {
|
||||
for {
|
||||
fmt.Printf("> ")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
if text = strings.TrimSpace(text); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readDefaultString reads a single line from stdin, trimming if from spaces. If
|
||||
// an empty line is entered, the default value is returned.
|
||||
func (w *wizard) readDefaultString(def string) string {
|
||||
for {
|
||||
fmt.Printf("> ")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
if text = strings.TrimSpace(text); text != "" {
|
||||
return text
|
||||
}
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// readInt reads a single line from stdin, trimming if from spaces, enforcing it
|
||||
// to parse into an integer.
|
||||
func (w *wizard) readInt() int {
|
||||
for {
|
||||
fmt.Printf("> ")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
if text = strings.TrimSpace(text); text == "" {
|
||||
continue
|
||||
}
|
||||
val, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
log.Error("Invalid input, expected integer", "err", err)
|
||||
continue
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// readDefaultInt reads a single line from stdin, trimming if from spaces, enforcing
|
||||
// it to parse into an integer. If an empty line is entered, the default value is
|
||||
// returned.
|
||||
func (w *wizard) readDefaultInt(def int) int {
|
||||
for {
|
||||
fmt.Printf("> ")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
if text = strings.TrimSpace(text); text == "" {
|
||||
return def
|
||||
}
|
||||
val, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
log.Error("Invalid input, expected integer", "err", err)
|
||||
continue
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// readPassword reads a single line from stdin, trimming it from the trailing new
|
||||
// line and returns it. The input will not be echoed.
|
||||
func (w *wizard) readPassword() string {
|
||||
for {
|
||||
fmt.Printf("> ")
|
||||
text, err := terminal.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
log.Crit("Failed to read password", "err", err)
|
||||
}
|
||||
fmt.Println()
|
||||
return string(text)
|
||||
}
|
||||
}
|
||||
|
||||
// readAddress reads a single line from stdin, trimming if from spaces and converts
|
||||
// it to an Ethereum address.
|
||||
func (w *wizard) readAddress() *common.Address {
|
||||
for {
|
||||
// Read the address from the user
|
||||
fmt.Printf("> 0x")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
if text = strings.TrimSpace(text); text == "" {
|
||||
return nil
|
||||
}
|
||||
// Make sure it looks ok and return it if so
|
||||
if len(text) != 40 {
|
||||
log.Error("Invalid address length, please retry")
|
||||
continue
|
||||
}
|
||||
bigaddr, _ := new(big.Int).SetString(text, 16)
|
||||
address := common.BigToAddress(bigaddr)
|
||||
return &address
|
||||
}
|
||||
}
|
||||
|
||||
// readDefaultAddress reads a single line from stdin, trimming if from spaces and
|
||||
// converts it to an Ethereum address. If an empty line is entered, the default
|
||||
// value is returned.
|
||||
func (w *wizard) readDefaultAddress(def common.Address) common.Address {
|
||||
for {
|
||||
// Read the address from the user
|
||||
fmt.Printf("> 0x")
|
||||
text, err := w.in.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Crit("Failed to read user input", "err", err)
|
||||
}
|
||||
if text = strings.TrimSpace(text); text == "" {
|
||||
return def
|
||||
}
|
||||
// Make sure it looks ok and return it if so
|
||||
if len(text) != 40 {
|
||||
log.Error("Invalid address length, please retry")
|
||||
continue
|
||||
}
|
||||
bigaddr, _ := new(big.Int).SetString(text, 16)
|
||||
return common.BigToAddress(bigaddr)
|
||||
}
|
||||
}
|
||||
|
||||
// readJSON reads a raw JSON message and returns it.
|
||||
func (w *wizard) readJSON() string {
|
||||
var blob json.RawMessage
|
||||
|
||||
for {
|
||||
fmt.Printf("> ")
|
||||
if err := json.NewDecoder(w.in).Decode(&blob); err != nil {
|
||||
log.Error("Invalid JSON, please try again", "err", err)
|
||||
continue
|
||||
}
|
||||
return string(blob)
|
||||
}
|
||||
}
|
132
cmd/puppeth/wizard_dashboard.go
Normal file
132
cmd/puppeth/wizard_dashboard.go
Normal file
@ -0,0 +1,132 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// deployDashboard queries the user for various input on deploying a web-service
|
||||
// dashboard, after which is pushes the container.
|
||||
func (w *wizard) deployDashboard() {
|
||||
// Select the server to interact with
|
||||
server := w.selectServer()
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
client := w.servers[server]
|
||||
|
||||
// Retrieve any active dashboard configurations from the server
|
||||
infos, err := checkDashboard(client, w.network)
|
||||
if err != nil {
|
||||
infos = &dashboardInfos{
|
||||
port: 80,
|
||||
host: client.server,
|
||||
}
|
||||
}
|
||||
// Figure out which port to listen on
|
||||
fmt.Println()
|
||||
fmt.Printf("Which port should the dashboard listen on? (default = %d)\n", infos.port)
|
||||
infos.port = w.readDefaultInt(infos.port)
|
||||
|
||||
// Figure which virtual-host to deploy the dashboard on
|
||||
infos.host, err = w.ensureVirtualHost(client, infos.port, infos.host)
|
||||
if err != nil {
|
||||
log.Error("Failed to decide on dashboard host", "err", err)
|
||||
return
|
||||
}
|
||||
// Port and proxy settings retrieved, figure out which services are available
|
||||
available := make(map[string][]string)
|
||||
for server, services := range w.services {
|
||||
for _, service := range services {
|
||||
available[service] = append(available[service], server)
|
||||
}
|
||||
}
|
||||
listing := make(map[string]string)
|
||||
for _, service := range []string{"ethstats", "explorer", "wallet", "faucet"} {
|
||||
// Gather all the locally hosted pages of this type
|
||||
var pages []string
|
||||
for _, server := range available[service] {
|
||||
client := w.servers[server]
|
||||
if client == nil {
|
||||
continue
|
||||
}
|
||||
// If there's a service running on the machine, retrieve it's port number
|
||||
var port int
|
||||
switch service {
|
||||
case "ethstats":
|
||||
if infos, err := checkEthstats(client, w.network); err == nil {
|
||||
port = infos.port
|
||||
}
|
||||
case "faucet":
|
||||
if infos, err := checkFaucet(client, w.network); err == nil {
|
||||
port = infos.port
|
||||
}
|
||||
}
|
||||
if page, err := resolve(client, w.network, service, port); err == nil && page != "" {
|
||||
pages = append(pages, page)
|
||||
}
|
||||
}
|
||||
// Promt the user to chose one, enter manually or simply not list this service
|
||||
defLabel, defChoice := "don't list", len(pages)+2
|
||||
if len(pages) > 0 {
|
||||
defLabel, defChoice = pages[0], 1
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("Which %s service to list? (default = %s)\n", service, defLabel)
|
||||
for i, page := range pages {
|
||||
fmt.Printf(" %d. %s\n", i+1, page)
|
||||
}
|
||||
fmt.Printf(" %d. List external %s service\n", len(pages)+1, service)
|
||||
fmt.Printf(" %d. Don't list any %s service\n", len(pages)+2, service)
|
||||
|
||||
choice := w.readDefaultInt(defChoice)
|
||||
if choice < 0 || choice > len(pages)+2 {
|
||||
log.Error("Invalid listing choice, aborting")
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case choice <= len(pages):
|
||||
listing[service] = pages[choice-1]
|
||||
case choice == len(pages)+1:
|
||||
fmt.Println()
|
||||
fmt.Printf("Which address is the external %s service at?\n", service)
|
||||
listing[service] = w.readString()
|
||||
default:
|
||||
// No service hosting for this
|
||||
}
|
||||
}
|
||||
// If we have ethstats running, ask whether to make the secret public or not
|
||||
var ethstats bool
|
||||
if w.conf.ethstats != "" {
|
||||
fmt.Println()
|
||||
fmt.Println("Include ethstats secret on dashboard (y/n)? (default = yes)")
|
||||
ethstats = w.readDefaultString("y") == "y"
|
||||
}
|
||||
// Try to deploy the dashboard container on the host
|
||||
if out, err := deployDashboard(client, w.network, infos.port, infos.host, listing, &w.conf, ethstats); err != nil {
|
||||
log.Error("Failed to deploy dashboard container", "err", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf("%s\n", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
// All ok, run a network scan to pick any changes up
|
||||
w.networkStats(false)
|
||||
}
|
79
cmd/puppeth/wizard_ethstats.go
Normal file
79
cmd/puppeth/wizard_ethstats.go
Normal file
@ -0,0 +1,79 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// deployEthstats queries the user for various input on deploying an ethstats
|
||||
// monitoring server, after which it executes it.
|
||||
func (w *wizard) deployEthstats() {
|
||||
// Select the server to interact with
|
||||
server := w.selectServer()
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
client := w.servers[server]
|
||||
|
||||
// Retrieve any active ethstats configurations from the server
|
||||
infos, err := checkEthstats(client, w.network)
|
||||
if err != nil {
|
||||
infos = ðstatsInfos{
|
||||
port: 80,
|
||||
host: client.server,
|
||||
secret: "",
|
||||
}
|
||||
}
|
||||
// Figure out which port to listen on
|
||||
fmt.Println()
|
||||
fmt.Printf("Which port should ethstats listen on? (default = %d)\n", infos.port)
|
||||
infos.port = w.readDefaultInt(infos.port)
|
||||
|
||||
// Figure which virtual-host to deploy ethstats on
|
||||
if infos.host, err = w.ensureVirtualHost(client, infos.port, infos.host); err != nil {
|
||||
log.Error("Failed to decide on ethstats host", "err", err)
|
||||
return
|
||||
}
|
||||
// Port and proxy settings retrieved, figure out the secret and boot ethstats
|
||||
fmt.Println()
|
||||
if infos.secret == "" {
|
||||
fmt.Printf("What should be the secret password for the API? (must not be empty)\n")
|
||||
infos.secret = w.readString()
|
||||
} else {
|
||||
fmt.Printf("What should be the secret password for the API? (default = %s)\n", infos.secret)
|
||||
infos.secret = w.readDefaultString(infos.secret)
|
||||
}
|
||||
// Try to deploy the ethstats server on the host
|
||||
trusted := make([]string, 0, len(w.servers))
|
||||
for _, client := range w.servers {
|
||||
if client != nil {
|
||||
trusted = append(trusted, client.address)
|
||||
}
|
||||
}
|
||||
if out, err := deployEthstats(client, w.network, infos.port, infos.secret, infos.host, trusted); err != nil {
|
||||
log.Error("Failed to deploy ethstats container", "err", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf("%s\n", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
// All ok, run a network scan to pick any changes up
|
||||
w.networkStats(false)
|
||||
}
|
172
cmd/puppeth/wizard_faucet.go
Normal file
172
cmd/puppeth/wizard_faucet.go
Normal file
@ -0,0 +1,172 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/keystore"
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// deployFaucet queries the user for various input on deploying a faucet, after
|
||||
// which it executes it.
|
||||
func (w *wizard) deployFaucet() {
|
||||
// Select the server to interact with
|
||||
server := w.selectServer()
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
client := w.servers[server]
|
||||
|
||||
// Retrieve any active faucet configurations from the server
|
||||
infos, err := checkFaucet(client, w.network)
|
||||
if err != nil {
|
||||
infos = &faucetInfos{
|
||||
node: &nodeInfos{portFull: 30303, peersTotal: 25},
|
||||
port: 80,
|
||||
host: client.server,
|
||||
amount: 1,
|
||||
minutes: 1440,
|
||||
}
|
||||
}
|
||||
infos.node.genesis, _ = json.MarshalIndent(w.conf.genesis, "", " ")
|
||||
infos.node.network = w.conf.genesis.Config.ChainId.Int64()
|
||||
|
||||
// Figure out which port to listen on
|
||||
fmt.Println()
|
||||
fmt.Printf("Which port should the faucet listen on? (default = %d)\n", infos.port)
|
||||
infos.port = w.readDefaultInt(infos.port)
|
||||
|
||||
// Figure which virtual-host to deploy ethstats on
|
||||
if infos.host, err = w.ensureVirtualHost(client, infos.port, infos.host); err != nil {
|
||||
log.Error("Failed to decide on faucet host", "err", err)
|
||||
return
|
||||
}
|
||||
// Port and proxy settings retrieved, figure out the funcing amount per perdion configurations
|
||||
fmt.Println()
|
||||
fmt.Printf("How many Ethers to release per request? (default = %d)\n", infos.amount)
|
||||
infos.amount = w.readDefaultInt(infos.amount)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("How many minutes to enforce between requests? (default = %d)\n", infos.minutes)
|
||||
infos.minutes = w.readDefaultInt(infos.minutes)
|
||||
|
||||
// Accessing GitHub gists requires API authorization, retrieve it
|
||||
if infos.githubUser != "" {
|
||||
fmt.Println()
|
||||
fmt.Printf("Reused previous (%s) GitHub API authorization (y/n)? (default = yes)\n", infos.githubUser)
|
||||
if w.readDefaultString("y") != "y" {
|
||||
infos.githubUser, infos.githubToken = "", ""
|
||||
}
|
||||
}
|
||||
if infos.githubUser == "" {
|
||||
// No previous authorization (or new one requested)
|
||||
fmt.Println()
|
||||
fmt.Println("Which GitHub user to verify Gists through?")
|
||||
infos.githubUser = w.readString()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("What is the GitHub personal access token of the user? (won't be echoed)")
|
||||
infos.githubToken = w.readPassword()
|
||||
|
||||
// Do a sanity check query against github to ensure it's valid
|
||||
req, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||
req.SetBasicAuth(infos.githubUser, infos.githubToken)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Error("Failed to verify GitHub authentication", "err", err)
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var msg struct {
|
||||
Login string `json:"login"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err = json.NewDecoder(res.Body).Decode(&msg); err != nil {
|
||||
log.Error("Failed to decode authorization response", "err", err)
|
||||
return
|
||||
}
|
||||
if msg.Login != infos.githubUser {
|
||||
log.Error("GitHub authorization failed", "user", infos.githubUser, "message", msg.Message)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Figure out where the user wants to store the persistent data
|
||||
fmt.Println()
|
||||
if infos.node.datadir == "" {
|
||||
fmt.Printf("Where should data be stored on the remote machine?\n")
|
||||
infos.node.datadir = w.readString()
|
||||
} else {
|
||||
fmt.Printf("Where should data be stored on the remote machine? (default = %s)\n", infos.node.datadir)
|
||||
infos.node.datadir = w.readDefaultString(infos.node.datadir)
|
||||
}
|
||||
// Figure out which port to listen on
|
||||
fmt.Println()
|
||||
fmt.Printf("Which TCP/UDP port should the light client listen on? (default = %d)\n", infos.node.portFull)
|
||||
infos.node.portFull = w.readDefaultInt(infos.node.portFull)
|
||||
|
||||
// Set a proper name to report on the stats page
|
||||
fmt.Println()
|
||||
if infos.node.ethstats == "" {
|
||||
fmt.Printf("What should the node be called on the stats page?\n")
|
||||
infos.node.ethstats = w.readString() + ":" + w.conf.ethstats
|
||||
} else {
|
||||
fmt.Printf("What should the node be called on the stats page? (default = %s)\n", infos.node.ethstats)
|
||||
infos.node.ethstats = w.readDefaultString(infos.node.ethstats) + ":" + w.conf.ethstats
|
||||
}
|
||||
// Load up the credential needed to release funds
|
||||
if infos.node.keyJSON != "" {
|
||||
var key keystore.Key
|
||||
if err := json.Unmarshal([]byte(infos.node.keyJSON), &key); err != nil {
|
||||
infos.node.keyJSON, infos.node.keyPass = "", ""
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Printf("Reuse previous (%s) funding account (y/n)? (default = yes)\n", key.Address.Hex())
|
||||
if w.readDefaultString("y") != "y" {
|
||||
infos.node.keyJSON, infos.node.keyPass = "", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if infos.node.keyJSON == "" {
|
||||
fmt.Println()
|
||||
fmt.Println("Please paste the faucet's funding account key JSON:")
|
||||
infos.node.keyJSON = w.readJSON()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("What's the unlock password for the account? (won't be echoed)")
|
||||
infos.node.keyPass = w.readPassword()
|
||||
|
||||
if _, err := keystore.DecryptKey([]byte(infos.node.keyJSON), infos.node.keyPass); err != nil {
|
||||
log.Error("Failed to decrypt key with given passphrase")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Try to deploy the faucet server on the host
|
||||
if out, err := deployFaucet(client, w.network, w.conf.bootLight, infos); err != nil {
|
||||
log.Error("Failed to deploy faucet container", "err", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf("%s\n", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
// All ok, run a network scan to pick any changes up
|
||||
w.networkStats(false)
|
||||
}
|
136
cmd/puppeth/wizard_genesis.go
Normal file
136
cmd/puppeth/wizard_genesis.go
Normal file
@ -0,0 +1,136 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core"
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
"github.com/ethereum/go-ethereum/params"
|
||||
)
|
||||
|
||||
// makeGenesis creates a new genesis struct based on some user input.
|
||||
func (w *wizard) makeGenesis() {
|
||||
// Construct a default genesis block
|
||||
genesis := &core.Genesis{
|
||||
Timestamp: uint64(time.Now().Unix()),
|
||||
GasLimit: 4700000,
|
||||
Difficulty: big.NewInt(1048576),
|
||||
Alloc: make(core.GenesisAlloc),
|
||||
Config: ¶ms.ChainConfig{
|
||||
HomesteadBlock: big.NewInt(1),
|
||||
EIP150Block: big.NewInt(2),
|
||||
EIP155Block: big.NewInt(3),
|
||||
EIP158Block: big.NewInt(3),
|
||||
},
|
||||
}
|
||||
// Figure out which consensus engine to choose
|
||||
fmt.Println()
|
||||
fmt.Println("Which consensus engine to use? (default = clique)")
|
||||
fmt.Println(" 1. Ethash - proof-of-work")
|
||||
fmt.Println(" 2. Clique - proof-of-authority")
|
||||
|
||||
choice := w.read()
|
||||
switch {
|
||||
case choice == "1":
|
||||
// In case of ethash, we're pretty much done
|
||||
genesis.Config.Ethash = new(params.EthashConfig)
|
||||
genesis.ExtraData = make([]byte, 32)
|
||||
|
||||
case choice == "" || choice == "2":
|
||||
// In the case of clique, configure the consensus parameters
|
||||
genesis.Difficulty = big.NewInt(1)
|
||||
genesis.Config.Clique = ¶ms.CliqueConfig{
|
||||
Period: 15,
|
||||
Epoch: 30000,
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("How many seconds should blocks take? (default = 15)")
|
||||
genesis.Config.Clique.Period = uint64(w.readDefaultInt(15))
|
||||
|
||||
// We also need the initial list of signers
|
||||
fmt.Println()
|
||||
fmt.Println("Which accounts are allowed to seal? (mandatory at least one)")
|
||||
|
||||
var signers []common.Address
|
||||
for {
|
||||
if address := w.readAddress(); address != nil {
|
||||
signers = append(signers, *address)
|
||||
continue
|
||||
}
|
||||
if len(signers) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Sort the signers and embed into the extra-data section
|
||||
for i := 0; i < len(signers); i++ {
|
||||
for j := i + 1; j < len(signers); j++ {
|
||||
if bytes.Compare(signers[i][:], signers[j][:]) > 0 {
|
||||
signers[i], signers[j] = signers[j], signers[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
genesis.ExtraData = make([]byte, 32+len(signers)*common.AddressLength+65)
|
||||
for i, signer := range signers {
|
||||
copy(genesis.ExtraData[32+i*common.AddressLength:], signer[:])
|
||||
}
|
||||
|
||||
default:
|
||||
log.Crit("Invalid consensus engine choice", "choice", choice)
|
||||
}
|
||||
// Consensus all set, just ask for initial funds and go
|
||||
fmt.Println()
|
||||
fmt.Println("Which accounts should be pre-funded? (advisable at least one)")
|
||||
for {
|
||||
// Read the address of the account to fund
|
||||
if address := w.readAddress(); address != nil {
|
||||
genesis.Alloc[*address] = core.GenesisAccount{
|
||||
Balance: new(big.Int).Lsh(big.NewInt(1), 256-7), // 2^256 / 128 (allow many pre-funds without balance overflows)
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// Add a batch of precompile balances to avoid them getting deleted
|
||||
for i := int64(0); i < 256; i++ {
|
||||
genesis.Alloc[common.BigToAddress(big.NewInt(i))] = core.GenesisAccount{Balance: big.NewInt(1)}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Query the user for some custom extras
|
||||
fmt.Println()
|
||||
fmt.Println("Specify your chain/network ID if you want an explicit one (default = random)")
|
||||
genesis.Config.ChainId = big.NewInt(int64(w.readDefaultInt(rand.Intn(65536))))
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Anything fun to embed into the genesis block? (max 32 bytes)")
|
||||
|
||||
extra := w.read()
|
||||
if len(extra) > 32 {
|
||||
extra = extra[:32]
|
||||
}
|
||||
genesis.ExtraData = append([]byte(extra), genesis.ExtraData[len(extra):]...)
|
||||
|
||||
// All done, store the genesis and flush to disk
|
||||
w.conf.genesis = genesis
|
||||
}
|
153
cmd/puppeth/wizard_intro.go
Normal file
153
cmd/puppeth/wizard_intro.go
Normal file
@ -0,0 +1,153 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// makeWizard creates and returns a new puppeth wizard.
|
||||
func makeWizard(network string) *wizard {
|
||||
return &wizard{
|
||||
network: network,
|
||||
servers: make(map[string]*sshClient),
|
||||
services: make(map[string][]string),
|
||||
in: bufio.NewReader(os.Stdin),
|
||||
}
|
||||
}
|
||||
|
||||
// run displays some useful infos to the user, starting on the journey of
|
||||
// setting up a new or managing an existing Ethereum private network.
|
||||
func (w *wizard) run() {
|
||||
fmt.Println("+-----------------------------------------------------------+")
|
||||
fmt.Println("| Welcome to puppeth, your Ethereum private network manager |")
|
||||
fmt.Println("| |")
|
||||
fmt.Println("| This tool lets you create a new Ethereum network down to |")
|
||||
fmt.Println("| the genesis block, bootnodes, miners and ethstats servers |")
|
||||
fmt.Println("| without the hassle that it would normally entail. |")
|
||||
fmt.Println("| |")
|
||||
fmt.Println("| Puppeth uses SSH to dial in to remote servers, and builds |")
|
||||
fmt.Println("| its network components out of Docker containers using the |")
|
||||
fmt.Println("| docker-compose toolset. |")
|
||||
fmt.Println("+-----------------------------------------------------------+")
|
||||
fmt.Println()
|
||||
|
||||
// Make sure we have a good network name to work with fmt.Println()
|
||||
if w.network == "" {
|
||||
fmt.Println("Please specify a network name to administer (no spaces, please)")
|
||||
for {
|
||||
w.network = w.readString()
|
||||
if !strings.Contains(w.network, " ") {
|
||||
fmt.Printf("Sweet, you can set this via --network=%s next time!\n\n", w.network)
|
||||
break
|
||||
}
|
||||
log.Error("I also like to live dangerously, still no spaces")
|
||||
}
|
||||
}
|
||||
log.Info("Administering Ethereum network", "name", w.network)
|
||||
|
||||
// Load initial configurations and connect to all live servers
|
||||
w.conf.path = filepath.Join(os.Getenv("HOME"), ".puppeth", w.network)
|
||||
|
||||
blob, err := ioutil.ReadFile(w.conf.path)
|
||||
if err != nil {
|
||||
log.Warn("No previous configurations found", "path", w.conf.path)
|
||||
} else if err := json.Unmarshal(blob, &w.conf); err != nil {
|
||||
log.Crit("Previous configuration corrupted", "path", w.conf.path, "err", err)
|
||||
} else {
|
||||
for _, server := range w.conf.Servers {
|
||||
log.Info("Dialing previously configured server", "server", server)
|
||||
client, err := dial(server)
|
||||
if err != nil {
|
||||
log.Error("Previous server unreachable", "server", server, "err", err)
|
||||
}
|
||||
w.servers[server] = client
|
||||
}
|
||||
w.networkStats(false)
|
||||
}
|
||||
// Basics done, loop ad infinitum about what to do
|
||||
for {
|
||||
fmt.Println()
|
||||
fmt.Println("What would you like to do? (default = stats)")
|
||||
fmt.Println(" 1. Show network stats")
|
||||
if w.conf.genesis == nil {
|
||||
fmt.Println(" 2. Configure new genesis")
|
||||
} else {
|
||||
fmt.Println(" 2. Save existing genesis")
|
||||
}
|
||||
if len(w.servers) == 0 {
|
||||
fmt.Println(" 3. Track new remote server")
|
||||
} else {
|
||||
fmt.Println(" 3. Manage tracked machines")
|
||||
}
|
||||
if len(w.services) == 0 {
|
||||
fmt.Println(" 4. Deploy network components")
|
||||
} else {
|
||||
fmt.Println(" 4. Manage network components")
|
||||
}
|
||||
//fmt.Println(" 5. ProTips for common usecases")
|
||||
|
||||
choice := w.read()
|
||||
switch {
|
||||
case choice == "" || choice == "1":
|
||||
w.networkStats(false)
|
||||
|
||||
case choice == "2":
|
||||
// If we don't have a genesis, make one
|
||||
if w.conf.genesis == nil {
|
||||
w.makeGenesis()
|
||||
} else {
|
||||
// Otherwise just save whatever we currently have
|
||||
fmt.Println()
|
||||
fmt.Printf("Which file to save the genesis into? (default = %s.json)\n", w.network)
|
||||
out, _ := json.MarshalIndent(w.conf.genesis, "", " ")
|
||||
if err := ioutil.WriteFile(w.readDefaultString(fmt.Sprintf("%s.json", w.network)), out, 0644); err != nil {
|
||||
log.Error("Failed to save genesis file", "err", err)
|
||||
}
|
||||
log.Info("Exported existing genesis block")
|
||||
}
|
||||
case choice == "3":
|
||||
if len(w.servers) == 0 {
|
||||
if w.makeServer() != "" {
|
||||
w.networkStats(false)
|
||||
}
|
||||
} else {
|
||||
w.manageServers()
|
||||
}
|
||||
case choice == "4":
|
||||
if len(w.services) == 0 {
|
||||
w.deployComponent()
|
||||
} else {
|
||||
w.manageComponents()
|
||||
}
|
||||
|
||||
case choice == "5":
|
||||
w.networkStats(true)
|
||||
|
||||
default:
|
||||
log.Error("That's not something I can do")
|
||||
}
|
||||
}
|
||||
}
|
235
cmd/puppeth/wizard_netstats.go
Normal file
235
cmd/puppeth/wizard_netstats.go
Normal file
@ -0,0 +1,235 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core"
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
// networkStats verifies the status of network components and generates a protip
|
||||
// configuration set to give users hints on how to do various tasks.
|
||||
func (w *wizard) networkStats(tips bool) {
|
||||
if len(w.servers) == 0 {
|
||||
log.Error("No remote machines to gather stats from")
|
||||
return
|
||||
}
|
||||
protips := new(protips)
|
||||
|
||||
// Iterate over all the specified hosts and check their status
|
||||
stats := tablewriter.NewWriter(os.Stdout)
|
||||
stats.SetHeader([]string{"Server", "IP", "Status", "Service", "Details"})
|
||||
stats.SetColWidth(128)
|
||||
|
||||
for _, server := range w.conf.Servers {
|
||||
client := w.servers[server]
|
||||
logger := log.New("server", server)
|
||||
logger.Info("Starting remote server health-check")
|
||||
|
||||
// If the server is not connected, try to connect again
|
||||
if client == nil {
|
||||
conn, err := dial(server)
|
||||
if err != nil {
|
||||
logger.Error("Failed to establish remote connection", "err", err)
|
||||
stats.Append([]string{server, "", err.Error(), "", ""})
|
||||
continue
|
||||
}
|
||||
client = conn
|
||||
}
|
||||
// Client connected one way or another, run health-checks
|
||||
services := make(map[string]string)
|
||||
logger.Debug("Checking for nginx availability")
|
||||
if infos, err := checkNginx(client, w.network); err != nil {
|
||||
if err != ErrServiceUnknown {
|
||||
services["nginx"] = err.Error()
|
||||
}
|
||||
} else {
|
||||
services["nginx"] = infos.String()
|
||||
}
|
||||
logger.Debug("Checking for ethstats availability")
|
||||
if infos, err := checkEthstats(client, w.network); err != nil {
|
||||
if err != ErrServiceUnknown {
|
||||
services["ethstats"] = err.Error()
|
||||
}
|
||||
} else {
|
||||
services["ethstats"] = infos.String()
|
||||
protips.ethstats = infos.config
|
||||
}
|
||||
logger.Debug("Checking for bootnode availability")
|
||||
if infos, err := checkNode(client, w.network, true); err != nil {
|
||||
if err != ErrServiceUnknown {
|
||||
services["bootnode"] = err.Error()
|
||||
}
|
||||
} else {
|
||||
services["bootnode"] = infos.String()
|
||||
|
||||
protips.genesis = string(infos.genesis)
|
||||
protips.bootFull = append(protips.bootFull, infos.enodeFull)
|
||||
if infos.enodeLight != "" {
|
||||
protips.bootLight = append(protips.bootLight, infos.enodeLight)
|
||||
}
|
||||
}
|
||||
logger.Debug("Checking for sealnode availability")
|
||||
if infos, err := checkNode(client, w.network, false); err != nil {
|
||||
if err != ErrServiceUnknown {
|
||||
services["sealnode"] = err.Error()
|
||||
}
|
||||
} else {
|
||||
services["sealnode"] = infos.String()
|
||||
protips.genesis = string(infos.genesis)
|
||||
}
|
||||
logger.Debug("Checking for faucet availability")
|
||||
if infos, err := checkFaucet(client, w.network); err != nil {
|
||||
if err != ErrServiceUnknown {
|
||||
services["faucet"] = err.Error()
|
||||
}
|
||||
} else {
|
||||
services["faucet"] = infos.String()
|
||||
}
|
||||
logger.Debug("Checking for dashboard availability")
|
||||
if infos, err := checkDashboard(client, w.network); err != nil {
|
||||
if err != ErrServiceUnknown {
|
||||
services["dashboard"] = err.Error()
|
||||
}
|
||||
} else {
|
||||
services["dashboard"] = infos.String()
|
||||
}
|
||||
// All status checks complete, report and check next server
|
||||
delete(w.services, server)
|
||||
for service := range services {
|
||||
w.services[server] = append(w.services[server], service)
|
||||
}
|
||||
server, address := client.server, client.address
|
||||
for service, status := range services {
|
||||
stats.Append([]string{server, address, "online", service, status})
|
||||
server, address = "", ""
|
||||
}
|
||||
if len(services) == 0 {
|
||||
stats.Append([]string{server, address, "online", "", ""})
|
||||
}
|
||||
}
|
||||
// If a genesis block was found, load it into our configs
|
||||
if protips.genesis != "" {
|
||||
genesis := new(core.Genesis)
|
||||
if err := json.Unmarshal([]byte(protips.genesis), genesis); err != nil {
|
||||
log.Error("Failed to parse remote genesis", "err", err)
|
||||
} else {
|
||||
w.conf.genesis = genesis
|
||||
protips.network = genesis.Config.ChainId.Int64()
|
||||
}
|
||||
}
|
||||
if protips.ethstats != "" {
|
||||
w.conf.ethstats = protips.ethstats
|
||||
}
|
||||
w.conf.bootFull = protips.bootFull
|
||||
w.conf.bootLight = protips.bootLight
|
||||
|
||||
// Print any collected stats and return
|
||||
if !tips {
|
||||
stats.Render()
|
||||
} else {
|
||||
protips.print(w.network)
|
||||
}
|
||||
}
|
||||
|
||||
// protips contains a collection of network infos to report pro-tips
|
||||
// based on.
|
||||
type protips struct {
|
||||
genesis string
|
||||
network int64
|
||||
bootFull []string
|
||||
bootLight []string
|
||||
ethstats string
|
||||
}
|
||||
|
||||
// print analyzes the network information available and prints a collection of
|
||||
// pro tips for the user's consideration.
|
||||
func (p *protips) print(network string) {
|
||||
// If a known genesis block is available, display it and prepend an init command
|
||||
fullinit, lightinit := "", ""
|
||||
if p.genesis != "" {
|
||||
fullinit = fmt.Sprintf("geth --datadir=$HOME/.%s init %s.json && ", network, network)
|
||||
lightinit = fmt.Sprintf("geth --datadir=$HOME/.%s --light init %s.json && ", network, network)
|
||||
}
|
||||
// If an ethstats server is available, add the ethstats flag
|
||||
statsflag := ""
|
||||
if p.ethstats != "" {
|
||||
if strings.Contains(p.ethstats, " ") {
|
||||
statsflag = fmt.Sprintf(` --ethstats="yournode:%s"`, p.ethstats)
|
||||
} else {
|
||||
statsflag = fmt.Sprintf(` --ethstats=yournode:%s`, p.ethstats)
|
||||
}
|
||||
}
|
||||
// If bootnodes have been specified, add the bootnode flag
|
||||
bootflagFull := ""
|
||||
if len(p.bootFull) > 0 {
|
||||
bootflagFull = fmt.Sprintf(` --bootnodes %s`, strings.Join(p.bootFull, ","))
|
||||
}
|
||||
bootflagLight := ""
|
||||
if len(p.bootLight) > 0 {
|
||||
bootflagLight = fmt.Sprintf(` --bootnodes %s`, strings.Join(p.bootLight, ","))
|
||||
}
|
||||
// Assemble all the known pro-tips
|
||||
var tasks, tips []string
|
||||
|
||||
tasks = append(tasks, "Run an archive node with historical data")
|
||||
tips = append(tips, fmt.Sprintf("%sgeth --networkid=%d --datadir=$HOME/.%s --cache=1024%s%s", fullinit, p.network, network, statsflag, bootflagFull))
|
||||
|
||||
tasks = append(tasks, "Run a full node with recent data only")
|
||||
tips = append(tips, fmt.Sprintf("%sgeth --networkid=%d --datadir=$HOME/.%s --cache=512 --fast%s%s", fullinit, p.network, network, statsflag, bootflagFull))
|
||||
|
||||
tasks = append(tasks, "Run a light node with on demand retrievals")
|
||||
tips = append(tips, fmt.Sprintf("%sgeth --networkid=%d --datadir=$HOME/.%s --light%s%s", lightinit, p.network, network, statsflag, bootflagLight))
|
||||
|
||||
tasks = append(tasks, "Run an embedded node with constrained memory")
|
||||
tips = append(tips, fmt.Sprintf("%sgeth --networkid=%d --datadir=$HOME/.%s --cache=32 --light%s%s", lightinit, p.network, network, statsflag, bootflagLight))
|
||||
|
||||
// If the tips are short, display in a table
|
||||
short := true
|
||||
for _, tip := range tips {
|
||||
if len(tip) > 100 {
|
||||
short = false
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
if short {
|
||||
howto := tablewriter.NewWriter(os.Stdout)
|
||||
howto.SetHeader([]string{"Fun tasks for you", "Tips on how to"})
|
||||
howto.SetColWidth(100)
|
||||
|
||||
for i := 0; i < len(tasks); i++ {
|
||||
howto.Append([]string{tasks[i], tips[i]})
|
||||
}
|
||||
howto.Render()
|
||||
return
|
||||
}
|
||||
// Meh, tips got ugly, split into many lines
|
||||
for i := 0; i < len(tasks); i++ {
|
||||
fmt.Println(tasks[i])
|
||||
fmt.Println(strings.Repeat("-", len(tasks[i])))
|
||||
fmt.Println(tips[i])
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
194
cmd/puppeth/wizard_network.go
Normal file
194
cmd/puppeth/wizard_network.go
Normal file
@ -0,0 +1,194 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// manageServers displays a list of servers the user can disconnect from, and an
|
||||
// option to connect to new servers.
|
||||
func (w *wizard) manageServers() {
|
||||
// List all the servers we can disconnect, along with an entry to connect a new one
|
||||
fmt.Println()
|
||||
for i, server := range w.conf.Servers {
|
||||
fmt.Printf(" %d. Disconnect %s\n", i+1, server)
|
||||
}
|
||||
fmt.Printf(" %d. Connect another server\n", len(w.conf.Servers)+1)
|
||||
|
||||
choice := w.readInt()
|
||||
if choice < 0 || choice > len(w.conf.Servers)+1 {
|
||||
log.Error("Invalid server choice, aborting")
|
||||
return
|
||||
}
|
||||
// If the user selected an existing server, drop it
|
||||
if choice <= len(w.conf.Servers) {
|
||||
server := w.conf.Servers[choice-1]
|
||||
client := w.servers[server]
|
||||
|
||||
delete(w.servers, server)
|
||||
if client != nil {
|
||||
client.Close()
|
||||
}
|
||||
w.conf.Servers = append(w.conf.Servers[:choice-1], w.conf.Servers[choice:]...)
|
||||
w.conf.flush()
|
||||
|
||||
log.Info("Disconnected existing server", "server", server)
|
||||
w.networkStats(false)
|
||||
return
|
||||
}
|
||||
// If the user requested connecting a new server, do it
|
||||
if w.makeServer() != "" {
|
||||
w.networkStats(false)
|
||||
}
|
||||
}
|
||||
|
||||
// makeServer reads a single line from stdin and interprets it as a hostname to
|
||||
// connect to. It tries to establish a new SSH session and also executing some
|
||||
// baseline validations.
|
||||
//
|
||||
// If connection succeeds, the server is added to the wizards configs!
|
||||
func (w *wizard) makeServer() string {
|
||||
fmt.Println()
|
||||
fmt.Println("Please enter remote server's address:")
|
||||
|
||||
for {
|
||||
// Read and fial the server to ensure docker is present
|
||||
input := w.readString()
|
||||
|
||||
client, err := dial(input)
|
||||
if err != nil {
|
||||
log.Error("Server not ready for puppeth", "err", err)
|
||||
return ""
|
||||
}
|
||||
// All checks passed, start tracking the server
|
||||
w.servers[input] = client
|
||||
w.conf.Servers = append(w.conf.Servers, input)
|
||||
w.conf.flush()
|
||||
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
// selectServer lists the user all the currnetly known servers to choose from,
|
||||
// also granting the option to add a new one.
|
||||
func (w *wizard) selectServer() string {
|
||||
// List the available server to the user and wait for a choice
|
||||
fmt.Println()
|
||||
fmt.Println("Which server do you want to interact with?")
|
||||
for i, server := range w.conf.Servers {
|
||||
fmt.Printf(" %d. %s\n", i+1, server)
|
||||
}
|
||||
fmt.Printf(" %d. Connect another server\n", len(w.conf.Servers)+1)
|
||||
|
||||
choice := w.readInt()
|
||||
if choice < 0 || choice > len(w.conf.Servers)+1 {
|
||||
log.Error("Invalid server choice, aborting")
|
||||
return ""
|
||||
}
|
||||
// If the user requested connecting to a new server, go for it
|
||||
if choice <= len(w.conf.Servers) {
|
||||
return w.conf.Servers[choice-1]
|
||||
}
|
||||
return w.makeServer()
|
||||
}
|
||||
|
||||
// manageComponents displays a list of network components the user can tear down
|
||||
// and an option
|
||||
func (w *wizard) manageComponents() {
|
||||
// List all the componens we can tear down, along with an entry to deploy a new one
|
||||
fmt.Println()
|
||||
|
||||
var serviceHosts, serviceNames []string
|
||||
for server, services := range w.services {
|
||||
for _, service := range services {
|
||||
serviceHosts = append(serviceHosts, server)
|
||||
serviceNames = append(serviceNames, service)
|
||||
|
||||
fmt.Printf(" %d. Tear down %s on %s\n", len(serviceHosts), strings.Title(service), server)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %d. Deploy new network component\n", len(serviceHosts)+1)
|
||||
|
||||
choice := w.readInt()
|
||||
if choice < 0 || choice > len(serviceHosts)+1 {
|
||||
log.Error("Invalid component choice, aborting")
|
||||
return
|
||||
}
|
||||
// If the user selected an existing service, destroy it
|
||||
if choice <= len(serviceHosts) {
|
||||
// Figure out the service to destroy and execute it
|
||||
service := serviceNames[choice-1]
|
||||
server := serviceHosts[choice-1]
|
||||
client := w.servers[server]
|
||||
|
||||
if out, err := tearDown(client, w.network, service, true); err != nil {
|
||||
log.Error("Failed to tear down component", "err", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf("%s\n", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Clean up any references to it from out state
|
||||
services := w.services[server]
|
||||
for i, name := range services {
|
||||
if name == service {
|
||||
w.services[server] = append(services[:i], services[i+1:]...)
|
||||
if len(w.services[server]) == 0 {
|
||||
delete(w.services, server)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Info("Torn down existing component", "server", server, "service", service)
|
||||
return
|
||||
}
|
||||
// If the user requested deploying a new component, do it
|
||||
w.deployComponent()
|
||||
}
|
||||
|
||||
// deployComponent displays a list of network components the user can deploy and
|
||||
// guides through the process.
|
||||
func (w *wizard) deployComponent() {
|
||||
// Print all the things we can deploy and wait or user choice
|
||||
fmt.Println()
|
||||
fmt.Println("What would you like to deploy? (recommended order)")
|
||||
fmt.Println(" 1. Ethstats - Network monitoring tool")
|
||||
fmt.Println(" 2. Bootnode - Entry point of the network")
|
||||
fmt.Println(" 3. Sealer - Full node minting new blocks")
|
||||
fmt.Println(" 4. Wallet - Browser wallet for quick sends (todo)")
|
||||
fmt.Println(" 5. Faucet - Crypto faucet to give away funds")
|
||||
fmt.Println(" 6. Dashboard - Website listing above web-services")
|
||||
|
||||
switch w.read() {
|
||||
case "1":
|
||||
w.deployEthstats()
|
||||
case "2":
|
||||
w.deployNode(true)
|
||||
case "3":
|
||||
w.deployNode(false)
|
||||
case "4":
|
||||
case "5":
|
||||
w.deployFaucet()
|
||||
case "6":
|
||||
w.deployDashboard()
|
||||
default:
|
||||
log.Error("That's not something I can do")
|
||||
}
|
||||
}
|
58
cmd/puppeth/wizard_nginx.go
Normal file
58
cmd/puppeth/wizard_nginx.go
Normal file
@ -0,0 +1,58 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// ensureVirtualHost checks whether a reverse-proxy is running on the specified
|
||||
// host machine, and if yes requests a virtual host from the user to host a
|
||||
// specific web service on. If no proxy exists, the method will offer to deploy
|
||||
// one.
|
||||
//
|
||||
// If the user elects not to use a reverse proxy, an empty hostname is returned!
|
||||
func (w *wizard) ensureVirtualHost(client *sshClient, port int, def string) (string, error) {
|
||||
if proxy, _ := checkNginx(client, w.network); proxy != nil {
|
||||
// Reverse proxy is running, if ports match, we need a virtual host
|
||||
if proxy.port == port {
|
||||
fmt.Println()
|
||||
fmt.Printf("Shared port, which domain to assign? (default = %s)\n", def)
|
||||
return w.readDefaultString(def), nil
|
||||
}
|
||||
}
|
||||
// Reverse proxy is not running, offer to deploy a new one
|
||||
fmt.Println()
|
||||
fmt.Println("Allow sharing the port with other services (y/n)? (default = yes)")
|
||||
if w.readDefaultString("y") == "y" {
|
||||
if out, err := deployNginx(client, w.network, port); err != nil {
|
||||
log.Error("Failed to deploy reverse-proxy", "err", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf("%s\n", out)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// Reverse proxy deployed, ask again for the virtual-host
|
||||
fmt.Println()
|
||||
fmt.Printf("Proxy deployed, which domain to assign? (default = %s)\n", def)
|
||||
return w.readDefaultString(def), nil
|
||||
}
|
||||
// Reverse proxy not requested, deploy as a standalone service
|
||||
return "", nil
|
||||
}
|
153
cmd/puppeth/wizard_node.go
Normal file
153
cmd/puppeth/wizard_node.go
Normal file
@ -0,0 +1,153 @@
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts/keystore"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
)
|
||||
|
||||
// deployNode creates a new node configuration based on some user input.
|
||||
func (w *wizard) deployNode(boot bool) {
|
||||
// Do some sanity check before the user wastes time on input
|
||||
if w.conf.genesis == nil {
|
||||
log.Error("No genesis block configured")
|
||||
return
|
||||
}
|
||||
if w.conf.ethstats == "" {
|
||||
log.Error("No ethstats server configured")
|
||||
return
|
||||
}
|
||||
// Select the server to interact with
|
||||
server := w.selectServer()
|
||||
if server == "" {
|
||||
return
|
||||
}
|
||||
client := w.servers[server]
|
||||
|
||||
// Retrieve any active ethstats configurations from the server
|
||||
infos, err := checkNode(client, w.network, boot)
|
||||
if err != nil {
|
||||
if boot {
|
||||
infos = &nodeInfos{portFull: 30303, peersTotal: 512, peersLight: 256}
|
||||
} else {
|
||||
infos = &nodeInfos{portFull: 30303, peersTotal: 50, peersLight: 0}
|
||||
}
|
||||
}
|
||||
infos.genesis, _ = json.MarshalIndent(w.conf.genesis, "", " ")
|
||||
infos.network = w.conf.genesis.Config.ChainId.Int64()
|
||||
|
||||
// Figure out where the user wants to store the persistent data
|
||||
fmt.Println()
|
||||
if infos.datadir == "" {
|
||||
fmt.Printf("Where should data be stored on the remote machine?\n")
|
||||
infos.datadir = w.readString()
|
||||
} else {
|
||||
fmt.Printf("Where should data be stored on the remote machine? (default = %s)\n", infos.datadir)
|
||||
infos.datadir = w.readDefaultString(infos.datadir)
|
||||
}
|
||||
// Figure out which port to listen on
|
||||
fmt.Println()
|
||||
fmt.Printf("Which TCP/UDP port to listen on? (default = %d)\n", infos.portFull)
|
||||
infos.portFull = w.readDefaultInt(infos.portFull)
|
||||
|
||||
// Figure out how many peers to allow (different based on node type)
|
||||
fmt.Println()
|
||||
fmt.Printf("How many peers to allow connecting? (default = %d)\n", infos.peersTotal)
|
||||
infos.peersTotal = w.readDefaultInt(infos.peersTotal)
|
||||
|
||||
// Figure out how many light peers to allow (different based on node type)
|
||||
fmt.Println()
|
||||
fmt.Printf("How many light peers to allow connecting? (default = %d)\n", infos.peersLight)
|
||||
infos.peersLight = w.readDefaultInt(infos.peersLight)
|
||||
|
||||
// Set a proper name to report on the stats page
|
||||
fmt.Println()
|
||||
if infos.ethstats == "" {
|
||||
fmt.Printf("What should the node be called on the stats page?\n")
|
||||
infos.ethstats = w.readString() + ":" + w.conf.ethstats
|
||||
} else {
|
||||
fmt.Printf("What should the node be called on the stats page? (default = %s)\n", infos.ethstats)
|
||||
infos.ethstats = w.readDefaultString(infos.ethstats) + ":" + w.conf.ethstats
|
||||
}
|
||||
// If the node is a miner/signer, load up needed credentials
|
||||
if !boot {
|
||||
if w.conf.genesis.Config.Ethash != nil {
|
||||
// Ethash based miners only need an etherbase to mine against
|
||||
fmt.Println()
|
||||
if infos.etherbase == "" {
|
||||
fmt.Printf("What address should the miner user?\n")
|
||||
for {
|
||||
if address := w.readAddress(); address != nil {
|
||||
infos.etherbase = address.Hex()
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("What address should the miner user? (default = %s)\n", infos.etherbase)
|
||||
infos.etherbase = w.readDefaultAddress(common.HexToAddress(infos.etherbase)).Hex()
|
||||
}
|
||||
} else if w.conf.genesis.Config.Clique != nil {
|
||||
// If a previous signer was already set, offer to reuse it
|
||||
if infos.keyJSON != "" {
|
||||
var key keystore.Key
|
||||
if err := json.Unmarshal([]byte(infos.keyJSON), &key); err != nil {
|
||||
infos.keyJSON, infos.keyPass = "", ""
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Printf("Reuse previous (%s) signing account (y/n)? (default = yes)\n", key.Address.Hex())
|
||||
if w.readDefaultString("y") != "y" {
|
||||
infos.keyJSON, infos.keyPass = "", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clique based signers need a keyfile and unlock password, ask if unavailable
|
||||
if infos.keyJSON == "" {
|
||||
fmt.Println()
|
||||
fmt.Println("Please paste the signer's key JSON:")
|
||||
infos.keyJSON = w.readJSON()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("What's the unlock password for the account? (won't be echoed)")
|
||||
infos.keyPass = w.readPassword()
|
||||
|
||||
if _, err := keystore.DecryptKey([]byte(infos.keyJSON), infos.keyPass); err != nil {
|
||||
log.Error("Failed to decrypt key with given passphrase")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to deploy the full node on the host
|
||||
if out, err := deployNode(client, w.network, w.conf.bootFull, infos); err != nil {
|
||||
log.Error("Failed to deploy Ethereum node container", "err", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf("%s\n", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
// All ok, run a network scan to pick any changes up
|
||||
log.Info("Waiting for node to finish booting")
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
w.networkStats(false)
|
||||
}
|
Reference in New Issue
Block a user