ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
655
services/oci-l4-relay-guest/runtime.go
Normal file
655
services/oci-l4-relay-guest/runtime.go
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
webAllowStateDirectory = "/var/lib/oci-l4-relay/web-allow"
|
||||
smtpDenyStateDirectory = "/var/lib/oci-l4-relay/smtp-deny"
|
||||
interfaceName = "wg-oci0"
|
||||
logIngestTokenPath = "/etc/oci-l4-relay/log-ingest.token"
|
||||
telemetrySnapshotPath = "/run/oci-l4-relay-telemetry/current.json"
|
||||
privateKeyPath = "/etc/oci-l4-relay/private.key"
|
||||
logIngestGroup = "oci-l4-relay-logs"
|
||||
)
|
||||
|
||||
func bootstrap(ctx context.Context, configuration config) error {
|
||||
publicInterface, err := defaultInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("bootstrap_stage=fail_closed_firewall")
|
||||
if err := configureHost(ctx, configuration, publicInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restoreAdmission(ctx, configuration); err != nil {
|
||||
fmt.Printf("bootstrap_stage=cached_admission cache_restored=false\n")
|
||||
} else {
|
||||
fmt.Printf("bootstrap_stage=cached_admission cache_restored=true\n")
|
||||
}
|
||||
if configuration.MailRelayAddress != "" {
|
||||
if err := restoreSMTPDeny(ctx, configuration); err != nil {
|
||||
fmt.Printf("bootstrap_stage=cached_smtp_deny cache_restored=false\n")
|
||||
} else {
|
||||
fmt.Printf("bootstrap_stage=cached_smtp_deny cache_restored=true\n")
|
||||
}
|
||||
} else if configuration.mailConfigurationError != "" {
|
||||
fmt.Printf("bootstrap_stage=mail_configuration active=false reason=%q\n", configuration.mailConfigurationError)
|
||||
}
|
||||
if fileExists(privateKeyPath) {
|
||||
if err := configureTunnel(ctx, configuration); err != nil {
|
||||
fmt.Printf("bootstrap_stage=cached_tunnel cache_restored=false\n")
|
||||
} else {
|
||||
fmt.Printf("bootstrap_stage=cached_tunnel cache_restored=true\n")
|
||||
}
|
||||
}
|
||||
fmt.Println("bootstrap_stage=instance_identity")
|
||||
clients, err := newOCIClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configureIdentityAndTunnel(ctx, clients, configuration); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("bootstrap_stage=wireguard_handshake")
|
||||
if err := waitForHandshake(ctx, 60*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("bootstrap_stage=fresh_admission")
|
||||
if err := updateAdmissionWithClients(ctx, clients, configuration); err != nil {
|
||||
return err
|
||||
}
|
||||
if configuration.MailRelayAddress != "" {
|
||||
if err := updateSMTPDenyWithClients(ctx, clients, configuration); err != nil {
|
||||
fmt.Println("bootstrap_stage=smtp_deny current_available=false")
|
||||
} else {
|
||||
fmt.Println("bootstrap_stage=smtp_deny current_available=true")
|
||||
}
|
||||
}
|
||||
if err := updateLogCredentialWithClients(ctx, clients, configuration); err != nil {
|
||||
fmt.Println("bootstrap_stage=log_ingest credential_available=false")
|
||||
} else {
|
||||
fmt.Println("bootstrap_stage=log_ingest credential_available=true")
|
||||
}
|
||||
fmt.Println("bootstrap_stage=complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func reconcile(ctx context.Context, configuration config) error {
|
||||
clients, err := newOCIClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configureIdentityAndTunnel(ctx, clients, configuration); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := waitForHandshake(ctx, 180*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateAdmissionWithClients(ctx, clients, configuration); err != nil {
|
||||
return err
|
||||
}
|
||||
if configuration.MailRelayAddress != "" {
|
||||
if err := updateSMTPDenyWithClients(ctx, clients, configuration); err != nil {
|
||||
fmt.Println("reconcile_stage=smtp_deny current_available=false")
|
||||
} else {
|
||||
fmt.Println("reconcile_stage=smtp_deny current_available=true")
|
||||
}
|
||||
}
|
||||
if err := updateLogCredentialWithClients(ctx, clients, configuration); err != nil {
|
||||
fmt.Println("reconcile_stage=log_ingest credential_available=false")
|
||||
} else {
|
||||
fmt.Println("reconcile_stage=log_ingest credential_available=true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureHost(ctx context.Context, configuration config, publicInterface string) error {
|
||||
if err := os.MkdirAll("/etc/oci-l4-relay", 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, err := logIngestGroupID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown("/etc/oci-l4-relay", 0, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod("/etc/oci-l4-relay", 0710); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(webAllowStateDirectory, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if configuration.MailRelayAddress != "" {
|
||||
if err := os.MkdirAll(smtpDenyStateDirectory, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeAtomic("/etc/sysctl.d/90-oci-l4-relay.conf", []byte("net.ipv4.ip_forward = 1\nnet.ipv4.tcp_syncookies = 1\nnet.netfilter.nf_conntrack_acct = 1\nnet.netfilter.nf_conntrack_max = 262144\nnet.netfilter.nf_conntrack_timestamp = 1\n"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
ruleset := renderRuleset(configuration, publicInterface)
|
||||
if err := writeAtomic("/etc/sysconfig/nftables.conf", []byte(ruleset), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
settings := []string{
|
||||
"net.ipv4.ip_forward=1",
|
||||
"net.ipv4.tcp_syncookies=1",
|
||||
"net.netfilter.nf_conntrack_acct=1",
|
||||
"net.netfilter.nf_conntrack_max=262144",
|
||||
"net.netfilter.nf_conntrack_timestamp=1",
|
||||
}
|
||||
for _, setting := range settings {
|
||||
if err := run(ctx, "/usr/sbin/sysctl", "--write", setting); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := run(ctx, "/usr/sbin/nft", "--check", "--file", "/etc/sysconfig/nftables.conf"); err != nil {
|
||||
return err
|
||||
}
|
||||
return run(ctx, "/usr/sbin/nft", "--file", "/etc/sysconfig/nftables.conf")
|
||||
}
|
||||
|
||||
func configureIdentityAndTunnel(ctx context.Context, clients ociClients, configuration config) error {
|
||||
seed, err := clients.getSecret(ctx, configuration.WireGuardSeedSecretOCID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieve WireGuard seed: %w", err)
|
||||
}
|
||||
privateKey, publicKey, err := deriveWireGuardKey(seed)
|
||||
for index := range seed {
|
||||
seed[index] = 0
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
for index := range privateKey {
|
||||
privateKey[index] = 0
|
||||
}
|
||||
}()
|
||||
encodedPrivateKey := []byte(base64.StdEncoding.EncodeToString(privateKey) + "\n")
|
||||
if err := writeAtomic(privateKeyPath, encodedPrivateKey, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range encodedPrivateKey {
|
||||
encodedPrivateKey[index] = 0
|
||||
}
|
||||
encodedPublicKey := base64.StdEncoding.EncodeToString(publicKey)
|
||||
if err := clients.publishSecret(ctx, configuration.PublicKeySecretOCID, []byte(encodedPublicKey)); err != nil {
|
||||
return fmt.Errorf("publish WireGuard public key: %w", err)
|
||||
}
|
||||
return configureTunnel(ctx, configuration)
|
||||
}
|
||||
|
||||
func configureTunnel(ctx context.Context, configuration config) error {
|
||||
if err := ensureWireGuardInterface(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run(ctx, "/usr/bin/wg", "set", interfaceName, "listen-port", strconv.Itoa(configuration.WireGuardListenPort), "private-key", privateKeyPath, "peer", configuration.HomeWireGuardPublicKey, "allowed-ips", "0.0.0.0/0"); err != nil {
|
||||
return err
|
||||
}
|
||||
commands := [][]string{
|
||||
{"/usr/sbin/ip", "address", "replace", configuration.RelayTunnelAddress, "dev", interfaceName},
|
||||
{"/usr/sbin/ip", "link", "set", "dev", interfaceName, "mtu", strconv.Itoa(configuration.RelayTunnelMTU), "up"},
|
||||
{"/usr/sbin/ip", "route", "replace", configuration.GatewayRelayAddress + "/32", "dev", interfaceName},
|
||||
}
|
||||
if configuration.MailRelayAddress != "" {
|
||||
commands = append(commands, []string{"/usr/sbin/ip", "route", "replace", configuration.MailRelayAddress + "/32", "dev", interfaceName})
|
||||
}
|
||||
for _, command := range commands {
|
||||
if err := run(ctx, command[0], command[1:]...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
clearTrafficControl(ctx)
|
||||
for _, command := range trafficControlCommands(configuration) {
|
||||
if err := run(ctx, command[0], command[1:]...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearTrafficControl(ctx context.Context) {
|
||||
// Oracle Linux cannot replace an existing HTB root qdisc; deletion also clears partial bootstrap state.
|
||||
_ = exec.CommandContext(ctx, "/usr/sbin/tc", "qdisc", "delete", "dev", interfaceName, "root").Run()
|
||||
}
|
||||
|
||||
func trafficControlCommands(configuration config) [][]string {
|
||||
rate := strconv.Itoa(configuration.RateLimitMbit) + "mbit"
|
||||
webRate := strconv.Itoa(max(1, configuration.RateLimitMbit*40/100)) + "mbit"
|
||||
smtpRate := strconv.Itoa(max(1, configuration.RateLimitMbit*10/100)) + "mbit"
|
||||
authRate := strconv.Itoa(max(1, configuration.RateLimitMbit*20/100)) + "mbit"
|
||||
defaultRate := strconv.Itoa(max(1, configuration.RateLimitMbit*10/100)) + "mbit"
|
||||
commands := [][]string{
|
||||
{"/usr/sbin/tc", "qdisc", "add", "dev", interfaceName, "root", "handle", "1:", "htb", "default", "40"},
|
||||
{"/usr/sbin/tc", "class", "replace", "dev", interfaceName, "parent", "1:", "classid", "1:1", "htb", "rate", rate, "ceil", rate},
|
||||
{"/usr/sbin/tc", "class", "replace", "dev", interfaceName, "parent", "1:1", "classid", "1:10", "htb", "rate", webRate, "ceil", rate},
|
||||
{"/usr/sbin/tc", "class", "replace", "dev", interfaceName, "parent", "1:1", "classid", "1:20", "htb", "rate", smtpRate, "ceil", rate},
|
||||
{"/usr/sbin/tc", "class", "replace", "dev", interfaceName, "parent", "1:1", "classid", "1:30", "htb", "rate", authRate, "ceil", rate},
|
||||
{"/usr/sbin/tc", "class", "replace", "dev", interfaceName, "parent", "1:1", "classid", "1:40", "htb", "rate", defaultRate, "ceil", rate},
|
||||
{"/usr/sbin/tc", "qdisc", "replace", "dev", interfaceName, "parent", "1:10", "handle", "10:", "fq_codel"},
|
||||
{"/usr/sbin/tc", "qdisc", "replace", "dev", interfaceName, "parent", "1:20", "handle", "20:", "fq_codel"},
|
||||
{"/usr/sbin/tc", "qdisc", "replace", "dev", interfaceName, "parent", "1:30", "handle", "30:", "fq_codel"},
|
||||
{"/usr/sbin/tc", "qdisc", "replace", "dev", interfaceName, "parent", "1:40", "handle", "40:", "fq_codel"},
|
||||
}
|
||||
priority := 10
|
||||
addFilter := func(address string, port int, classID string) {
|
||||
commands = append(commands, []string{
|
||||
"/usr/sbin/tc", "filter", "replace", "dev", interfaceName,
|
||||
"protocol", "ip", "parent", "1:", "prio", strconv.Itoa(priority),
|
||||
"flower", "dst_ip", address, "ip_proto", "tcp",
|
||||
"dst_port", strconv.Itoa(port), "classid", classID,
|
||||
})
|
||||
priority++
|
||||
}
|
||||
addFilter(configuration.GatewayRelayAddress, 80, "1:10")
|
||||
addFilter(configuration.GatewayRelayAddress, 443, "1:10")
|
||||
if configuration.MailRelayAddress != "" {
|
||||
addFilter(configuration.MailRelayAddress, 25, "1:20")
|
||||
for _, port := range []int{465, 993} {
|
||||
addFilter(configuration.MailRelayAddress, port, "1:30")
|
||||
}
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func ensureWireGuardInterface(ctx context.Context) error {
|
||||
if exec.CommandContext(ctx, "/usr/sbin/ip", "link", "show", "dev", interfaceName).Run() == nil {
|
||||
return nil
|
||||
}
|
||||
return run(ctx, "/usr/sbin/ip", "link", "add", "dev", interfaceName, "type", "wireguard")
|
||||
}
|
||||
|
||||
func deriveWireGuardKey(seed []byte) ([]byte, []byte, error) {
|
||||
digest := sha256.Sum256(seed)
|
||||
privateKey := append([]byte(nil), digest[:]...)
|
||||
privateKey[0] &= 248
|
||||
privateKey[31] &= 127
|
||||
privateKey[31] |= 64
|
||||
key, err := ecdh.X25519().NewPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return privateKey, key.PublicKey().Bytes(), nil
|
||||
}
|
||||
|
||||
func updateAdmission(ctx context.Context, configuration config) error {
|
||||
clients, err := newOCIClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return updateAdmissionWithClients(ctx, clients, configuration)
|
||||
}
|
||||
|
||||
func updateSMTPDeny(ctx context.Context, configuration config) error {
|
||||
clients, err := newOCIClients()
|
||||
if err != nil {
|
||||
return completeSMTPDenyRefresh(err)
|
||||
}
|
||||
return updateSMTPDenyWithClients(ctx, clients, configuration)
|
||||
}
|
||||
|
||||
func updateLogCredential(ctx context.Context, configuration config) error {
|
||||
clients, err := newOCIClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return updateLogCredentialWithClients(ctx, clients, configuration)
|
||||
}
|
||||
|
||||
func updateLogCredentialWithClients(ctx context.Context, clients ociClients, configuration config) error {
|
||||
token, err := clients.getSecret(ctx, configuration.LogIngestSecretOCID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieve log ingestion credential: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
for index := range token {
|
||||
token[index] = 0
|
||||
}
|
||||
}()
|
||||
if !validLogIngestToken(token) {
|
||||
return errors.New("log ingestion credential is invalid")
|
||||
}
|
||||
groupID, err := logIngestGroupID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomicOwned(logIngestTokenPath, token, 0640, 0, groupID)
|
||||
}
|
||||
|
||||
func logIngestGroupID() (int, error) {
|
||||
group, err := user.LookupGroup(logIngestGroup)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("look up log ingestion group: %w", err)
|
||||
}
|
||||
groupID, err := strconv.Atoi(group.Gid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse log ingestion group ID: %w", err)
|
||||
}
|
||||
return groupID, nil
|
||||
}
|
||||
|
||||
func validLogIngestToken(token []byte) bool {
|
||||
if len(token) < 32 || len(token) > 256 {
|
||||
return false
|
||||
}
|
||||
for _, value := range token {
|
||||
if !validLogIngestTokenByte(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validLogIngestTokenByte(value byte) bool {
|
||||
switch {
|
||||
case value >= 'a' && value <= 'z', value >= 'A' && value <= 'Z', value >= '0' && value <= '9':
|
||||
return true
|
||||
default:
|
||||
return value == '-' || value == '_' || value == '.'
|
||||
}
|
||||
}
|
||||
|
||||
func updateAdmissionWithClients(ctx context.Context, clients ociClients, configuration config) error {
|
||||
path, cleanup, err := clients.downloadAdmission(ctx, configuration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
publicInterface, err := defaultInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAdmission(ctx, configuration, "apply", "--input", path, "--public-interface", publicInterface)
|
||||
}
|
||||
|
||||
func restoreAdmission(ctx context.Context, configuration config) error {
|
||||
publicInterface, err := defaultInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAdmission(ctx, configuration, "restore", "--public-interface", publicInterface)
|
||||
}
|
||||
|
||||
func runAdmission(ctx context.Context, configuration config, arguments ...string) error {
|
||||
arguments = append(arguments,
|
||||
"--state-directory", webAllowStateDirectory,
|
||||
"--minimum-admitted-addresses", strconv.FormatUint(configuration.Admission.MinimumAddresses, 10),
|
||||
"--maximum-admitted-addresses", strconv.FormatUint(configuration.Admission.MaximumAddresses, 10),
|
||||
"--minimum-admitted-intervals", strconv.Itoa(configuration.Admission.MinimumIntervals),
|
||||
)
|
||||
for _, address := range configuration.MonitoringSourceIPv4 {
|
||||
arguments = append(arguments, "--static-exception-ipv4", address)
|
||||
}
|
||||
return run(ctx, configuration.AdmissionHelper, arguments...)
|
||||
}
|
||||
|
||||
func updateSMTPDenyWithClients(ctx context.Context, clients ociClients, configuration config) (operationErr error) {
|
||||
defer func() {
|
||||
operationErr = completeSMTPDenyRefresh(operationErr)
|
||||
}()
|
||||
path, cleanup, err := clients.downloadSMTPDeny(ctx, configuration)
|
||||
if err != nil {
|
||||
return errors.Join(err, restoreSMTPDeny(ctx, configuration))
|
||||
}
|
||||
defer cleanup()
|
||||
publicInterface, err := defaultInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runSMTPDeny(ctx, configuration, "apply", "--input", path, "--public-interface", publicInterface)
|
||||
}
|
||||
|
||||
func restoreSMTPDeny(ctx context.Context, configuration config) error {
|
||||
publicInterface, err := defaultInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runSMTPDeny(ctx, configuration, "restore", "--public-interface", publicInterface)
|
||||
}
|
||||
|
||||
func runSMTPDeny(ctx context.Context, configuration config, arguments ...string) error {
|
||||
if configuration.SMTPDeny == nil || configuration.SMTPDenyHelper == "" {
|
||||
return errors.New("SMTP deny configuration is inactive")
|
||||
}
|
||||
arguments = append(arguments,
|
||||
"--state-directory", smtpDenyStateDirectory,
|
||||
"--minimum-observations", strconv.FormatUint(configuration.SMTPDeny.MinimumObservations, 10),
|
||||
"--maximum-addresses", strconv.FormatUint(configuration.SMTPDeny.MaximumAddresses, 10),
|
||||
"--maximum-intervals", strconv.Itoa(configuration.SMTPDeny.MaximumIntervals),
|
||||
"--maximum-lifetime", strconv.Itoa(configuration.SMTPDeny.MaximumLifetimeSeconds)+"s",
|
||||
"--maximum-observation-window", strconv.Itoa(configuration.SMTPDeny.MaximumObservationWindow)+"s",
|
||||
)
|
||||
return run(ctx, configuration.SMTPDenyHelper, arguments...)
|
||||
}
|
||||
|
||||
func waitForHandshake(ctx context.Context, maximumAge time.Duration) error {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
latest, err := latestHandshake(ctx)
|
||||
if err == nil && latest > 0 && time.Since(time.Unix(latest, 0)) <= maximumAge {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for WireGuard handshake: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestHandshake(ctx context.Context) (int64, error) {
|
||||
output, err := exec.CommandContext(ctx, "/usr/bin/wg", "show", interfaceName, "latest-handshakes").Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) < 2 {
|
||||
return 0, errors.New("WireGuard peer handshake is absent")
|
||||
}
|
||||
return strconv.ParseInt(fields[1], 10, 64)
|
||||
}
|
||||
|
||||
func defaultInterface() (string, error) {
|
||||
contents, err := os.ReadFile("/proc/net/route")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, line := range strings.Split(string(contents), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 && fields[1] == "00000000" {
|
||||
flags, err := strconv.ParseUint(fields[3], 16, 16)
|
||||
if err == nil && flags&1 != 0 {
|
||||
return fields[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("default IPv4 interface not found")
|
||||
}
|
||||
|
||||
func writeAtomic(path string, contents []byte, mode os.FileMode) error {
|
||||
return writeAtomicOwned(path, contents, mode, -1, -1)
|
||||
}
|
||||
|
||||
func writeAtomicOwned(path string, contents []byte, mode os.FileMode, ownerID, groupID int) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".write-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := file.Name()
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if err := file.Chmod(mode); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if ownerID >= 0 || groupID >= 0 {
|
||||
if err := file.Chown(ownerID, groupID); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := file.Write(contents); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryPath, path)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, path string, arguments ...string) error {
|
||||
command := exec.CommandContext(ctx, path, arguments...)
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w: %s", filepath.Base(path), err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type status struct {
|
||||
ConntrackCount int64 `json:"conntrack_count"`
|
||||
ConntrackMaximum int64 `json:"conntrack_maximum"`
|
||||
HandshakeAgeSecond int64 `json:"handshake_age_seconds"`
|
||||
NftablesActive bool `json:"nftables_active"`
|
||||
SMTPDenyActive bool `json:"smtp_deny_active"`
|
||||
SMTPDenyActiveSource string `json:"smtp_deny_active_source,omitempty"`
|
||||
SMTPDenyBundleAgeSeconds int64 `json:"smtp_deny_bundle_age_seconds"`
|
||||
SMTPDenyBundleRemainingSeconds int64 `json:"smtp_deny_bundle_remaining_seconds"`
|
||||
SMTPDenyCached bool `json:"smtp_deny_cached"`
|
||||
SMTPDenyPolicyValid bool `json:"smtp_deny_policy_valid"`
|
||||
SMTPDenyRefreshCompletedAgeSeconds int64 `json:"smtp_deny_refresh_completed_age_seconds"`
|
||||
SMTPDenyRefreshSuccessful bool `json:"smtp_deny_refresh_successful"`
|
||||
SMTPDenyRemoteCurrentAgeSeconds int64 `json:"smtp_deny_remote_current_age_seconds"`
|
||||
WebAllowActive bool `json:"web_allow_active"`
|
||||
WebAllowCached bool `json:"web_allow_cached"`
|
||||
WireGuardActive bool `json:"wireguard_active"`
|
||||
}
|
||||
|
||||
type telemetryFile struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Status status `json:"status"`
|
||||
}
|
||||
|
||||
func currentStatus(configuration config) status {
|
||||
result := status{
|
||||
SMTPDenyBundleAgeSeconds: -1,
|
||||
SMTPDenyBundleRemainingSeconds: -1,
|
||||
SMTPDenyRefreshCompletedAgeSeconds: -1,
|
||||
SMTPDenyRemoteCurrentAgeSeconds: -1,
|
||||
}
|
||||
result.NftablesActive = exec.Command("/usr/sbin/nft", "list", "table", "inet", "oci_l4_relay").Run() == nil
|
||||
result.WebAllowActive = exec.Command("/usr/sbin/nft", "list", "table", "inet", "oci_l4_relay_web_allow").Run() == nil
|
||||
result.SMTPDenyActive = exec.Command("/usr/sbin/nft", "list", "table", "inet", "oci_l4_relay_smtp_deny").Run() == nil
|
||||
result.WireGuardActive = exec.Command("/usr/sbin/ip", "link", "show", "dev", interfaceName).Run() == nil
|
||||
result.WebAllowCached = fileExists(filepath.Join(webAllowStateDirectory, "last-good.json.gz"))
|
||||
if configuration.MailRelayAddress != "" {
|
||||
now := time.Now().UTC()
|
||||
if active, err := readSMTPDenyActiveState(smtpDenyStateDirectory); err == nil {
|
||||
result.SMTPDenyActiveSource = active.Source
|
||||
result.SMTPDenyPolicyValid = active.validAt(now, time.Duration(configuration.SMTPDeny.MaximumLifetimeSeconds)*time.Second)
|
||||
result.SMTPDenyCached = result.SMTPDenyPolicyValid && fileExists(filepath.Join(smtpDenyStateDirectory, "last-good.json.gz"))
|
||||
if !active.BundleGeneratedAt.IsZero() {
|
||||
result.SMTPDenyBundleAgeSeconds = max(0, int64(now.Sub(active.BundleGeneratedAt).Seconds()))
|
||||
}
|
||||
if !active.BundleExpiresAt.IsZero() {
|
||||
result.SMTPDenyBundleRemainingSeconds = int64(active.BundleExpiresAt.Sub(now).Seconds())
|
||||
}
|
||||
if !active.LastRemoteCurrentAt.IsZero() {
|
||||
result.SMTPDenyRemoteCurrentAgeSeconds = max(0, int64(now.Sub(active.LastRemoteCurrentAt).Seconds()))
|
||||
}
|
||||
}
|
||||
if refresh, err := readSMTPDenyRefreshState(smtpDenyStateDirectory); err == nil && !refresh.CompletedAt.IsZero() && !refresh.CompletedAt.After(now.Add(5*time.Minute)) {
|
||||
result.SMTPDenyRefreshCompletedAgeSeconds = max(0, int64(now.Sub(refresh.CompletedAt).Seconds()))
|
||||
result.SMTPDenyRefreshSuccessful = refresh.Successful && refresh.ActiveSource == "current"
|
||||
}
|
||||
}
|
||||
result.ConntrackCount = readInteger("/proc/sys/net/netfilter/nf_conntrack_count")
|
||||
result.ConntrackMaximum = readInteger("/proc/sys/net/netfilter/nf_conntrack_max")
|
||||
if latest, err := latestHandshake(context.Background()); err == nil && latest > 0 {
|
||||
result.HandshakeAgeSecond = max(0, time.Now().Unix()-latest)
|
||||
} else {
|
||||
result.HandshakeAgeSecond = -1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func reportStatus(configuration config, requireHealthy bool) error {
|
||||
result := currentStatus(configuration)
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
mailPolicyUnhealthy := configuration.MailRelayAddress != "" && (!result.SMTPDenyActive || !result.SMTPDenyPolicyValid)
|
||||
if requireHealthy && (!result.NftablesActive || !result.WebAllowActive || !result.WebAllowCached || !result.WireGuardActive || mailPolicyUnhealthy || result.HandshakeAgeSecond < 0 || result.HandshakeAgeSecond > 180) {
|
||||
return errors.New("relay is unhealthy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reportTelemetry(ctx context.Context, configuration config) error {
|
||||
result := currentStatus(configuration)
|
||||
generatedAt := time.Now().UTC()
|
||||
snapshot, err := json.Marshal(telemetryFile{GeneratedAt: generatedAt, Status: result})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(telemetrySnapshotPath, append(snapshot, '\n'), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("oci_l4_relay_telemetry timestamp=%s conntrack_count=%d conntrack_max=%d wireguard_handshake_age_seconds=%d web_allow_cached=%t smtp_deny_cached=%t smtp_deny_policy_valid=%t smtp_deny_active_source=%s smtp_deny_bundle_age_seconds=%d smtp_deny_bundle_remaining_seconds=%d smtp_deny_remote_current_age_seconds=%d smtp_deny_refresh_successful=%t smtp_deny_refresh_completed_age_seconds=%d nftables_active=%t wireguard_active=%t\n", generatedAt.Format(time.RFC3339), result.ConntrackCount, result.ConntrackMaximum, result.HandshakeAgeSecond, result.WebAllowCached, result.SMTPDenyCached, result.SMTPDenyPolicyValid, result.SMTPDenyActiveSource, result.SMTPDenyBundleAgeSeconds, result.SMTPDenyBundleRemainingSeconds, result.SMTPDenyRemoteCurrentAgeSeconds, result.SMTPDenyRefreshSuccessful, result.SMTPDenyRefreshCompletedAgeSeconds, result.NftablesActive, result.WireGuardActive)
|
||||
for _, command := range [][]string{
|
||||
{"/usr/sbin/nft", "-j", "list", "counters"},
|
||||
{"/usr/sbin/tc", "-s", "-j", "qdisc", "show", "dev", interfaceName},
|
||||
{"/usr/sbin/ip", "-s", "-j", "link", "show", "dev", interfaceName},
|
||||
} {
|
||||
output, err := exec.CommandContext(ctx, command[0], command[1:]...).Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("oci_l4_relay_telemetry detail=%s\n", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readInteger(path string) int64 {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(string(contents)), 10, 64)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return value
|
||||
}
|
||||
Loading…
Reference in a new issue