Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions cmd/litewitness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ SSH_AUTH_SOCK=litewitness.sock ssh-add litewitness.pem
address of the bastion(s) to reverse proxy through, comma separated, the first online one is selected
-listen string
address to listen for HTTP requests (default "localhost:7380")

Only one of `-bastion` or `-listen` must be specified. The former will cause
litewitness to serve requests through a bastion reverse proxy (see below). The
latter will listen for HTTP requests on the specified port. (HTTPS needs to be
terminated outside of litewitness.) The bastion flag is an optionally
comma-separated list of bastions to try in order until one connects
successfully. If the connection drops after establishing, litewitness exits.
-no-listen
do not open any listening socket, rely exclusively on bastions

Only one of `-bastion` or `-listen` must be specified, or `-no-listen` can be
used to rely exclusively on per-log bastions configured in the database. The
`-bastion` flag will cause litewitness to serve requests through a bastion
reverse proxy (see below). The `-listen` flag will listen for HTTP requests on
the specified port. (HTTPS needs to be terminated outside of litewitness.) The
bastion flag is an optionally comma-separated list of bastions to try in order
until one connects successfully. If the connection drops after establishing,
litewitness exits.

## witnessctl

Expand All @@ -81,6 +85,14 @@ re-added. To disable a log, remove all its keys.
The `add-key` and `del-key` commands add and remove verifier keys for a known
log. The name of the key must match the log origin.

witnessctl add-bastion -db <path> -origin <origin> -bastion <address:port>
witnessctl del-bastion -db <path> -origin <origin> -bastion <address:port>

The `add-bastion` and `del-bastion` commands add and remove bastion addresses
for a log. Multiple bastions can be configured for a log and will be used
simultaneously. Bastion configuration is reloaded when litewitness receives a
SIGHUP signal.

witnessctl add-sigsum-log -db <path> -key <hex-encoded key>

The `add-sigsum-log` command is a helper that adds a new Sigsum log, computing
Expand Down
160 changes: 146 additions & 14 deletions cmd/litewitness/litewitness.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
Expand All @@ -39,10 +40,64 @@ var nameFlag = flag.String("name", "", "URL-like (e.g. example.com/foo) name of
var dbFlag = flag.String("db", "litewitness.db", "path to sqlite database")
var sshAgentFlag = flag.String("ssh-agent", "litewitness.sock", "path to ssh-agent socket")
var listenFlag = flag.String("listen", "localhost:7380", "address to listen for HTTP requests")
var noListenFlag = flag.Bool("no-listen", false, "do not open any listening socket, rely exclusively on bastions")
var keyFlag = flag.String("key", "", "SSH fingerprint (with SHA256: prefix) of the witness key")
var bastionFlag = flag.String("bastion", "", "address of the bastion(s) to reverse proxy through, comma separated, the first online one is selected")
var testCertFlag = flag.Bool("testcert", false, "use rootCA.pem for connections to the bastion")

type ConnectionSet struct {
connections map[string]func() // connection => cancel func
connect func(context.Context, string)
}

func NewConnectionSet(connect func(context.Context, string)) *ConnectionSet {
return &ConnectionSet{
connections: make(map[string]func()),
connect: connect,
}
}

func (s *ConnectionSet) Configure(ctx context.Context, addrs []string) {
slices.Sort(addrs)

// Disconnect addresses that have disappeared.
var toDelete []string
for addr, cancel := range s.connections {
if _, found := slices.BinarySearch(addrs, addr); !found {
cancel()
// Postpone delete, we can't delete while iterating over the map.
toDelete = append(toDelete, addr)
}
}
for _, addr := range toDelete {
delete(s.connections, addr)
}

// Connect new bastions.
for _, addr := range addrs {
if _, found := s.connections[addr]; found {
continue
}
// Quit early on cancel.
if ctx.Err() != nil {
break
}
connectionCtx, cancel := context.WithCancel(ctx)
s.connections[addr] = cancel
go s.connect(connectionCtx, addr)
}
}

func onSignal(signo os.Signal, callback func()) {
c := make(chan os.Signal, 1)
signal.Notify(c, signo)
go func() {
for range c {
callback()
}
}()
}

func main() {
flag.Parse()

Expand All @@ -52,18 +107,14 @@ func main() {
console.SetFilter(slogconsole.IPAddressFilter)
slog.SetDefault(slog.New(slogconsole.MultiHandler(h, console)))

c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGUSR1)
go func() {
for range c {
slog.Info("received USR1 signal, toggling log level")
if level.Level() == slog.LevelDebug {
level.Set(slog.LevelInfo)
} else {
level.Set(slog.LevelDebug)
}
onSignal(syscall.SIGUSR1, func() {
slog.Info("received USR1 signal, toggling log level")
if level.Level() == slog.LevelDebug {
level.Set(slog.LevelInfo)
} else {
level.Set(slog.LevelDebug)
}
}()
})

signer := connectToSSHAgent()

Expand All @@ -89,10 +140,77 @@ func main() {
BaseContext: func(net.Listener) context.Context { return ctx },
}
e := make(chan error, 1)

bastionSet := NewConnectionSet(func(ctx context.Context, addr string) {
var delays = []time.Duration{
100 * time.Millisecond,
1 * time.Second, 1 * time.Second, 1 * time.Second,
5 * time.Second, 15 * time.Second, 30 * time.Second,
1 * time.Minute,
}

// If a connection survives for resetRetryDelay, reset the retry delay.
const resetRetryDelay = 5 * time.Minute

retry := 0
for {
startTime := time.Now()
err := connectToBastion(ctx, addr, signer, srv, true)
duration := time.Since(startTime)
slog.Warn("bastion connection failed", "bastion", addr, "duration", duration, "err", err)

// Quit early on cancel.
if ctx.Err() != nil {
return
}

// If the connection lasted long enough, reset the retry delay.
if duration >= resetRetryDelay {
retry = 0
}

// Wait before retrying.
var delay time.Duration
if retry < len(delays) {
delay = delays[retry]
} else {
delay = delays[len(delays)-1]
}
slog.Info("waiting before reconnecting to bastion", "bastion", addr, "delay", delay)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
retry++
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reworked this logic to

  1. cap the retry at one minute, exponential backoff is useful to avoid thundering herds, but even 100 witnesses doing 1 minute retries will be 1.7qps, totally manageable; on the other hand, a 1 hour backoff extends an outage by 30 minutes on average
  2. return if ctx is cancelled, the previous logic would keep trying to reconnect (and fail due to the cancelled ctx) after deleting a bastion
  3. not kill the process if a per-log bastion is unavailable for a long time, the witness serves multiple logs and one log should not affect the others (this also combined with (2) to kill the process ~2h after a bastion was deleted)
  4. reset the delay if the connection is alive for a fixed amount of time, not for an amount of time dependent on the latest delay (the two are not related)
  5. make the delays easier to think about by expanding them into a table

})

// Handle log-specific bastions.
logBastions, err := w.AllBastions()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only looks up configured bastions at startup, changes made with witnessctl won't take effect until next restart. I wonder if that is good enough, or if we need something more dynamic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be an sqlite3_update_hook function, but unclear to me if that is usable for this purpose.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's highly desirable to make this a bit more dynamic, in one way or the otherm but I think that can be improved in later PRs.

if err != nil {
fatal("failed looking up bastions", "err", err)
}
bastionSet.Configure(ctx, logBastions)

// At this point, ownership of bastionSet belongs with the signal goroutine,
// and must no longer be accessed by main goroutine.
onSignal(syscall.SIGHUP, func() {
slog.Info("received SIGHUP, reconfiguring bastions")
logBastions, err := w.AllBastions()
if err != nil {
slog.Warn("failed looking up bastions", "err", err)
return
}
bastionSet.Configure(ctx, logBastions)
})

if *bastionFlag != "" {
go func() {
for _, bastion := range strings.Split(*bastionFlag, ",") {
err := connectToBastion(ctx, bastion, signer, srv)
err := connectToBastion(ctx, bastion, signer, srv, false)
if err == errBastionDisconnected {
// Connection succeeded and then was interrupted. Restart to
// let the scheduler apply any backoff, and then retry all bastions.
Expand All @@ -102,11 +220,13 @@ func main() {
}
e <- errors.New("couldn't connect to any bastion")
}()
} else {
} else if !*noListenFlag {
go func() {
slog.Info("listening", "addr", *listenFlag)
e <- srv.ListenAndServe()
}()
} else if len(logBastions) == 0 {
slog.Warn("configured to not open a listening port, but no bastions configured")
}

select {
Expand Down Expand Up @@ -246,7 +366,7 @@ func indexHandler(w *witness.Witness) http.HandlerFunc {

var errBastionDisconnected = errors.New("connection to bastion interrupted")

func connectToBastion(ctx context.Context, bastion string, signer *signer, srv *http.Server) error {
func connectToBastion(ctx context.Context, bastion string, signer *signer, srv *http.Server, logSpecific bool) error {
slog.Info("connecting to bastion", "bastion", bastion)
cert, err := selfSignedCertificate(signer)
if err != nil {
Expand Down Expand Up @@ -279,7 +399,19 @@ func connectToBastion(ctx context.Context, bastion string, signer *signer, srv *
slog.Info("connecting to bastion failed", "bastion", bastion, "err", err)
return fmt.Errorf("connecting to bastion: %v", err)
}
// Ensure that the connection is closed when our context is cancelled.
ctx, cancel = context.WithCancel(ctx)
defer cancel()
go func(ctx context.Context) {
// TODO: gracefully complete in-flight requests.
<-ctx.Done()
conn.Close()
}(ctx)

slog.Info("connected to bastion", "bastion", bastion)
if logSpecific {
ctx = witness.ContextWithBastion(ctx, bastion)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This write to the ctx variable races with using ctx in the goroutine above.

Comment on lines +402 to +413

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that it is unclear to me why this is needed; it would make sense to me if one could have ServeConn close the connection when the context passed down to that machinery is cancelled.

I also first tried to move creation of this goroutine later, after the ctx = witness.ContextWithBastion(...) call, but then it seemed to have no effect. It's as if the use of context.WithValue breaks parent/child relation, which is weird.

}
// TODO: find a way to surface the fatal error, especially since with
// TLS 1.3 it might be that the bastion rejected the client certificate.
(&http2.Server{
Expand Down
Loading