327 lines
11 KiB
Go
327 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/authz"
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
"github.com/lixenwraith/logwisp/internal/core"
|
|
"github.com/lixenwraith/logwisp/internal/tlsx"
|
|
|
|
"github.com/lixenwraith/auth"
|
|
)
|
|
|
|
const reloadHint = "send SIGHUP to logwisp to apply (auto_reload does not watch the credentials file)"
|
|
|
|
var authCommands = []subcommand{
|
|
{"add-user", "--credentials FILE -u NAME [--password-file FILE] [--generate]",
|
|
"Add a user to a credentials file, or replace its password",
|
|
[]string{"credentials", "user"}, defineAddUser},
|
|
{"remove-user", "--credentials FILE -u NAME",
|
|
"Remove a user from a credentials file",
|
|
[]string{"credentials", "user"}, defineRemoveUser},
|
|
{"token", "--url https://HOST:PORT -u NAME --password-file FILE [--unbound] [TLS flags]",
|
|
"Log in to an http sink or http_chain source and print a bearer token",
|
|
[]string{"url", "user", "password-file"}, defineToken},
|
|
{"stream", "--addr HOST:PORT -u NAME --password-file FILE [TLS flags]",
|
|
"Log in to a tcp sink and copy its stream to stdout until interrupted",
|
|
[]string{"addr", "user", "password-file"}, defineStream},
|
|
}
|
|
|
|
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|
path := fs.String("credentials", "", "credentials `file`, created when missing")
|
|
user := fs.String("user", "", "user `name`")
|
|
passwordFile := fs.String("password-file", "", "read the password from `file` when it exists, else write a generated one there")
|
|
generate := fs.Bool("generate", false, "generate a new password, replacing any --password-file; needed to rotate without one")
|
|
return func(stdout, stderr io.Writer) error {
|
|
creds, err := authz.LoadCredentials(*path)
|
|
// An empty file is one created beforehand to choose its owner
|
|
if fi, serr := os.Stat(*path); errors.Is(err, os.ErrNotExist) || serr == nil && fi.Size() == 0 {
|
|
creds, err = &authz.Credentials{DecoyKey: make([]byte, 32)}, nil
|
|
rand.Read(creds.DecoyKey)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
i := slices.IndexFunc(creds.Users, func(c *auth.Credential) bool { return c.Username == *user })
|
|
|
|
var password string
|
|
if *passwordFile != "" && !*generate {
|
|
password, err = authz.ReadPassword(*passwordFile)
|
|
switch {
|
|
case err == nil && len(password) < 8:
|
|
return fmt.Errorf("the password in %s is shorter than 8 bytes", *passwordFile)
|
|
case err != nil && !errors.Is(err, os.ErrNotExist):
|
|
return err
|
|
}
|
|
}
|
|
generated := password == ""
|
|
if generated && i >= 0 && !*generate {
|
|
// A mistyped --password-file must not replace a working password
|
|
return fmt.Errorf("user %q exists: replacing its password needs an existing --password-file or --generate", *user)
|
|
}
|
|
if generated {
|
|
password = rand.Text()
|
|
}
|
|
|
|
var cred *auth.Credential
|
|
if len(creds.Users) == 0 {
|
|
cred, err = auth.NewCredential(*user, password)
|
|
} else {
|
|
// One profile per file: mixed ones would tell which users exist
|
|
p := creds.Users[0]
|
|
salt := make([]byte, len(p.Salt))
|
|
rand.Read(salt)
|
|
cred, err = auth.DeriveCredential(*user, password, salt, p.ArgonTime, p.ArgonMemory, p.ArgonThreads)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
done := "added user %q to %s\n"
|
|
if i >= 0 {
|
|
creds.Users[i] = cred
|
|
done = "replaced the password of user %q in %s\n"
|
|
} else {
|
|
creds.Users = append(creds.Users, cred)
|
|
}
|
|
data, err := encodeCredentials(creds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if generated && *passwordFile != "" {
|
|
// First, so a failure leaves the verifiers untouched and a rerun
|
|
// without --generate completes the change
|
|
if err := writeAtomic(*passwordFile, []byte(password+"\n")); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(stderr, "wrote the generated password to %s\n", *passwordFile)
|
|
}
|
|
if err := writeAtomic(*path, data); err != nil {
|
|
return err
|
|
}
|
|
if generated && *passwordFile == "" {
|
|
fmt.Fprintf(stderr, "generated password for %q, shown once:\n", *user)
|
|
fmt.Fprintln(stdout, password)
|
|
}
|
|
fmt.Fprintf(stderr, done+"%s\n", *user, *path, reloadHint)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func defineRemoveUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|
path := fs.String("credentials", "", "credentials `file`")
|
|
user := fs.String("user", "", "user `name`")
|
|
return func(_, stderr io.Writer) error {
|
|
creds, err := authz.LoadCredentials(*path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
i := slices.IndexFunc(creds.Users, func(c *auth.Credential) bool { return c.Username == *user })
|
|
switch {
|
|
case i < 0:
|
|
return fmt.Errorf("no user %q in %s", *user, *path)
|
|
case len(creds.Users) == 1:
|
|
// The daemon rejects a file without users
|
|
return fmt.Errorf("refusing to remove %q, the last user: remove the listener's auth block instead", *user)
|
|
}
|
|
creds.Users = slices.Delete(creds.Users, i, i+1)
|
|
data, err := encodeCredentials(creds)
|
|
if err == nil {
|
|
err = writeAtomic(*path, data)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(stderr, "removed user %q from %s\n%s\n", *user, *path, reloadHint)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with --unbound, a proxy's mount URL may carry a path)")
|
|
user := fs.String("user", "", "user `name`")
|
|
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
|
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
|
|
tlsOpts := tlsFlags(fs)
|
|
return func(stdout, _ io.Writer) error {
|
|
u, err := url.Parse(*rawURL)
|
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
|
|
return usageError("--url must be https://HOST:PORT")
|
|
}
|
|
if u.Path = strings.TrimSuffix(u.Path, "/"); u.Path != "" && !*unbound {
|
|
// TLS passthrough cannot route by path: only a terminating proxy can
|
|
return usageError("--url takes a path only with --unbound")
|
|
}
|
|
network, err := core.Network(u.Hostname())
|
|
if err != nil {
|
|
return usageError("--url: " + err.Error())
|
|
}
|
|
tlsCfg, policy, err := dialPolicy(tlsOpts, u.Hostname(), *user, *passwordFile, authz.HTTP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *unbound {
|
|
policy.Unbind()
|
|
}
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, network, addr)
|
|
},
|
|
TLSClientConfig: tlsCfg,
|
|
TLSHandshakeTimeout: tlsx.HandshakeTimeout,
|
|
},
|
|
// A redirect would carry the login to another endpoint
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
// String escapes an IPv6 zone, which u.Host holds raw; the parts drop
|
|
// an empty "?" that would swallow the /auth path
|
|
base := url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path, RawPath: u.RawPath}
|
|
token, err := policy.Token(context.Background(), client, base.String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(stdout, token)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|
addr := fs.String("addr", "", "tcp sink address, `HOST:PORT` or [IPV6]:PORT")
|
|
user := fs.String("user", "", "user `name`")
|
|
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
|
tlsOpts := tlsFlags(fs)
|
|
return func(stdout, _ io.Writer) error {
|
|
host, _, err := net.SplitHostPort(*addr)
|
|
network, nerr := core.Network(host)
|
|
if err != nil || nerr != nil || host == "" {
|
|
return usageError("--addr must be HOST:PORT, with an IPv6 address in brackets")
|
|
}
|
|
tlsCfg, policy, err := dialPolicy(tlsOpts, host, *user, *passwordFile, authz.TCP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
dctx, cancel := context.WithTimeout(ctx, tlsx.HandshakeTimeout)
|
|
conn, err := (&tls.Dialer{Config: tlsCfg}).DialContext(dctx, network, *addr)
|
|
cancel()
|
|
if err == nil {
|
|
defer conn.Close()
|
|
var r io.Reader
|
|
if r, err = policy.Greet(ctx, conn, ""); err == nil {
|
|
defer context.AfterFunc(ctx, func() { conn.Close() })()
|
|
if _, err = io.Copy(stdout, r); err == nil {
|
|
err = errors.New("stream closed by server")
|
|
}
|
|
}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil // interrupted: how a viewer normally ends
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// tlsFlags registers the dialer TLS flags. There is no insecure flag: an
|
|
// unverified server could relay the login.
|
|
func tlsFlags(fs *flag.FlagSet) *config.TLSOptions {
|
|
o := &config.TLSOptions{Enabled: true}
|
|
fs.StringVar(&o.CAFile, "ca-file", "", "CA `file` that verifies the server (default: system roots)")
|
|
fs.StringVar(&o.PinSHA256, "pin-sha256", "", "sha256//BASE64 `pin` of the server key, in place of --ca-file (a self_signed listener logs it)")
|
|
fs.StringVar(&o.ServerName, "server-name", "", "`name` the server certificate must carry (default: the host)")
|
|
fs.StringVar(&o.CertFile, "cert-file", "", "client certificate `file`, for listeners with tls.client_auth")
|
|
fs.StringVar(&o.KeyFile, "key-file", "", "client key `file`")
|
|
return o
|
|
}
|
|
|
|
// dialPolicy builds what a scram dialer plugin builds from its tls and auth blocks
|
|
func dialPolicy(o *config.TLSOptions, host, user, passwordFile string, transport authz.Transport) (*tls.Config, *authz.Policy, error) {
|
|
tlsCfg, err := tlsx.Client(o, host)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
policy, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, Username: user, PasswordFile: passwordFile},
|
|
tlsCfg, authz.RoleDialer, transport)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
tlsCfg.VerifyConnection = policy.VerifyConnection
|
|
return tlsCfg, policy, nil
|
|
}
|
|
|
|
// encodeCredentials renders creds and refuses what the daemon would not load
|
|
func encodeCredentials(creds *authz.Credentials) ([]byte, error) {
|
|
data, err := creds.Marshal()
|
|
if err == nil {
|
|
_, err = authz.ParseCredentials(data)
|
|
}
|
|
return data, err
|
|
}
|
|
|
|
// writeAtomic replaces path (a symlink's target) by rename, so the daemon
|
|
// never reads a partial file, keeping an existing file's mode and owner (0600
|
|
// for a new one). A change that would lose the owner fails: the daemon could
|
|
// no longer read the file and would keep the old users.
|
|
func writeAtomic(path string, data []byte) error {
|
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
path = resolved
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
} else if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 {
|
|
// Replacing the link would leave its target, what the daemon reads, unwritten
|
|
return fmt.Errorf("%s is a symlink to a missing file; create the target first", path)
|
|
}
|
|
mode, uid, gid := os.FileMode(0o600), -1, -1
|
|
if fi, err := os.Stat(path); err == nil {
|
|
mode = fi.Mode().Perm()
|
|
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
|
uid, gid = int(st.Uid), int(st.Gid)
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tmp.Write(data)
|
|
if err == nil {
|
|
err = tmp.Chmod(mode)
|
|
}
|
|
if err == nil && uid >= 0 {
|
|
if err = tmp.Chown(uid, gid); err != nil {
|
|
err = fmt.Errorf("%s: cannot keep owner %d:%d (run as root or that owner): %w", path, uid, gid, err)
|
|
}
|
|
}
|
|
if err == nil {
|
|
err = tmp.Sync()
|
|
}
|
|
if cerr := tmp.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err == nil {
|
|
err = os.Rename(tmp.Name(), path)
|
|
}
|
|
if err != nil {
|
|
os.Remove(tmp.Name())
|
|
}
|
|
return err
|
|
}
|