mirror of https://github.com/perkeep/perkeep.git
Remove CamliNetIP, camnetdns, gpgchallenge
These were all part of an earlier effort to let it be easy for people to easily deploy their own Perkeep servers and get them on the internet. The idea was that we'd run a small DNS server which would map from GPG public keys to their rented cloud IPs which users would prove to us with the gpgchallenge stuff. The recently added Tailscale support (see43f34e5cc5
, #1668) makes most of this redundant. Also I'd stopped running this infrastructure ages ago and removed the launcher code recently inb5823a65b9
(and disabled it inc9f78d02ad
). So this was all basically dead code. Signed-off-by: Brad Fitzpatrick <brad@danga.com>
This commit is contained in:
parent
43f34e5cc5
commit
0caf36bc9c
|
@ -44,18 +44,6 @@ JSON. It can either be in [simple mode](#simplemode) (for basic configurations),
|
|||
from Let's Encrypt.
|
||||
* As a fallback, if no FQDN is found, a self-signed certificate is generated.
|
||||
|
||||
* `camliNetIP`: the optional internet-facing IP address for this
|
||||
Perkeep instance. If set, a name in the camlistore.net domain for
|
||||
that IP address will be requested on startup. The obtained domain name
|
||||
will then be used as the host name in the base URL.
|
||||
For now, the protocol to get the name requires receiving a challenge
|
||||
on port 443. Also, this option implies `https`, and that the HTTPS
|
||||
certificate is obtained from [Let's Encrypt](https://letsencrypt.org).
|
||||
For these reasons, this option is mutually exclusive with `baseURL`, `listen`,
|
||||
`httpsCert`, and `httpsKey`.
|
||||
On cloud instances (Google Compute Engine only for now), this option is
|
||||
automatically used.
|
||||
|
||||
* `identity`: your GPG fingerprint. A keypair is created for new users on
|
||||
start, but this may be changed if you know what you're doing.
|
||||
|
||||
|
|
2
go.mod
2
go.mod
|
@ -22,7 +22,6 @@ require (
|
|||
github.com/lib/pq v1.10.2
|
||||
github.com/mailgun/mailgun-go v0.0.0-20171127222028-17e8bd11e87c
|
||||
github.com/mattn/go-mastodon v0.0.5-0.20190517015615-8f6192e26b66
|
||||
github.com/miekg/dns v1.1.57
|
||||
github.com/nf/cr2 v0.0.0-20140528043846-05d46fef4f2f
|
||||
github.com/pkg/sftp v1.13.6
|
||||
github.com/plaid/plaid-go v0.0.0-20161222051224-02b6af68061b
|
||||
|
@ -111,6 +110,7 @@ require (
|
|||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/sdnotify v1.0.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.0 // indirect
|
||||
github.com/miekg/dns v1.1.57 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
|
|
14
make.go
14
make.go
|
@ -52,7 +52,6 @@ var (
|
|||
buildARM = flag.String("arm", "7", "ARM version to use if building for ARM. Note that this version applies even if the host arch is ARM too (and possibly of a different version).")
|
||||
stampVersion = flag.Bool("stampversion", true, "Stamp version into buildinfo.GitInfo")
|
||||
website = flag.Bool("website", false, "Just build the website.")
|
||||
camnetdns = flag.Bool("camnetdns", false, "Just build perkeep.org/server/camnetdns.")
|
||||
static = flag.Bool("static", false, "Build a static binary, so it can run in an empty container.")
|
||||
offline = flag.Bool("offline", false, "Do not fetch the JS code for the web UI from perkeep.org. If not rebuilding the web UI, just trust the files on disk (if they exist).")
|
||||
)
|
||||
|
@ -73,10 +72,6 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
if *website && *camnetdns {
|
||||
log.Fatal("-camnetdns and -website are mutually exclusive")
|
||||
}
|
||||
|
||||
failIfCamlistoreOrgDir()
|
||||
verifyGoModules()
|
||||
verifyGoVersion()
|
||||
|
@ -114,20 +109,13 @@ func main() {
|
|||
if *website {
|
||||
log.Fatal("--targets and --website are mutually exclusive")
|
||||
}
|
||||
if *camnetdns {
|
||||
log.Fatal("--targets and --camnetdns are mutually exclusive")
|
||||
}
|
||||
if t := strings.Split(*targets, ","); len(t) != 0 {
|
||||
targs = t
|
||||
}
|
||||
}
|
||||
if *website || *camnetdns {
|
||||
buildAll = false
|
||||
if *website {
|
||||
buildAll = false
|
||||
targs = []string{"perkeep.org/website/pk-web"}
|
||||
} else if *camnetdns {
|
||||
targs = []string{"perkeep.org/server/camnetdns"}
|
||||
}
|
||||
}
|
||||
|
||||
tags := []string{"purego"} // for cznic/zappy
|
||||
|
|
|
@ -1,93 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 The Perkeep Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// The client command is an example client of the gpgchallenge package.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
"perkeep.org/internal/httputil"
|
||||
"perkeep.org/internal/osutil"
|
||||
"perkeep.org/pkg/gpgchallenge"
|
||||
)
|
||||
|
||||
var (
|
||||
flagPort = flag.Int("p", 443, "port that the server will challenge us on.")
|
||||
flagKeyRing = flag.String("keyring", osutil.DefaultSecretRingFile(), "path to the GPG keyring file")
|
||||
flagKeyID = flag.String("keyid", "", "GPG key ID")
|
||||
flagClaimedIP = flag.String("ip", "", "IP address to prove control over")
|
||||
flagServer = flag.String("server", "camnetdns.camlistore.org", "server we want to run the challenge against")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *flagKeyID == "" {
|
||||
log.Fatal("you need to specify -keyid")
|
||||
}
|
||||
if *flagClaimedIP == "" {
|
||||
log.Fatal("you need to specify -ip")
|
||||
}
|
||||
|
||||
gpgchallenge.ClientChallengedPort = *flagPort
|
||||
cl, err := gpgchallenge.NewClient(
|
||||
*flagKeyRing,
|
||||
*flagKeyID,
|
||||
*flagClaimedIP,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
config := &tls.Config{
|
||||
NextProtos: []string{http2.NextProtoTLS, "http/1.1"},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
selfCert, selfKey, err := httputil.GenSelfTLS(*flagClaimedIP + "-challenge")
|
||||
if err != nil {
|
||||
log.Fatalf("could no generate self-signed certificate: %v", err)
|
||||
}
|
||||
config.Certificates = make([]tls.Certificate, 1)
|
||||
config.Certificates[0], err = tls.X509KeyPair(selfCert, selfKey)
|
||||
if err != nil {
|
||||
log.Fatalf("could not load TLS certificate: %v", err)
|
||||
}
|
||||
laddr := fmt.Sprintf(":%d", *flagPort)
|
||||
l, err := tls.Listen("tcp", laddr, config)
|
||||
if err != nil {
|
||||
log.Fatalf("could not listen on %v for challenge: %v", laddr, err)
|
||||
}
|
||||
|
||||
pattern, handler := cl.Handler()
|
||||
http.Handle(pattern, handler)
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
errc <- http.Serve(l, handler)
|
||||
}()
|
||||
time.Sleep(time.Second)
|
||||
if err := cl.Challenge(*flagServer); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Challenge success")
|
||||
}
|
|
@ -1,830 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 The Perkeep Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package gpgchallenge provides a Client and a Server so that a Client can
|
||||
// prove ownership of an IP address by solving a GPG challenge sent by the Server
|
||||
// at the claimed IP.
|
||||
// The protocol is as follows:
|
||||
//
|
||||
// - The Client GETs a random token from the server, at the /token endpoint, and signs
|
||||
// that token with its GPG private key (armor detached signature).
|
||||
//
|
||||
// - When it is ready[*], the client POSTs an application/x-www-form-urlencoded over
|
||||
// HTTPS to the server, at the /claim endpoint. It sends the following URL-encoded
|
||||
// values as the request body: its armor encoded public key as "pubkey", the IP
|
||||
// address it's claiming as "challengeIP", the token it got from the server as "token",
|
||||
// and the signature for the token as "signature".
|
||||
//
|
||||
// - The Server receives the claim. It verifies that the token (nonce) is indeed one that
|
||||
// it had generated. It parses the client's public key. It verifies with that
|
||||
// public key that the sent signature matches the token. The serve ACKs to the client.
|
||||
//
|
||||
// - The Server generates a random token, and POSTs it to the challenged IP
|
||||
// (over HTTPS, with certificate verification disabled) at the /challenge endpoint.
|
||||
//
|
||||
// - The Client receives the random token, signs it (armored detached
|
||||
// signature), and sends the signature as a reply.
|
||||
//
|
||||
// - The Server receives the signed token and verifies it with the Client's
|
||||
// public key.
|
||||
//
|
||||
// - At this point, the challenge is successful, so the Server performs the
|
||||
// action registered through the OnSuccess function.
|
||||
//
|
||||
// - The Server sends a last message to the Client at the /ack endpoint,
|
||||
// depending on the result of the OnSuccess action. "ACK" if it was successful, the
|
||||
// error message otherwise.
|
||||
//
|
||||
// [*]As the Server connects to the Client to challenge it, the Client must obviously
|
||||
// have a way, which does not need to be described by the protocol, to listen to and
|
||||
// accept these connections.
|
||||
package gpgchallenge // import "perkeep.org/pkg/gpgchallenge"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/armor"
|
||||
"golang.org/x/crypto/openpgp/packet"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"go4.org/wkfs"
|
||||
)
|
||||
|
||||
// ClientChallengedPort is the port that the client will be challenged on by
|
||||
// the server.
|
||||
var ClientChallengedPort = 443
|
||||
|
||||
// SNISuffix is the Server Name Indication prefix used when dialing the
|
||||
// client's handler. The SNI is challengeIP+SNISuffix.
|
||||
const SNISuffix = "-gpgchallenge"
|
||||
|
||||
const (
|
||||
clientEndPointChallenge = "challenge"
|
||||
clientEndPointAck = "ack"
|
||||
clientEndPointReady = "ready" // not part of the protocol, just to check if client has a listener
|
||||
serverEndPointToken = "token"
|
||||
serverEndPointChallenge = "claim"
|
||||
// clientHandlerPrefix is the URL path prefix for all the client endpoints.
|
||||
clientHandlerPrefix = "/.well-known/camlistore/gpgchallenge/"
|
||||
|
||||
nonceValidity = 10 * time.Second
|
||||
spamDelay = 5 * time.Second // any repeated attempt under this delay is considered as spam
|
||||
forgetSeen = time.Minute // anyone being quiet for that long is taken off the "potential spammer" list
|
||||
queriesRate = 10 // max concurrent (non-whitelisted) clients
|
||||
minKeySize = 2048 // in bits. to force potential attackers to generate GPG keys at least this expensive.
|
||||
requestTimeout = 3 * time.Second // so a client does not make use create many long-lived connections
|
||||
)
|
||||
|
||||
// Server sends a challenge when a client that wants to claim ownership of an IP
|
||||
// address requests so. Server runs OnSuccess when the challenge is successful.
|
||||
type Server struct {
|
||||
// OnSuccess is user-defined, and it is run by the server upon
|
||||
// successuful verification of the client's challenge. Identity is the
|
||||
// short form of the client's public key's fingerprint in capital hex.
|
||||
// Address is the IP address that the client was claiming.
|
||||
OnSuccess func(identity, address string) error
|
||||
|
||||
once sync.Once // for initializing all the fields below in serverInit
|
||||
|
||||
// keyHMAC is the key for generating with HMAC, the random tokens for
|
||||
// the clients. Each token is: message-HEX(HMAC(message)) , with message
|
||||
// being the current unix time. This format allows the server to verify a
|
||||
// token it gets back from the client was indeed generated by the server.
|
||||
keyHMAC []byte
|
||||
|
||||
nonceUsedMu sync.Mutex
|
||||
nonceUsed map[string]bool // whether such a nonce has already been sent back by the client
|
||||
|
||||
// All of the fields below are for rate-limiting or attacks suppression.
|
||||
limiter *rate.Limiter
|
||||
|
||||
whiteListMu sync.Mutex
|
||||
// whiteList contains clients which already successfully challenged us,
|
||||
// and therefore are not subject to any rate-limiting. keyed by "keyID-IP".
|
||||
whiteList map[string]struct{}
|
||||
|
||||
keyIDSeenMu sync.Mutex
|
||||
keyIDSeen map[string]time.Time // last time we saw a keyID, for rate-limiting purposes.
|
||||
|
||||
IPSeenMu sync.Mutex
|
||||
IPSeen map[string]time.Time // last time we saw a claimedIP, for rate-limiting purposes.
|
||||
}
|
||||
|
||||
func (cs *Server) serverInit() error {
|
||||
nonce, err := genNonce()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error generating key for hmac: %v", err)
|
||||
}
|
||||
cs.keyHMAC = []byte(nonce)
|
||||
cs.nonceUsed = make(map[string]bool)
|
||||
cs.limiter = rate.NewLimiter(queriesRate, 1)
|
||||
cs.whiteList = make(map[string]struct{})
|
||||
cs.keyIDSeen = make(map[string]time.Time)
|
||||
cs.IPSeen = make(map[string]time.Time)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
cs.once.Do(func() {
|
||||
if err := cs.serverInit(); err != nil {
|
||||
panic(fmt.Sprintf("Could not initialize server: %v", err))
|
||||
}
|
||||
})
|
||||
if r.URL.Path == "/"+serverEndPointToken {
|
||||
cs.handleNonce(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/"+serverEndPointChallenge {
|
||||
cs.handleClaim(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "nope", 404)
|
||||
}
|
||||
|
||||
// handleNonce replies with a nonce. The nonce format is:
|
||||
// message-HexOf(HMAC(message)), where message is the current unix time in seconds.
|
||||
func (cs *Server) handleNonce(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "not a GET", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, cs.keyHMAC)
|
||||
message := fmt.Sprintf("%d", time.Now().Unix())
|
||||
mac.Write([]byte(message))
|
||||
messageHash := string(mac.Sum(nil))
|
||||
nonce := fmt.Sprintf("%s-%x", message, messageHash)
|
||||
if _, err := io.Copy(w, strings.NewReader(nonce)); err != nil {
|
||||
log.Printf("error sending nonce: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "not a POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "could not parse claim form", 500)
|
||||
return
|
||||
}
|
||||
pks := r.Form.Get("pubkey")
|
||||
if len(pks) == 0 {
|
||||
http.Error(w, "pubkey value not found in form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claimedIP := r.Form.Get("challengeIP")
|
||||
if len(claimedIP) == 0 {
|
||||
http.Error(w, "claimedIP value not found in form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token := r.Form.Get("token")
|
||||
if len(token) == 0 {
|
||||
http.Error(w, "token value not found in form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tokenSig := r.Form.Get("signature")
|
||||
if len(tokenSig) == 0 {
|
||||
http.Error(w, "signature value not found in form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cs.validateToken(token); err != nil {
|
||||
http.Error(w, "invalid token", http.StatusBadRequest)
|
||||
log.Printf("Error validating token: %v", err)
|
||||
return
|
||||
}
|
||||
pk, err := parsePubKey(strings.NewReader(pks))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid public key", http.StatusBadRequest)
|
||||
log.Printf("Error parsing client public key: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
keySize, err := pk.BitLength()
|
||||
if err != nil {
|
||||
http.Error(w, "could not check key size", 500)
|
||||
log.Printf("could not check key size: %v", err)
|
||||
return
|
||||
}
|
||||
if keySize < minKeySize {
|
||||
http.Error(w, fmt.Sprintf("minimum key size is %d bits", minKeySize), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cs.validateTokenSignature(pk, token, tokenSig); err != nil {
|
||||
http.Error(w, "invalid token signature", http.StatusBadRequest)
|
||||
log.Printf("Error validating token signature: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify claimedIP looks ok
|
||||
ip := net.ParseIP(claimedIP)
|
||||
if ip == nil {
|
||||
http.Error(w, "nope", http.StatusBadRequest)
|
||||
log.Printf("%q does not look like a valid IP address", claimedIP)
|
||||
return
|
||||
}
|
||||
if !ip.IsGlobalUnicast() {
|
||||
http.Error(w, "nope", http.StatusBadRequest)
|
||||
log.Printf("%q does not look like a nice IP", claimedIP)
|
||||
return
|
||||
}
|
||||
|
||||
keyID := pk.KeyIdString()
|
||||
if isSpammer := cs.rateLimit(keyID, claimedIP); isSpammer {
|
||||
http.Error(w, "don't be a spammer", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// ACK to the client
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
nonce, err := genNonce()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: claimedIP + SNISuffix,
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
cl := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: requestTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := cl.Post(fmt.Sprintf("https://%s:%d%s%s", claimedIP, ClientChallengedPort, clientHandlerPrefix, clientEndPointChallenge),
|
||||
"text/plain", strings.NewReader(nonce))
|
||||
if err != nil {
|
||||
log.Printf("Error while sending the challenge to the client: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
sig, err := cs.receiveSignedNonce(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error reading signed token: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
hash := sig.Hash.New()
|
||||
hash.Write([]byte(nonce))
|
||||
if err := pk.VerifySignature(hash, sig); err != nil {
|
||||
log.Printf("Error verifying token signature: %v", err)
|
||||
return
|
||||
}
|
||||
// Client is in the clear, so we add them to the whitelist for next time
|
||||
// TODO(mpl): unbounded for now, but it would be easy to e.g. keep the
|
||||
// time as value, and regularly remove very old entries. Or use a sized
|
||||
// cache. etc.
|
||||
cs.whiteListMu.Lock()
|
||||
cs.whiteList[keyID+"-"+claimedIP] = struct{}{}
|
||||
cs.whiteListMu.Unlock()
|
||||
|
||||
ackMessage := "ACK"
|
||||
if err := cs.OnSuccess(pk.KeyIdShortString(), claimedIP); err != nil {
|
||||
ackMessage = fmt.Sprintf("challenge successful, but could not perform operation: %v", err)
|
||||
}
|
||||
|
||||
resp, err = cl.Post(fmt.Sprintf("https://%s:%d%s%s", claimedIP, ClientChallengedPort, clientHandlerPrefix, clientEndPointAck),
|
||||
"text/plain", strings.NewReader(ackMessage))
|
||||
if err != nil {
|
||||
log.Printf("Error sending closing message: %v", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// rateLimit uses the cs.limiter to make sure that any client that hasn't
|
||||
// previously successfully challenged us is rate limited. It also keeps track of
|
||||
// clients that haven't successfully challenged, and it returns true if such a
|
||||
// client should be considered a spammer.
|
||||
func (cs *Server) rateLimit(keyID, claimedIP string) (isSpammer bool) {
|
||||
cs.whiteListMu.Lock()
|
||||
if _, ok := cs.whiteList[keyID+"-"+claimedIP]; ok {
|
||||
cs.whiteListMu.Unlock()
|
||||
return false
|
||||
}
|
||||
cs.whiteListMu.Unlock()
|
||||
// If they haven't successfully challenged us before, they look suspicious.
|
||||
cs.keyIDSeenMu.Lock()
|
||||
lastSeen, ok := cs.keyIDSeen[keyID]
|
||||
// always keep track of the last time we saw them
|
||||
cs.keyIDSeen[keyID] = time.Now()
|
||||
cs.keyIDSeenMu.Unlock()
|
||||
time.AfterFunc(forgetSeen, func() {
|
||||
// but everyone get a clean slate after a minute of being quiet
|
||||
cs.keyIDSeenMu.Lock()
|
||||
delete(cs.keyIDSeen, keyID)
|
||||
cs.keyIDSeenMu.Unlock()
|
||||
})
|
||||
if ok {
|
||||
// if we've seen their keyID before, they look even more suspicious, so investigate.
|
||||
if lastSeen.Add(spamDelay).After(time.Now()) {
|
||||
// we kick them out if we saw them less than 5 seconds ago.
|
||||
return true
|
||||
}
|
||||
}
|
||||
cs.IPSeenMu.Lock()
|
||||
lastSeen, ok = cs.IPSeen[claimedIP]
|
||||
// always keep track of the last time we saw them
|
||||
cs.IPSeen[claimedIP] = time.Now()
|
||||
cs.IPSeenMu.Unlock()
|
||||
time.AfterFunc(forgetSeen, func() {
|
||||
// but everyone get a clean slate after a minute of being quiet
|
||||
cs.IPSeenMu.Lock()
|
||||
delete(cs.IPSeen, claimedIP)
|
||||
cs.IPSeenMu.Unlock()
|
||||
})
|
||||
if ok {
|
||||
// if we've seen their IP before, they look even more suspicious, so investigate.
|
||||
if lastSeen.Add(spamDelay).After(time.Now()) {
|
||||
// we kick them out if we saw them less than 5 seconds ago.
|
||||
return true
|
||||
}
|
||||
}
|
||||
// global rate limit that applies to all strangers at the same time
|
||||
cs.limiter.Wait(context.Background())
|
||||
return false
|
||||
}
|
||||
|
||||
func genNonce() (string, error) {
|
||||
buf := make([]byte, 20)
|
||||
if n, err := rand.Read(buf); err != nil || n != len(buf) {
|
||||
return "", fmt.Errorf("failed to generate random nonce: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", buf), nil
|
||||
}
|
||||
|
||||
func (cs *Server) validateToken(token string) error {
|
||||
// Check the token is one of ours, and not too old
|
||||
parts := strings.Split(token, "-")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("client sent back an invalid token")
|
||||
}
|
||||
nonce := parts[0]
|
||||
nonceTimeSeconds, err := strconv.ParseInt(nonce, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("time in nonce could not be parsed: %v", err)
|
||||
}
|
||||
nonceTime := time.Unix(nonceTimeSeconds, 0)
|
||||
if nonceTime.Add(nonceValidity).Before(time.Now()) {
|
||||
return fmt.Errorf("client sent back an expired nonce")
|
||||
}
|
||||
mac := hmac.New(sha256.New, cs.keyHMAC)
|
||||
mac.Write([]byte(nonce))
|
||||
expectedMAC, err := hex.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not decode HMAC %q in nonce: %v", parts[1], err)
|
||||
}
|
||||
if !hmac.Equal(mac.Sum(nil), expectedMAC) {
|
||||
return fmt.Errorf("client sent back a nonce we did not generate")
|
||||
}
|
||||
|
||||
cs.nonceUsedMu.Lock()
|
||||
if used, _ := cs.nonceUsed[nonce]; used {
|
||||
log.Printf("nonce %q has already been received", nonce)
|
||||
return nil
|
||||
}
|
||||
cs.nonceUsed[nonce] = true
|
||||
cs.nonceUsedMu.Unlock()
|
||||
time.AfterFunc(nonceValidity, func() {
|
||||
cs.nonceUsedMu.Lock()
|
||||
defer cs.nonceUsedMu.Unlock()
|
||||
delete(cs.nonceUsed, nonce)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *Server) validateTokenSignature(pk *packet.PublicKey, token, tokenSig string) error {
|
||||
sig, err := cs.receiveSignedNonce(strings.NewReader(tokenSig))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sig.Hash.New()
|
||||
hash.Write([]byte(token))
|
||||
return pk.VerifySignature(hash, sig)
|
||||
}
|
||||
|
||||
func parsePubKey(r io.Reader) (*packet.PublicKey, error) {
|
||||
block, _ := armor.Decode(r)
|
||||
if block == nil {
|
||||
return nil, errors.New("can't parse armor")
|
||||
}
|
||||
var p packet.Packet
|
||||
var err error
|
||||
p, err = packet.Read(block.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pk, ok := p.(*packet.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("PGP packet isn't a public key")
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
func (cs *Server) receiveSignedNonce(r io.Reader) (*packet.Signature, error) {
|
||||
block, _ := armor.Decode(r)
|
||||
if block == nil {
|
||||
return nil, errors.New("can't parse armor")
|
||||
}
|
||||
var p packet.Packet
|
||||
var err error
|
||||
p, err = packet.Read(block.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, ok := p.(*packet.Signature)
|
||||
if !ok {
|
||||
return nil, errors.New("PGP packet isn't a signature packet")
|
||||
}
|
||||
if sig.Hash != crypto.SHA1 && sig.Hash != crypto.SHA256 {
|
||||
return nil, errors.New("can only verify SHA1 or SHA256 signatures")
|
||||
}
|
||||
if sig.SigType != packet.SigTypeBinary {
|
||||
return nil, errors.New("can only verify binary signatures")
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// Client is used to prove ownership of an IP address, by answering a GPG
|
||||
// challenge that the server sends at the address.
|
||||
// A client must first register its Handler with an HTTPS server, before it can
|
||||
// perform the challenge.
|
||||
type Client struct {
|
||||
keyRing, keyId string
|
||||
signer *openpgp.Entity
|
||||
challengeIP string
|
||||
|
||||
handler http.Handler
|
||||
// any error from one of the HTTP handle func is sent through errc, so
|
||||
// it can be communicated to the Challenge method, which can then error out
|
||||
// accordingly.
|
||||
errc chan error
|
||||
|
||||
mu sync.Mutex
|
||||
challengeDone bool
|
||||
}
|
||||
|
||||
// NewClient returns a Client. keyRing and keyId are the GPG key ring and key ID
|
||||
// used to fulfill the challenge. challengeIP is the address that client proves
|
||||
// that it owns, by answering the challenge the server sends at this address.
|
||||
func NewClient(keyRing, keyId, challengeIP string) (*Client, error) {
|
||||
signer, err := secretKeyEntity(keyRing, keyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get signer %v from keyRing %v: %v", keyId, keyRing, err)
|
||||
}
|
||||
cl := &Client{
|
||||
keyRing: keyRing,
|
||||
keyId: keyId,
|
||||
signer: signer,
|
||||
challengeIP: challengeIP,
|
||||
errc: make(chan error, 1),
|
||||
}
|
||||
handler := &clientHandler{
|
||||
cl: cl,
|
||||
}
|
||||
cl.handler = handler
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// Handler returns the client's handler, that should be registered with an HTTPS
|
||||
// server for the returned prefix, for the client to be able to receive the
|
||||
// challenge.
|
||||
func (cl *Client) Handler() (prefix string, h http.Handler) {
|
||||
return clientHandlerPrefix, cl.handler
|
||||
}
|
||||
|
||||
// clientHandler is the "server" part of the Client, so it can receive and
|
||||
// answer the Server's challenge.
|
||||
type clientHandler struct {
|
||||
cl *Client
|
||||
}
|
||||
|
||||
func (h *clientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.cl.mu.Lock()
|
||||
defer h.cl.mu.Unlock()
|
||||
if r.URL.Path == clientHandlerPrefix+clientEndPointReady {
|
||||
h.handleReady(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == clientHandlerPrefix+clientEndPointChallenge {
|
||||
h.handleChallenge(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == clientHandlerPrefix+clientEndPointAck {
|
||||
h.handleACK(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "wrong path", 404)
|
||||
}
|
||||
|
||||
// Challenge requests a challenge from the server running at serverAddr, which
|
||||
// should be a host name or of the hostname:port form, and then fulfills that challenge.
|
||||
func (cl *Client) Challenge(serverAddr string) error {
|
||||
if err := cl.listenSelfCheck(serverAddr); err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.challenge(serverAddr)
|
||||
}
|
||||
|
||||
// listenSelfCheck tests whether the client is ready to receive a challenge,
|
||||
// i.e. that the caller has registered the client's handler with a server.
|
||||
func (cl *Client) listenSelfCheck(serverAddr string) error {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: cl.challengeIP + SNISuffix,
|
||||
},
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: tr,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
respc := make(chan *http.Response, 1)
|
||||
var err error
|
||||
var resp *http.Response
|
||||
go func() {
|
||||
resp, err := httpClient.PostForm(fmt.Sprintf("https://localhost:%d%s%s", ClientChallengedPort, clientHandlerPrefix, clientEndPointReady),
|
||||
url.Values{"server": []string{serverAddr}})
|
||||
errc <- err
|
||||
respc <- resp
|
||||
}()
|
||||
timeout := time.NewTimer(time.Second)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case err = <-errc:
|
||||
resp = <-respc
|
||||
case <-timeout.C:
|
||||
return errors.New("The client needs an HTTPS listener for its handler to answer the server's challenge. You need to call Handler and register the http.Handler with an HTTPS server, before calling Challenge.")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error starting challenge: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("error starting challenge: %v", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cl *Client) challenge(serverAddr string) error {
|
||||
token, err := cl.getToken(serverAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get token from server: %v", err)
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
if err := cl.sendClaim(serverAddr, token); err != nil {
|
||||
errc <- fmt.Errorf("error sending challenge claim to server: %v", err)
|
||||
}
|
||||
}()
|
||||
timeout := time.NewTimer(10 * time.Second)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case err := <-cl.errc:
|
||||
return err // nil here on success.
|
||||
case <-timeout.C:
|
||||
return errors.New("challenge timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *clientHandler) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "not a POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *clientHandler) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
cl := h.cl
|
||||
var stickyErr error
|
||||
defer func() {
|
||||
if stickyErr != nil {
|
||||
cl.errc <- stickyErr
|
||||
}
|
||||
}()
|
||||
if cl.challengeDone {
|
||||
stickyErr = errors.New("challenge already answered")
|
||||
http.Error(w, stickyErr.Error(), 500)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
stickyErr = errors.New("not a POST")
|
||||
http.Error(w, stickyErr.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
nonce, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
stickyErr = err
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := openpgp.ArmoredDetachSign(
|
||||
&buf,
|
||||
cl.signer,
|
||||
bytes.NewReader(nonce),
|
||||
nil,
|
||||
); err != nil {
|
||||
stickyErr = err
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(w, &buf); err != nil {
|
||||
stickyErr = fmt.Errorf("could not reply to challenge: %v", err)
|
||||
return
|
||||
}
|
||||
cl.challengeDone = true
|
||||
}
|
||||
|
||||
func (h *clientHandler) handleACK(w http.ResponseWriter, r *http.Request) {
|
||||
cl := h.cl
|
||||
var stickyErr error
|
||||
defer func() {
|
||||
cl.errc <- stickyErr
|
||||
}()
|
||||
if r.Method != "POST" {
|
||||
stickyErr = errors.New("not a POST")
|
||||
http.Error(w, stickyErr.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !cl.challengeDone {
|
||||
stickyErr = errors.New("ACK received before challenge was over")
|
||||
http.Error(w, stickyErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ack, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
stickyErr = err
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if string(ack) != "ACK" {
|
||||
stickyErr = fmt.Errorf("unexpected ACK message from server: %q", string(ack))
|
||||
http.Error(w, stickyErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// reset it for reuse of the client.
|
||||
cl.challengeDone = false
|
||||
if _, err := io.Copy(w, strings.NewReader("OK")); err != nil {
|
||||
log.Printf("non-critical error: could not reply to ACK: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (cl *Client) getToken(serverAddr string) (string, error) {
|
||||
resp, err := http.Get(fmt.Sprintf("https://%s/%s", serverAddr, serverEndPointToken))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := string(data)
|
||||
if len(token) == 0 {
|
||||
return "", errors.New("error getting initial token from server")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (cl *Client) sendClaim(server, token string) error {
|
||||
pubkey, err := armorPubKey(cl.keyRing, cl.keyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := openpgp.ArmoredDetachSign(
|
||||
&buf,
|
||||
cl.signer,
|
||||
strings.NewReader(token),
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not sign token: %v", err)
|
||||
}
|
||||
values := url.Values{
|
||||
"pubkey": {string(pubkey)},
|
||||
"challengeIP": {cl.challengeIP},
|
||||
"token": {token},
|
||||
"signature": {buf.String()},
|
||||
}
|
||||
resp, err := http.PostForm(fmt.Sprintf("https://%s/%s", server, serverEndPointChallenge), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
msg, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
return fmt.Errorf("unexpected claim response: %v, %v", resp.Status, string(msg))
|
||||
}
|
||||
return fmt.Errorf("unexpected claim response: %v", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func armorPubKey(keyRing string, keyId string) ([]byte, error) {
|
||||
pubkey, err := publicKeyEntity(keyRing, keyId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
wc, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pubkey.Serialize(wc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func publicKeyEntity(keyRing string, keyId string) (*openpgp.Entity, error) {
|
||||
f, err := wkfs.Open(keyRing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open keyRing %v: %v", keyRing, err)
|
||||
}
|
||||
defer f.Close()
|
||||
el, err := openpgp.ReadKeyRing(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range el {
|
||||
pubk := e.PrimaryKey
|
||||
if pubk.KeyIdString() == keyId {
|
||||
return e, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("keyId %v not found in %v", keyId, keyRing)
|
||||
}
|
||||
|
||||
func secretKeyEntity(keyRing string, keyId string) (*openpgp.Entity, error) {
|
||||
f, err := wkfs.Open(keyRing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open keyRing %v: %v", keyRing, err)
|
||||
}
|
||||
defer f.Close()
|
||||
el, err := openpgp.ReadKeyRing(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range el {
|
||||
pubk := &e.PrivateKey.PublicKey
|
||||
// TODO(mpl): decrypt private key if it is passphrase-encrypted
|
||||
if pubk.KeyIdString() == keyId {
|
||||
return e, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("keyId %v not found in %v", keyId, keyRing)
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 The Perkeep Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// The server command is an example server of the gpgchallenge package.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"perkeep.org/pkg/gpgchallenge"
|
||||
)
|
||||
|
||||
var (
|
||||
flagHost = flag.String("host", "", "host:port to listen on for https connections")
|
||||
flagTLSCert = flag.String("cert", "", "TLS certificate file")
|
||||
flagTLSKey = flag.String("key", "", "TLS key file")
|
||||
flagClientPort = flag.Int("client_port", 443, "the port to challenge the client on")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *flagHost == "" {
|
||||
log.Fatal("you need to specify -host")
|
||||
}
|
||||
if *flagTLSCert == "" {
|
||||
log.Fatal("you need to specify -cert")
|
||||
}
|
||||
if *flagTLSKey == "" {
|
||||
log.Fatal("you need to specify -key")
|
||||
}
|
||||
|
||||
gpgchallenge.ClientChallengedPort = *flagClientPort
|
||||
cs := &gpgchallenge.Server{
|
||||
OnSuccess: func(identity, address string) error {
|
||||
// This is where and when camnetdns would add the name entry.
|
||||
log.Printf("Server says challenge is success for %v at %v", identity, address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
log.Fatal(http.ListenAndServeTLS(*flagHost, *flagTLSCert, *flagTLSKey, cs))
|
||||
}
|
|
@ -28,15 +28,7 @@ import (
|
|||
"cloud.google.com/go/compute/metadata"
|
||||
)
|
||||
|
||||
// For getting a name in camlistore.net
|
||||
const (
|
||||
// CamliNetDNS is the hostname of the camlistore.net DNS server.
|
||||
CamliNetDNS = "camnetdns.camlistore.org"
|
||||
// CamliNetDomain is the camlistore.net domain name. It is relevant to
|
||||
// Perkeep, because a deployment through the Perkeep on Google Cloud launcher
|
||||
// automatically offers a subdomain name in this domain to any instance.
|
||||
CamliNetDomain = "camlistore.net"
|
||||
|
||||
// useDBNamesConfig is a sentinel value for DBUnique to indicate that we want the
|
||||
// low-level configuration generator to keep on using the old DBNames
|
||||
// style configuration for database names.
|
||||
|
@ -83,7 +75,6 @@ func DefaultEnvConfig() (*Config, error) {
|
|||
ShareHandler: true,
|
||||
}
|
||||
|
||||
externalIP, _ := metadata.ExternalIP()
|
||||
hostName, _ := metadata.InstanceAttributeValue("camlistore-hostname")
|
||||
// If they specified a hostname (previously common with old pk-deploy), then:
|
||||
// if it looks like an FQDN, perkeepd is going to rely on Let's
|
||||
|
@ -93,11 +84,11 @@ func DefaultEnvConfig() (*Config, error) {
|
|||
// exactly as if the instance had no hostname, so that it registers its hostname/IP
|
||||
// with the camlistore.net DNS server (possibly needlessly, if the instance IP has
|
||||
// not changed) again.
|
||||
if hostName != "" && !strings.HasSuffix(hostName, CamliNetDomain) {
|
||||
if hostName != "" && !strings.HasSuffix(hostName, "camlistore.net") {
|
||||
highConf.BaseURL = fmt.Sprintf("https://%s", hostName)
|
||||
highConf.Listen = "0.0.0.0:443"
|
||||
} else {
|
||||
highConf.CamliNetIP = externalIP
|
||||
panic("unsupported legacy configuration using camlistore.net is no longer supported")
|
||||
}
|
||||
|
||||
// Detect a linked Docker MySQL container. It must have alias "mysqldb".
|
||||
|
|
|
@ -974,15 +974,6 @@ func (b *lowBuilder) genLowLevelPrefixes() error {
|
|||
|
||||
func (b *lowBuilder) build() (*Config, error) {
|
||||
conf, low := b.high, b.low
|
||||
if conf.CamliNetIP != "" {
|
||||
if !conf.HTTPS {
|
||||
return nil, errors.New("CamliNetIP requires HTTPS")
|
||||
}
|
||||
if conf.HTTPSCert != "" || conf.HTTPSKey != "" || conf.Listen != "" || conf.BaseURL != "" {
|
||||
return nil, errors.New("CamliNetIP is mutually exclusive with HTTPSCert, HTTPSKey, Listen, and BaseURL")
|
||||
}
|
||||
low["camliNetIP"] = conf.CamliNetIP
|
||||
}
|
||||
if conf.HTTPS {
|
||||
if (conf.HTTPSCert != "") != (conf.HTTPSKey != "") {
|
||||
return nil, errors.New("Must set both httpsCert and httpsKey (or neither to generate a self-signed cert)")
|
||||
|
|
|
@ -411,7 +411,6 @@ func handlerTypeWantsAuth(handlerType string) bool {
|
|||
type Config struct {
|
||||
jconf jsonconfig.Obj // low-level JSON config
|
||||
|
||||
camliNetIP string // optional
|
||||
httpsCert string // optional
|
||||
httpsKey string // optional
|
||||
https bool
|
||||
|
@ -442,11 +441,6 @@ func (c *Config) UIPath() string {
|
|||
return c.uiPath
|
||||
}
|
||||
|
||||
// CamliNetIP returns the optional IP address that this server can be
|
||||
// reached out. If set in the config, the server will request a DNS
|
||||
// subdomain name from the Perkeep camlistore.net DNS server.
|
||||
func (c *Config) CamliNetIP() string { return c.camliNetIP }
|
||||
|
||||
// BaseURL returns the optional URL prefix listening the root of this server.
|
||||
// It does not end in a trailing slash.
|
||||
func (c *Config) BaseURL() string { return c.baseURL }
|
||||
|
@ -577,7 +571,6 @@ func load(filename string, opener func(filename string) (jsonconfig.File, error)
|
|||
// readFields reads the low-level jsonconfig fields using the jsonconfig package
|
||||
// and copies them into c. This marks them as known fields before a future call to InstallerHandlers
|
||||
func (c *Config) readFields() error {
|
||||
c.camliNetIP = c.jconf.OptionalString("camliNetIP", "")
|
||||
c.listenAddr = c.jconf.OptionalString("listen", "")
|
||||
c.baseURL = strings.TrimSuffix(c.jconf.OptionalString("baseURL", ""), "/")
|
||||
c.httpsCert = c.jconf.OptionalString("httpsCert", "")
|
||||
|
|
|
@ -101,24 +101,14 @@ func GenerateClientConfig(serverConfig jsonconfig.Obj) (*Config, error) {
|
|||
return missingConfig(param)
|
||||
}
|
||||
|
||||
var listen, baseURL string
|
||||
camliNetIP := serverConfig.OptionalString("camliNetIP", "")
|
||||
if camliNetIP != "" {
|
||||
listen = ":443"
|
||||
// TODO(mpl): move the camliNetDomain const from perkeepd.go
|
||||
// to somewhere importable, so we can use it here. but later.
|
||||
camliNetDomain := "camlistore.net"
|
||||
baseURL = fmt.Sprintf("https://%s.%s/", keyID, camliNetDomain)
|
||||
} else {
|
||||
listen = serverConfig.OptionalString("listen", "")
|
||||
baseURL = serverConfig.OptionalString("baseURL", "")
|
||||
listen := serverConfig.OptionalString("listen", "")
|
||||
baseURL := serverConfig.OptionalString("baseURL", "")
|
||||
if listen == "" {
|
||||
listen = baseURL
|
||||
}
|
||||
if listen == "" {
|
||||
return nil, errors.New("required value for 'listen' or 'baseURL' not found")
|
||||
}
|
||||
}
|
||||
|
||||
https := serverConfig.OptionalBool("https", false)
|
||||
if !strings.HasPrefix(listen, "http://") && !strings.HasPrefix(listen, "https://") {
|
||||
|
|
|
@ -32,16 +32,6 @@ type Config struct {
|
|||
BaseURL string `json:"baseURL,omitempty"` // Base URL the server advertises. For when behind a proxy.
|
||||
Listen string `json:"listen"` // address (of the form host|ip:port) on which the server will listen on.
|
||||
|
||||
// CamliNetIP is the optional internet-facing IP address for this
|
||||
// Perkeep instance. If set, a name in the camlistore.net domain for
|
||||
// that IP address will be requested on startup. The obtained domain name
|
||||
// will then be used as the host name in the base URL.
|
||||
// For now, the protocol to get the name requires receiving a challenge
|
||||
// on port 443. Also, this option implies HTTPS, and that the HTTPS
|
||||
// certificate is obtained from Let's Encrypt. For these reasons, this
|
||||
// option is mutually exclusive with BaseURL, Listen, HTTPSCert, and
|
||||
// HTTPSKey.
|
||||
CamliNetIP string `json:"camliNetIP"`
|
||||
Identity string `json:"identity"` // GPG identity.
|
||||
IdentitySecretRing string `json:"identitySecretRing"` // path to the secret ring file.
|
||||
|
||||
|
|
|
@ -1,532 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 The Perkeep Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// The camnetdns server serves camlistore.net's DNS server and its
|
||||
// DNS challenges
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"perkeep.org/internal/lru"
|
||||
"perkeep.org/internal/osutil"
|
||||
"perkeep.org/pkg/gpgchallenge"
|
||||
"perkeep.org/pkg/sorted"
|
||||
|
||||
"cloud.google.com/go/compute/metadata"
|
||||
"cloud.google.com/go/datastore"
|
||||
"cloud.google.com/go/logging"
|
||||
"github.com/miekg/dns"
|
||||
"go4.org/cloud/cloudlaunch"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
compute "google.golang.org/api/compute/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", defaultListenAddr(), "specify address for server to listen on")
|
||||
flagServerIP = flag.String("server_ip", "104.154.231.160", "The IP address of the authoritative name server for camlistore.net, i.e. the address where this program will run.")
|
||||
)
|
||||
|
||||
var launchConfig = &cloudlaunch.Config{
|
||||
Name: "camnetdns",
|
||||
BinaryBucket: "camlistore-dnsserver-resource",
|
||||
GCEProjectID: GCEProjectID,
|
||||
Scopes: []string{
|
||||
compute.ComputeScope,
|
||||
logging.WriteScope,
|
||||
datastore.ScopeDatastore,
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
GCEProjectID = "camlistore-website"
|
||||
// DefaultResponseTTL is the record TTL in seconds
|
||||
DefaultResponseTTL = 300
|
||||
// even if record already existed in store, we overwrite it if it is older than 30 days, for analytics.
|
||||
staleRecord = 30 * 24 * time.Hour
|
||||
// max number of records in the lru cache
|
||||
cacheSize = 1e6
|
||||
// stagingCamwebHost is the FQDN of the staging version of the
|
||||
// Perkeep website. We handle it differently from the rest as:
|
||||
// 1) we discover its IP using the GCE API, 2) we only trust the stored
|
||||
// version of it for 5 minutes.
|
||||
stagingCamwebHost = "staging.camlistore.net."
|
||||
lowTTL = 300 // in seconds
|
||||
)
|
||||
|
||||
var (
|
||||
errRecordNotFound = errors.New("record not found")
|
||||
// lastCamwebUpdate is the last time we updated the stored value for stagingCamwebHost
|
||||
lastCamwebUpdate time.Time
|
||||
)
|
||||
|
||||
func defaultListenAddr() string {
|
||||
if metadata.OnGCE() {
|
||||
return ":53"
|
||||
}
|
||||
return ":5300"
|
||||
}
|
||||
|
||||
type keyValue interface {
|
||||
// Get fetches the value for key. It returns errRecordNotFound when
|
||||
// there is no such record.
|
||||
Get(key string) (string, error)
|
||||
Set(key, value string) error
|
||||
}
|
||||
|
||||
// cachedStore is a keyValue implementation that stores in Google's datastore
|
||||
// with dsClient. It automatically stores to cache as well on writes, and always
|
||||
// tries to read from cache first.
|
||||
type cachedStore struct {
|
||||
// datastore client to store the records. It should not be nil.
|
||||
dsClient *datastore.Client
|
||||
// cache stores the most recent records. It should not be nil.
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
// dsValue is the value type written to the datastore
|
||||
type dsValue struct {
|
||||
// Record is the RHS of an A or AAAA DNS record, i.e. an IPV4 or IPV6
|
||||
// address.
|
||||
Record string
|
||||
// Updated is the last time this key value pair was inserted. Values
|
||||
// older than 30 days are rewritten on writes.
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
func (cs cachedStore) Get(key string) (string, error) {
|
||||
val, ok := cs.cache.Get(key)
|
||||
if ok {
|
||||
return val.(string), nil
|
||||
}
|
||||
// Cache Miss. hit the datastore.
|
||||
ctx := context.Background()
|
||||
dk := datastore.NameKey("camnetdns", key, nil)
|
||||
var value dsValue
|
||||
if err := cs.dsClient.Get(ctx, dk, &value); err != nil {
|
||||
if err != datastore.ErrNoSuchEntity {
|
||||
return "", fmt.Errorf("error getting value for %q from datastore: %v", key, err)
|
||||
}
|
||||
return "", errRecordNotFound
|
||||
}
|
||||
// And cache it.
|
||||
cs.cache.Add(key, value.Record)
|
||||
return value.Record, nil
|
||||
}
|
||||
|
||||
func (cs cachedStore) put(ctx context.Context, key, value string) error {
|
||||
dk := datastore.NameKey("camnetdns", key, nil)
|
||||
val := &dsValue{
|
||||
Record: value,
|
||||
Updated: time.Now(),
|
||||
}
|
||||
if _, err := cs.dsClient.Put(ctx, dk, val); err != nil {
|
||||
return fmt.Errorf("error writing (%q : %q) record to datastore: %v", key, value, err)
|
||||
}
|
||||
// and cache it.
|
||||
cs.cache.Add(key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set writes the key, value pair to cs. But it does not actually write if the
|
||||
// value already exists, is up to date, and is more recent than 30 days.
|
||||
func (cs cachedStore) Set(key, value string) error {
|
||||
// check if record already exists
|
||||
ctx := context.Background()
|
||||
dk := datastore.NameKey("camnetdns", key, nil)
|
||||
var oldValue dsValue
|
||||
if err := cs.dsClient.Get(ctx, dk, &oldValue); err != nil {
|
||||
if err != datastore.ErrNoSuchEntity {
|
||||
return fmt.Errorf("error checking if record exists for %q from datastore: %v", key, err)
|
||||
}
|
||||
// record does not exist, write it.
|
||||
return cs.put(ctx, key, value)
|
||||
}
|
||||
// record already exists
|
||||
if oldValue.Record != value {
|
||||
// new record is different, overwrite old one.
|
||||
return cs.put(ctx, key, value)
|
||||
}
|
||||
// record is the same as before
|
||||
if oldValue.Updated.Add(staleRecord).After(time.Now()) {
|
||||
// record is still fresh, nothing to do.
|
||||
return nil
|
||||
}
|
||||
// record is older than 30 days, so we rewrite it, for analytics.
|
||||
return cs.put(ctx, key, value)
|
||||
}
|
||||
|
||||
type memkv struct {
|
||||
skv sorted.KeyValue
|
||||
}
|
||||
|
||||
func (kv memkv) Get(key string) (string, error) {
|
||||
val, err := kv.skv.Get(key)
|
||||
if err != nil {
|
||||
if err != sorted.ErrNotFound {
|
||||
return "", err
|
||||
}
|
||||
return "", errRecordNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (kv memkv) Set(key, value string) error {
|
||||
return kv.skv.Set(key, value)
|
||||
}
|
||||
|
||||
// DNSServer implements the dns.Handler interface to serve A and AAAA
|
||||
// records, using a KeyValue store for the lookups.
|
||||
type DNSServer struct {
|
||||
dataSource keyValue
|
||||
}
|
||||
|
||||
func newDNSServer(src keyValue) *DNSServer {
|
||||
return &DNSServer{
|
||||
dataSource: src,
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DNSServer) HandleLookup(name string) (string, error) {
|
||||
// Lowercase it all, to satisfy https://tools.ietf.org/html/draft-vixie-dnsext-dns0x20-00
|
||||
loName := strings.ToLower(name)
|
||||
if loName != stagingCamwebHost {
|
||||
return ds.dataSource.Get(loName)
|
||||
}
|
||||
if time.Now().Before(lastCamwebUpdate.Add(lowTTL * time.Second)) {
|
||||
return ds.dataSource.Get(loName)
|
||||
}
|
||||
stagingIP, err := stagingCamwebIP()
|
||||
if err != nil {
|
||||
log.Printf("Could not get new IP of %v: %v. Serving old value instead.", stagingCamwebHost, err)
|
||||
return ds.dataSource.Get(loName)
|
||||
}
|
||||
if err := ds.dataSource.Set(stagingCamwebHost, stagingIP); err != nil {
|
||||
log.Printf("Could not update (%v, %v) entry: %v", stagingCamwebHost, stagingIP, err)
|
||||
} else {
|
||||
lastCamwebUpdate = time.Now()
|
||||
log.Printf("%v -> %v updated successfully", stagingCamwebHost, stagingIP)
|
||||
}
|
||||
return stagingIP, nil
|
||||
}
|
||||
|
||||
const (
|
||||
domain = "camlistore.net."
|
||||
authorityNS = "camnetdns.camlistore.org."
|
||||
// Increment after every change with format YYYYMMDDnn.
|
||||
soaSerial = 2017012301
|
||||
)
|
||||
|
||||
var (
|
||||
authoritySection = &dns.NS{
|
||||
Ns: authorityNS,
|
||||
Hdr: dns.RR_Header{
|
||||
Name: domain,
|
||||
Rrtype: dns.TypeNS,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: DefaultResponseTTL,
|
||||
},
|
||||
}
|
||||
additionalSection = &dns.A{
|
||||
A: net.ParseIP(*flagServerIP),
|
||||
Hdr: dns.RR_Header{
|
||||
Name: authorityNS,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: DefaultResponseTTL,
|
||||
},
|
||||
}
|
||||
startOfAuthoritySection = &dns.SOA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: domain,
|
||||
Rrtype: dns.TypeSOA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: DefaultResponseTTL,
|
||||
},
|
||||
Ns: authorityNS,
|
||||
Mbox: "admin.camlistore.org.",
|
||||
Serial: soaSerial,
|
||||
Refresh: 3600, // TODO(mpl): set them lower once we got everything right.
|
||||
Retry: 3600,
|
||||
Expire: 86500,
|
||||
Minttl: DefaultResponseTTL,
|
||||
}
|
||||
)
|
||||
|
||||
func commonHeader(q dns.Question) dns.RR_Header {
|
||||
return dns.RR_Header{
|
||||
Name: q.Name,
|
||||
Rrtype: q.Qtype,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: DefaultResponseTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (ds *DNSServer) ServeDNS(rw dns.ResponseWriter, mes *dns.Msg) {
|
||||
resp := new(dns.Msg)
|
||||
|
||||
if mes.IsEdns0() != nil {
|
||||
// Because apparently, if we're not going to handle EDNS
|
||||
// properly, i.e. by returning an OPT section as well, we should
|
||||
// return an RcodeFormatError. Not seen in the RFC, but doing that
|
||||
// addresses some of the warnings from
|
||||
// http://dnsviz.net/d/granivo.re/dnssec/
|
||||
log.Print("unhandled EDNS message\n")
|
||||
resp.SetRcode(mes, dns.RcodeFormatError)
|
||||
if err := rw.WriteMsg(resp); err != nil {
|
||||
log.Printf("error responding to DNS query: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
resp.SetReply(mes)
|
||||
// TODO(mpl): Should we make sure that at least q.Name ends in
|
||||
// "camlistore.net" before claiming we're authoritative on that response?
|
||||
resp.Authoritative = true
|
||||
|
||||
for _, q := range mes.Question {
|
||||
log.Printf("DNS request from %s: %s", rw.RemoteAddr(), &q)
|
||||
|
||||
answer, err := ds.HandleLookup(q.Name)
|
||||
if err == errRecordNotFound {
|
||||
resp.SetRcode(mes, dns.RcodeNameError)
|
||||
if err := rw.WriteMsg(resp); err != nil {
|
||||
log.Printf("error responding to DNS query: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("error looking up %q: %v", q.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if q.Qclass != dns.ClassINET {
|
||||
log.Printf("error: got invalid DNS question class %d\n", q.Qclass)
|
||||
continue
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
// As long as we send a reply (even an empty one), we apparently
|
||||
// look compliant. Or at least more than if we replied with
|
||||
// RcodeNotImplemented.
|
||||
case dns.TypeDNSKEY, dns.TypeTXT, dns.TypeMX:
|
||||
break
|
||||
|
||||
case dns.TypeSOA:
|
||||
resp.Answer = []dns.RR{startOfAuthoritySection}
|
||||
resp.Extra = []dns.RR{additionalSection}
|
||||
|
||||
case dns.TypeNS:
|
||||
resp.Answer = []dns.RR{authoritySection}
|
||||
resp.Extra = []dns.RR{additionalSection}
|
||||
|
||||
case dns.TypeCAA:
|
||||
header := commonHeader(q)
|
||||
rr := &dns.CAA{
|
||||
Hdr: header,
|
||||
Flag: 1,
|
||||
Tag: "issue",
|
||||
Value: "letsencrypt.org",
|
||||
}
|
||||
resp.Answer = []dns.RR{rr}
|
||||
|
||||
case dns.TypeA, dns.TypeAAAA:
|
||||
val := answer
|
||||
ip := net.ParseIP(val)
|
||||
// TODO(mpl): maybe we should have a distinct memstore for each type?
|
||||
isIP6 := strings.Contains(ip.String(), ":")
|
||||
header := commonHeader(q)
|
||||
if strings.ToLower(q.Name) == stagingCamwebHost {
|
||||
header.Ttl = lowTTL
|
||||
}
|
||||
var rr dns.RR
|
||||
if q.Qtype == dns.TypeA {
|
||||
if isIP6 {
|
||||
break
|
||||
}
|
||||
rr = &dns.A{
|
||||
A: ip,
|
||||
Hdr: header,
|
||||
}
|
||||
} else if q.Qtype == dns.TypeAAAA {
|
||||
if !isIP6 {
|
||||
break
|
||||
}
|
||||
rr = &dns.AAAA{
|
||||
AAAA: ip,
|
||||
Hdr: header,
|
||||
}
|
||||
} else {
|
||||
panic("unreachable")
|
||||
}
|
||||
resp.Answer = []dns.RR{rr}
|
||||
// Not necessary, but I think they help.
|
||||
resp.Ns = []dns.RR{authoritySection}
|
||||
resp.Extra = []dns.RR{additionalSection}
|
||||
|
||||
default:
|
||||
log.Printf("unhandled qtype: %d\n", q.Qtype)
|
||||
resp.SetRcode(mes, dns.RcodeNotImplemented)
|
||||
if err := rw.WriteMsg(resp); err != nil {
|
||||
log.Printf("error responding to DNS query: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if err := rw.WriteMsg(resp); err != nil {
|
||||
log.Printf("error responding to DNS query: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stagingCamwebIP() (string, error) {
|
||||
const (
|
||||
projectID = "camlistore-website"
|
||||
instName = "camweb-staging"
|
||||
zone = "us-central1-f"
|
||||
)
|
||||
hc, err := google.DefaultClient(oauth2.NoContext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting an http client: %v", err)
|
||||
}
|
||||
s, err := compute.New(hc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting compute service: %v", err)
|
||||
}
|
||||
inst, err := compute.NewInstancesService(s).Get(projectID, zone, instName).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting instance: %v", err)
|
||||
}
|
||||
for _, netInt := range inst.NetworkInterfaces {
|
||||
for _, ac := range netInt.AccessConfigs {
|
||||
if ac.Type != "ONE_TO_ONE_NAT" {
|
||||
continue
|
||||
}
|
||||
return ac.NatIP, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
|
||||
func main() {
|
||||
launchConfig.MaybeDeploy()
|
||||
flag.Parse()
|
||||
|
||||
var kv keyValue
|
||||
var httpsListenAddr string
|
||||
if metadata.OnGCE() {
|
||||
httpsListenAddr = ":443"
|
||||
dsClient, err := datastore.NewClient(context.Background(), GCEProjectID)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating datastore client for records: %v", err)
|
||||
}
|
||||
kv = cachedStore{
|
||||
dsClient: dsClient,
|
||||
cache: lru.New(cacheSize),
|
||||
}
|
||||
} else {
|
||||
httpsListenAddr = ":4430"
|
||||
kv = memkv{skv: sorted.NewMemoryKeyValue()}
|
||||
}
|
||||
if err := kv.Set("6401800c.camlistore.net.", "159.203.246.79"); err != nil {
|
||||
log.Fatalf("Error adding %v:%v record: %v", "6401800c.camlistore.net.", "159.203.246.79", err)
|
||||
}
|
||||
if err := kv.Set(domain, *flagServerIP); err != nil {
|
||||
log.Fatalf("Error adding %v:%v record: %v", domain, *flagServerIP, err)
|
||||
}
|
||||
if err := kv.Set("www.camlistore.net.", *flagServerIP); err != nil {
|
||||
log.Fatalf("Error adding %v:%v record: %v", "www.camlistore.net.", *flagServerIP, err)
|
||||
}
|
||||
lastCamwebUpdate = time.Now()
|
||||
if stagingIP, err := stagingCamwebIP(); err == nil {
|
||||
if err := kv.Set(stagingCamwebHost+".", stagingIP); err != nil {
|
||||
log.Fatalf("Error adding %v:%v record: %v", stagingCamwebHost+".", stagingIP, err)
|
||||
}
|
||||
}
|
||||
|
||||
ds := newDNSServer(kv)
|
||||
cs := &gpgchallenge.Server{
|
||||
OnSuccess: func(identity string, address string) error {
|
||||
log.Printf("Adding %v.camlistore.net. as %v", identity, address)
|
||||
return ds.dataSource.Set(strings.ToLower(identity+".camlistore.net."), address)
|
||||
},
|
||||
}
|
||||
|
||||
tcperr := make(chan error, 1)
|
||||
udperr := make(chan error, 1)
|
||||
httpserr := make(chan error, 1)
|
||||
httperr := make(chan error, 1)
|
||||
log.Printf("serving DNS on %s\n", *addr)
|
||||
go func() {
|
||||
tcperr <- dns.ListenAndServe(*addr, "tcp", ds)
|
||||
}()
|
||||
go func() {
|
||||
udperr <- dns.ListenAndServe(*addr, "udp", ds)
|
||||
}()
|
||||
if metadata.OnGCE() {
|
||||
// TODO(mpl): if we want to get a cert for anything
|
||||
// *.camlistore.net, it's a bit of a chicken and egg problem, since
|
||||
// we need camnetdns itself to be already running and answering DNS
|
||||
// queries. It's probably doable, but easier for now to just ask
|
||||
// one for camnetdns.camlistore.org, since that name is not
|
||||
// resolved by camnetdns.
|
||||
hostname := strings.TrimSuffix(authorityNS, ".")
|
||||
m := autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(hostname),
|
||||
Cache: autocert.DirCache(osutil.DefaultLetsEncryptCache()),
|
||||
}
|
||||
go func() {
|
||||
httperr <- http.ListenAndServe(":http", m.HTTPHandler(nil))
|
||||
}()
|
||||
ln, err := tls.Listen("tcp", httpsListenAddr, &tls.Config{
|
||||
Rand: rand.Reader,
|
||||
Time: time.Now,
|
||||
NextProtos: []string{http2.NextProtoTLS, "http/1.1"},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: m.GetCertificate,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Error listening on %v: %v", httpsListenAddr, err)
|
||||
}
|
||||
go func() {
|
||||
httpserr <- http.Serve(ln, cs)
|
||||
}()
|
||||
}
|
||||
select {
|
||||
case err := <-tcperr:
|
||||
log.Fatalf("DNS over TCP error: %v", err)
|
||||
case err := <-udperr:
|
||||
log.Fatalf("DNS error: %v", err)
|
||||
case err := <-httpserr:
|
||||
log.Fatalf("HTTPS server error: %v", err)
|
||||
case err := <-httperr:
|
||||
log.Fatalf("HTTP server for Let's Encrypt error: %v", err)
|
||||
}
|
||||
}
|
|
@ -1,159 +0,0 @@
|
|||
/*
|
||||
Copyright 2018 The Perkeep Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Code related to obtaining camlistore.net DNS subdomains.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"perkeep.org/internal/httputil"
|
||||
"perkeep.org/internal/osutil"
|
||||
"perkeep.org/internal/osutil/gce"
|
||||
"perkeep.org/pkg/env"
|
||||
"perkeep.org/pkg/gpgchallenge"
|
||||
"perkeep.org/pkg/serverinit"
|
||||
"perkeep.org/pkg/webserver"
|
||||
)
|
||||
|
||||
// For getting a name in camlistore.net
|
||||
const (
|
||||
camliNetDNS = serverinit.CamliNetDNS
|
||||
camliNetDomain = serverinit.CamliNetDomain
|
||||
)
|
||||
|
||||
var camliNetHostName string // <keyId>.camlistore.net
|
||||
|
||||
// listenForCamliNet prepares the TLS listener for both the GPG challenge, and
|
||||
// for Let's Encrypt. It then starts listening and returns the baseURL derived from
|
||||
// the hostname we should obtain from the GPG challenge.
|
||||
func listenForCamliNet(ws *webserver.Server, config *serverinit.Config) (baseURL string, err error) {
|
||||
camliNetIP := config.CamliNetIP()
|
||||
if camliNetIP == "" {
|
||||
return "", errors.New("no camliNetIP")
|
||||
}
|
||||
if ip := net.ParseIP(camliNetIP); ip == nil {
|
||||
return "", fmt.Errorf("camliNetIP value %q is not a valid IP address", camliNetIP)
|
||||
} else if ip.To4() == nil {
|
||||
// TODO: support IPv6 when GCE supports IPv6: https://code.google.com/p/google-compute-engine/issues/detail?id=8
|
||||
return "", errors.New("CamliNetIP should be an IPv4, as IPv6 is not yet supported on GCE")
|
||||
}
|
||||
challengeHostname := camliNetIP + gpgchallenge.SNISuffix
|
||||
selfCert, selfKey, err := httputil.GenSelfTLS(challengeHostname)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not generate self-signed certificate: %v", err)
|
||||
}
|
||||
gpgchallengeCert, err := tls.X509KeyPair(selfCert, selfKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not load TLS certificate: %v", err)
|
||||
}
|
||||
_, keyId, err := config.KeyRingAndId()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get keyId for camliNet hostname: %v", err)
|
||||
}
|
||||
// catch future length changes
|
||||
if len(keyId) != 16 {
|
||||
panic("length of GPG keyId is not 16 anymore")
|
||||
}
|
||||
shortKeyId := keyId[8:]
|
||||
camliNetHostName = strings.ToLower(shortKeyId + "." + camliNetDomain)
|
||||
m := autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(camliNetHostName),
|
||||
Cache: autocert.DirCache(osutil.DefaultLetsEncryptCache()),
|
||||
}
|
||||
go func() {
|
||||
err := http.ListenAndServe(":http", m.HTTPHandler(nil))
|
||||
log.Fatalf("Could not start server for http-01 challenge: %v", err)
|
||||
}()
|
||||
getCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hello.ServerName == challengeHostname {
|
||||
return &gpgchallengeCert, nil
|
||||
}
|
||||
return m.GetCertificate(hello)
|
||||
}
|
||||
log.Printf("TLS enabled, with Let's Encrypt for %v", camliNetHostName)
|
||||
ws.SetTLS(webserver.TLSSetup{CertManager: getCertificate})
|
||||
|
||||
err = ws.Listen(fmt.Sprintf(":%d", gpgchallenge.ClientChallengedPort))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Listen: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("https://%s", camliNetHostName), nil
|
||||
}
|
||||
|
||||
// registerDNSChallengeHandler initializes and returns the
|
||||
// gpgchallenge Client if camliNetIP is configured and if so,
|
||||
// registers its handler with Perkeep's muxer.
|
||||
//
|
||||
// If camlistore.net support isn't enabled, it returns (nil, nil).
|
||||
func registerDNSChallengeHandler(ws *webserver.Server, config *serverinit.Config) (*gpgchallenge.Client, error) {
|
||||
camliNetIP := config.CamliNetIP()
|
||||
if camliNetIP == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if ip := net.ParseIP(camliNetIP); ip == nil {
|
||||
return nil, fmt.Errorf("camliNetIP value %q is not a valid IP address", camliNetIP)
|
||||
}
|
||||
|
||||
keyRing, keyId, err := config.KeyRingAndId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cl, err := gpgchallenge.NewClient(keyRing, keyId, camliNetIP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not init gpgchallenge client: %v", err)
|
||||
}
|
||||
ws.Handle(cl.Handler())
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// requestHostName performs the GPG challenge to register/obtain a name in the
|
||||
// camlistore.net domain. The acquired name should be "<gpgKeyId>.camlistore.net",
|
||||
// where <gpgKeyId> is the short form (8 trailing chars) of Perkeep's keyId.
|
||||
// It also starts a goroutine that will rerun the challenge every hour, to keep
|
||||
// the camlistore.net DNS server up to date.
|
||||
func requestHostName(cl *gpgchallenge.Client) error {
|
||||
if err := cl.Challenge(camliNetDNS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if env.OnGCE() {
|
||||
if err := gce.SetInstanceHostname(camliNetHostName); err != nil {
|
||||
return fmt.Errorf("error setting instance camlistore-hostname: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var repeatChallengeFn func()
|
||||
repeatChallengeFn = func() {
|
||||
if err := cl.Challenge(camliNetDNS); err != nil {
|
||||
log.Printf("error with hourly DNS challenge: %v", err)
|
||||
}
|
||||
time.AfterFunc(time.Hour, repeatChallengeFn)
|
||||
}
|
||||
time.AfterFunc(time.Hour, repeatChallengeFn)
|
||||
return nil
|
||||
}
|
|
@ -302,15 +302,7 @@ func handleSignals(shutdownc <-chan io.Closer) {
|
|||
|
||||
// listen discovers the listen address, base URL, and hostname that the ws is
|
||||
// going to use, sets up the TLS configuration, and starts listening.
|
||||
//
|
||||
// If camliNetIP is configured, it also prepares for the GPG
|
||||
// challenge, to register/acquire a name in the camlistore.net domain.
|
||||
func listen(ws *webserver.Server, config *serverinit.Config) (baseURL string, err error) {
|
||||
camliNetIP := config.CamliNetIP()
|
||||
if camliNetIP != "" {
|
||||
return listenForCamliNet(ws, config)
|
||||
}
|
||||
|
||||
baseURL = config.BaseURL()
|
||||
|
||||
// Prefer the --listen flag value. Otherwise use the config value.
|
||||
|
@ -435,11 +427,6 @@ func main() {
|
|||
exitf("Error starting webserver: %v", err)
|
||||
}
|
||||
|
||||
challengeClient, err := registerDNSChallengeHandler(ws, config)
|
||||
if err != nil {
|
||||
exitf("Error registering challenge client with Perkeep muxer: %v", err)
|
||||
}
|
||||
|
||||
config.SetReindex(*flagReindex)
|
||||
config.SetKeepGoing(*flagKeepGoing)
|
||||
|
||||
|
@ -452,11 +439,6 @@ func main() {
|
|||
|
||||
go ws.Serve()
|
||||
|
||||
if challengeClient != nil {
|
||||
if err := requestHostName(challengeClient); err != nil {
|
||||
exitf("Could not register on camlistore.net: %v", err)
|
||||
}
|
||||
}
|
||||
if env.OnGCE() {
|
||||
gce.FixUserDataForPerkeepRename()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue