amfora/client/client.go

215 lines
5.3 KiB
Go

// Package client retrieves data over Gemini and implements a TOFU system.
package client
import (
"errors"
"io/ioutil"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/makeworld-the-better-one/go-gemini"
gemsocks5 "github.com/makeworld-the-better-one/go-gemini-socks5"
"github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
)
// Simple key for certCache map and others, instead of a full URL
// Only uses the part of the URL relevant to matching certs to a URL
type certMapKey struct {
host string
path string
}
var (
// [auth] section of config put into maps
confCerts = make(map[certMapKey]string)
confKeys = make(map[certMapKey]string)
// Cache the cert and key assigned to different URLs
certCache = make(map[certMapKey][][]byte)
certCacheMu = &sync.RWMutex{}
fetchClient *gemini.Client
)
func Init() error {
fetchClient = &gemini.Client{
ConnectTimeout: 10 * time.Second, // Default is 15
ReadTimeout: time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second,
}
if socksHost := os.Getenv("AMFORA_SOCKS5"); socksHost != "" {
fetchClient.Proxy = gemsocks5.ProxyFunc(socksHost, nil)
}
// Populate config maps
certsViper := viper.Sub("auth.certs")
for _, certURL := range certsViper.AllKeys() {
// Normalize URL so that it can be matched no matter how it was written
// in the config
pu, _ := normalizeURL(FixUserURL(certURL))
if pu == nil {
//nolint:goerr113
return errors.New("[auth.certs]: couldn't normalize URL: " + certURL)
}
confCerts[certMapKey{pu.Host, pu.Path}] = certsViper.GetString(certURL)
}
keysViper := viper.Sub("auth.keys")
for _, keyURL := range keysViper.AllKeys() {
pu, _ := normalizeURL(FixUserURL(keyURL))
if pu == nil {
//nolint:goerr113
return errors.New("[auth.keys]: couldn't normalize URL: " + keyURL)
}
confKeys[certMapKey{pu.Host, pu.Path}] = keysViper.GetString(keyURL)
}
return nil
}
// getCertPath returns the path of the cert from the config.
// It returns "" if no config value exists.
func getCertPath(host string, path string) string {
for k, v := range confCerts {
if k.host == host && (k.path == path || strings.HasPrefix(path, k.path)) {
// Either exact match to what's in config, or a subpath
return v
}
}
// No matches
return ""
}
// getKeyPath returns the path of the key from the config.
// It returns "" if no config value exists.
func getKeyPath(host string, path string) string {
for k, v := range confKeys {
if k.host == host && (k.path == path || strings.HasPrefix(path, k.path)) {
// Either exact match to what's in config, or a subpath
return v
}
}
// No matches
return ""
}
func clientCert(host string, path string) ([]byte, []byte) {
mkey := certMapKey{host, path}
certCacheMu.RLock()
pair, ok := certCache[mkey]
certCacheMu.RUnlock()
if ok {
return pair[0], pair[1]
}
ogCertPath := getCertPath(host, path)
// Expand paths starting with ~/
certPath, err := homedir.Expand(ogCertPath)
if err != nil {
certPath = ogCertPath
}
ogKeyPath := getKeyPath(host, path)
keyPath, err := homedir.Expand(ogKeyPath)
if err != nil {
keyPath = ogKeyPath
}
if certPath == "" && keyPath == "" {
certCacheMu.Lock()
certCache[mkey] = [][]byte{nil, nil}
certCacheMu.Unlock()
return nil, nil
}
cert, err := ioutil.ReadFile(certPath)
if err != nil {
certCacheMu.Lock()
certCache[mkey] = [][]byte{nil, nil}
certCacheMu.Unlock()
return nil, nil
}
key, err := ioutil.ReadFile(keyPath)
if err != nil {
certCacheMu.Lock()
certCache[mkey] = [][]byte{nil, nil}
certCacheMu.Unlock()
return nil, nil
}
certCacheMu.Lock()
certCache[mkey] = [][]byte{cert, key}
certCacheMu.Unlock()
return cert, key
}
// HasClientCert returns whether or not a client certificate exists for a host and path.
func HasClientCert(host string, path string) bool {
cert, _ := clientCert(host, path)
return cert != nil
}
func fetch(u string, c *gemini.Client) (*gemini.Response, error) {
parsed, _ := url.Parse(u)
cert, key := clientCert(parsed.Host, parsed.Path)
var res *gemini.Response
var err error
if cert != nil {
res, err = c.FetchWithCert(u, cert, key)
} else {
res, err = c.Fetch(u)
}
if err != nil {
return nil, err
}
ok := handleTofu(parsed.Hostname(), parsed.Port(), res.Cert)
if !ok {
return res, ErrTofu
}
return res, err
}
// Fetch returns response data and an error.
// The error text is human friendly and should be displayed.
func Fetch(u string) (*gemini.Response, error) {
return fetch(u, fetchClient)
}
func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemini.Response, error) {
parsed, _ := url.Parse(u)
cert, key := clientCert(parsed.Host, parsed.Path)
var res *gemini.Response
var err error
if cert != nil {
res, err = c.FetchWithHostAndCert(net.JoinHostPort(proxyHostname, proxyPort), u, cert, key)
} else {
res, err = c.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u)
}
if err != nil {
return nil, err
}
// Only associate the returned cert with the proxy
ok := handleTofu(proxyHostname, proxyPort, res.Cert)
if !ok {
return res, ErrTofu
}
return res, nil
}
// FetchWithProxy is the same as Fetch, but uses a proxy.
func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) {
return fetchWithProxy(proxyHostname, proxyPort, u, fetchClient)
}