Add a local unix socket for root on the server
jarld also listens on /run/jarl/jarl.sock (socket in jarl.yaml). Requests on it from root or jarld's own user need no token and act as an administrator; the kernel's SO_PEERCRED says who is connecting, and anyone else must send a token. The jarl CLI uses the socket when no server is configured, or with -s local, so 'sudo jarl status' works on the server with nothing set up and a lost token or password never locks root out. Changes are audited under the system user's name via "socket". The systemd unit creates /run/jarl (0750). Socket paths over 107 bytes are refused with a clear message. The docs explain the socket and what happens when a token is revoked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f528c2aa9c
commit
cf49df0346
22 files changed
+570
-26
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -57,11 +58,19 @@ func New(o Options) (*Client, error) {
|
||||
if o.Server == "" {
|
||||
return nil, fmt.Errorf("no server configured: use --server, JARL_SERVER or 'jarl config set server https://...'")
|
||||
}
|
||||
u, err := url.Parse(o.Server)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dns.example.com:8053)", o.Server)
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base := strings.TrimSuffix(o.Server, "/")
|
||||
if path, ok := SocketPath(o.Server); ok {
|
||||
// jarld's local unix socket: every request goes to the socket,
|
||||
// whatever the URL's host.
|
||||
tr.Proxy = nil
|
||||
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
||||
}
|
||||
base = "http://jarld"
|
||||
} else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dns.example.com:8053, or unix:///run/jarl/jarl.sock)", o.Server)
|
||||
}
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // explicit user choice
|
||||
if o.CACert != "" {
|
||||
pem, err := os.ReadFile(o.CACert)
|
||||
@@ -82,7 +91,17 @@ func New(o Options) (*Client, error) {
|
||||
if timeout == 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
return &Client{BaseURL: strings.TrimSuffix(o.Server, "/"), Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil
|
||||
return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil
|
||||
}
|
||||
|
||||
// SocketPath returns the path of a unix:// server URL
|
||||
// ("unix:///run/jarl/jarl.sock").
|
||||
func SocketPath(server string) (string, bool) {
|
||||
path, ok := strings.CutPrefix(server, "unix://")
|
||||
if !ok || !strings.HasPrefix(path, "/") {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Error is an API error.
|
||||
|
||||
@@ -22,14 +22,17 @@ type Config struct {
|
||||
Listen []string `yaml:"listen"`
|
||||
TLS TLS `yaml:"tls"`
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
Database string `yaml:"database"`
|
||||
StagingDir string `yaml:"staging_dir"`
|
||||
Log Log `yaml:"log"`
|
||||
Session Session `yaml:"session"`
|
||||
BIND BIND `yaml:"bind"`
|
||||
Audit Audit `yaml:"audit"`
|
||||
Stats Stats `yaml:"stats"`
|
||||
ACME ACME `yaml:"acme"`
|
||||
// Socket is a local unix socket for the API, where root and jarld's
|
||||
// own user need no token ("" for none).
|
||||
Socket string `yaml:"socket"`
|
||||
Database string `yaml:"database"`
|
||||
StagingDir string `yaml:"staging_dir"`
|
||||
Log Log `yaml:"log"`
|
||||
Session Session `yaml:"session"`
|
||||
BIND BIND `yaml:"bind"`
|
||||
Audit Audit `yaml:"audit"`
|
||||
Stats Stats `yaml:"stats"`
|
||||
ACME ACME `yaml:"acme"`
|
||||
}
|
||||
|
||||
// ACME configures the ACME helpers.
|
||||
@@ -168,6 +171,7 @@ func ParseDuration(s string) (time.Duration, error) {
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Listen: []string{"127.0.0.1:8053"},
|
||||
Socket: "/run/jarl/jarl.sock",
|
||||
Database: "/var/lib/jarl/jarl.db",
|
||||
StagingDir: "/var/lib/jarl/staging",
|
||||
Log: Log{Level: "info", Format: "text"},
|
||||
@@ -220,6 +224,12 @@ func (c *Config) check() error {
|
||||
if len(c.Listen) == 0 {
|
||||
return fmt.Errorf("listen: at least one address is needed")
|
||||
}
|
||||
if c.Socket != "" && !filepath.IsAbs(c.Socket) {
|
||||
return fmt.Errorf("socket must be an absolute path (or \"\" for none)")
|
||||
}
|
||||
if len(c.Socket) > 107 {
|
||||
return fmt.Errorf("socket: the path is %d bytes long; unix sockets allow at most 107", len(c.Socket))
|
||||
}
|
||||
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
|
||||
return fmt.Errorf("tls: set both cert_file and key_file")
|
||||
}
|
||||
@@ -334,6 +344,12 @@ tls:
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
|
||||
# A local unix socket for the API and the jarl CLI. Requests on it from
|
||||
# root or jarld's own user need no token and have administrator rights
|
||||
# ('sudo jarl status' works with nothing configured); anyone else must send
|
||||
# a token. systemd creates /run/jarl. "" turns the socket off.
|
||||
socket: /run/jarl/jarl.sock
|
||||
|
||||
# Reverse proxies whose X-Forwarded-For / X-Forwarded-Proto headers are
|
||||
# trusted (addresses or prefixes).
|
||||
trusted_proxies: []
|
||||
|
||||
@@ -110,6 +110,14 @@ var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated",
|
||||
// cookie. For sessions, state-changing requests must carry the CSRF token.
|
||||
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
|
||||
ip := s.clientIP(r)
|
||||
// On the local socket, root and jarld's own user need no token
|
||||
// (socket.go). A token, if sent, is used as anywhere else.
|
||||
if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" {
|
||||
if trustedUID(pr.UID) {
|
||||
return socketPrincipal(pr, r.UserAgent()), nil, nil
|
||||
}
|
||||
return nil, nil, socketDenied(pr)
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
scheme, tok, _ := strings.Cut(h, " ")
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
|
||||
@@ -196,6 +196,9 @@ func narrower(a, b []model.Grant) bool {
|
||||
}
|
||||
|
||||
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.User.ID == 0 {
|
||||
return badRequest("%s on the local socket isn't a Jarl user and has no tokens; create the token for a user (jarl token create NAME --user admin)", p.Name())
|
||||
}
|
||||
return s.createTokenFor(w, r, p, p.User)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ type Config struct {
|
||||
Stats *stats.Collector
|
||||
// HTTPReq configures the endpoint for lego's httpreq DNS provider.
|
||||
HTTPReq HTTPReq
|
||||
// Socket is the local unix socket's path ("" for none); see socket.go.
|
||||
Socket string
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
@@ -104,7 +106,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
s.certs = cr
|
||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
|
||||
}
|
||||
errc := make(chan error, len(s.cfg.Listen))
|
||||
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||
var listeners []net.Listener
|
||||
for _, addr := range s.cfg.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
@@ -155,6 +157,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.serveSocket(errc)
|
||||
s.logHTTPReq(tlsCfg != nil)
|
||||
if ready != nil {
|
||||
ready()
|
||||
@@ -170,6 +173,37 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveSocket serves the API on the local unix socket. A socket that
|
||||
// can't be created (e.g. /run/jarl missing outside systemd) is logged, not
|
||||
// fatal: the network listeners still work.
|
||||
func (s *Server) serveSocket(errc chan error) {
|
||||
if s.cfg.Socket == "" {
|
||||
return
|
||||
}
|
||||
ln, err := listenSocket(s.cfg.Socket)
|
||||
if err != nil {
|
||||
s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err)
|
||||
return
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ConnContext: socketConnContext,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
s.log.Info("listening", "socket", s.cfg.Socket)
|
||||
go func() {
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) shutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -178,6 +212,9 @@ func (s *Server) shutdown() {
|
||||
for _, srv := range s.srvs {
|
||||
srv.Shutdown(ctx)
|
||||
}
|
||||
if s.cfg.Socket != "" {
|
||||
os.Remove(s.cfg.Socket)
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadTLS rereads the TLS certificate (on SIGHUP).
|
||||
@@ -344,6 +381,9 @@ func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
// clientIP returns the client's address, honoring X-Forwarded-For from
|
||||
// trusted proxies.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if peerOf(r.Context()) != nil {
|
||||
return "local"
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
@@ -413,6 +453,9 @@ func (s *Server) transport(r *http.Request) (https bool, why string) {
|
||||
if r.TLS != nil {
|
||||
return true, "HTTPS to jarld (tls in jarl.yaml)"
|
||||
}
|
||||
if peerOf(r.Context()) != nil {
|
||||
return true, "jarld's local unix socket (never crosses a network)"
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
// The local unix socket (socket in jarl.yaml, /run/jarl/jarl.sock by
|
||||
// default). It serves the same API as the network listeners, but a request
|
||||
// on it without an Authorization header is trusted by who sent it: the
|
||||
// kernel reports the connecting process's user (SO_PEERCRED), and root and
|
||||
// jarld's own user get administrator rights without a token. Both can
|
||||
// already do everything (read the database, run 'jarld user token', edit
|
||||
// named.conf), so this adds convenience, not power: 'sudo jarl status'
|
||||
// works on the server with nothing configured, and a lost token or
|
||||
// password never locks out root.
|
||||
//
|
||||
// Anyone else who can reach the socket must send an API token, which then
|
||||
// gives that token's permissions, as on the network. The socket file is
|
||||
// mode 0600 in a 0750 directory, so normally only root and the jarl user
|
||||
// can connect at all; the user check doesn't rely on that.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// peer is the process at the other end of a unix socket connection.
|
||||
type peer struct {
|
||||
UID int
|
||||
PID int
|
||||
}
|
||||
|
||||
type peerKey struct{}
|
||||
|
||||
// peerOf returns the socket peer of a request, or nil for network
|
||||
// requests.
|
||||
func peerOf(ctx context.Context) *peer {
|
||||
p, _ := ctx.Value(peerKey{}).(*peer)
|
||||
return p
|
||||
}
|
||||
|
||||
// socketConnContext records the peer's credentials on each connection to
|
||||
// the unix socket.
|
||||
func socketConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return ctx
|
||||
}
|
||||
p := &peer{UID: -1}
|
||||
if raw, err := uc.SyscallConn(); err == nil {
|
||||
raw.Control(func(fd uintptr) {
|
||||
if cred, err := syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err == nil {
|
||||
p.UID, p.PID = int(cred.Uid), int(cred.Pid)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Even when the credentials can't be read, the request is marked as a
|
||||
// socket request (with no trusted user).
|
||||
return context.WithValue(ctx, peerKey{}, p)
|
||||
}
|
||||
|
||||
// trustedUID reports whether a socket peer gets administrator rights
|
||||
// without a token: root and the user jarld runs as.
|
||||
func trustedUID(uid int) bool {
|
||||
return uid == 0 || uid == os.Getuid()
|
||||
}
|
||||
|
||||
// socketPrincipal is the administrator for a trusted socket peer. It isn't
|
||||
// a Jarl user (ID 0), so it has no tokens or password; the audit log and
|
||||
// history show it by its system user name, e.g. "root", with method
|
||||
// "socket".
|
||||
func socketPrincipal(p *peer, agent string) *auth.Principal {
|
||||
name := "uid " + strconv.Itoa(p.UID)
|
||||
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
return &auth.Principal{User: &model.User{ID: 0, Username: name, Admin: true}, Method: "socket", IP: "local", Agent: agent}
|
||||
}
|
||||
|
||||
// listenSocket creates the unix socket, replacing a stale one left by a
|
||||
// previous run.
|
||||
func listenSocket(path string) (net.Listener, error) {
|
||||
if len(path) > 107 {
|
||||
return nil, fmt.Errorf("the path is %d bytes long; unix sockets allow at most 107", len(path))
|
||||
}
|
||||
if fi, err := os.Lstat(path); err == nil {
|
||||
if fi.Mode()&fs.ModeSocket == 0 {
|
||||
return nil, fmt.Errorf("%s exists and isn't a socket", path)
|
||||
}
|
||||
// A socket nobody answers on is left over from a previous run.
|
||||
if c, err := net.Dial("unix", path); err == nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("%s is in use by another jarld", path)
|
||||
}
|
||||
os.Remove(path)
|
||||
}
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
// socketDenied explains a refused tokenless request on the socket.
|
||||
func socketDenied(p *peer) error {
|
||||
who := "an unknown user"
|
||||
if p.UID >= 0 {
|
||||
who = "uid " + strconv.Itoa(p.UID)
|
||||
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
}
|
||||
return &httpError{http.StatusUnauthorized, "unauthenticated", "the local socket gives administrator rights without a token only to root and jarld's own user, not to " + who + "; send an API token (--token), or run the command as root"}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/client"
|
||||
)
|
||||
|
||||
// serveTestSocket serves e's API on a unix socket, as jarld does.
|
||||
func serveTestSocket(t *testing.T, e *apiEnv) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "jarl.sock")
|
||||
ln, err := listenSocket(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &http.Server{Handler: e.srv.Handler(), ConnContext: socketConnContext}
|
||||
go srv.Serve(ln)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSocketTrustedUser(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
path := serveTestSocket(t, e)
|
||||
if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("socket mode: %v %v", fi.Mode(), err)
|
||||
}
|
||||
// The test runs as the same user as the server: an administrator
|
||||
// without a token.
|
||||
c, err := client.New(client.Options{Server: "unix://" + path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var who api.Whoami
|
||||
if err := c.Get(context.Background(), "/auth/whoami", nil, &who); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
me, _ := user.Current()
|
||||
if !who.Admin || who.Method != "socket" || who.User.Username != me.Username || who.Token != nil {
|
||||
t.Errorf("whoami over the socket = %+v", who)
|
||||
}
|
||||
// It can change things, and the history and audit log name the
|
||||
// system user.
|
||||
if err := c.Do(context.Background(), "POST", "/zones", nil, map[string]any{"name": "sock.example", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var events []map[string]any
|
||||
c.Get(context.Background(), "/audit", nil, &events)
|
||||
found := false
|
||||
for _, ev := range events {
|
||||
if ev["action"] == "changeset.apply" && ev["username"] == me.Username && ev["auth"] == "socket" && ev["ip"] == "local" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no audit event for the socket user: %v", events)
|
||||
}
|
||||
// A token sent over the socket is used as such.
|
||||
c.Token = e.token
|
||||
if err := c.Get(context.Background(), "/auth/whoami", nil, &who); err != nil || who.Method != "token" {
|
||||
t.Errorf("token over the socket: %+v %v", who, err)
|
||||
}
|
||||
// The socket's user isn't a Jarl user, so it has no tokens of its own.
|
||||
c.Token = ""
|
||||
err = c.Do(context.Background(), "POST", "/me/tokens", nil, map[string]any{"name": "x"}, nil)
|
||||
var ae *client.Error
|
||||
if !errors.As(err, &ae) || ae.Status != 400 {
|
||||
t.Errorf("own token over the socket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocketOtherUserNeedsToken(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
req := func(uid int, token string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", api.Prefix+"/auth/whoami", nil)
|
||||
r = r.WithContext(context.WithValue(r.Context(), peerKey{}, &peer{UID: uid}))
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.srv.Handler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
other := os.Getuid() + 4242
|
||||
if w := req(other, ""); w.Code != 401 || !strings.Contains(w.Body.String(), "only to root and jarld's own user") {
|
||||
t.Errorf("untrusted user without a token: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := req(-1, ""); w.Code != 401 {
|
||||
t.Errorf("unknown peer without a token: %d", w.Code)
|
||||
}
|
||||
if w := req(other, e.token); w.Code != 200 {
|
||||
t.Errorf("untrusted user with a token: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := req(0, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"method":"socket"`) {
|
||||
t.Errorf("root: %d %s", w.Code, w.Body)
|
||||
}
|
||||
// Network requests never get this treatment.
|
||||
r := httptest.NewRequest("GET", api.Prefix+"/auth/whoami", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.srv.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 401 {
|
||||
t.Errorf("network request without a token: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenSocketStale(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "jarl.sock")
|
||||
// A socket left by a process that is gone is replaced.
|
||||
old, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.(*net.UnixListener).SetUnlinkOnClose(false)
|
||||
old.Close()
|
||||
ln, err := listenSocket(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stale socket not replaced: %v", err)
|
||||
}
|
||||
// One that is in use isn't.
|
||||
if _, err := listenSocket(path); err == nil || !strings.Contains(err.Error(), "in use") {
|
||||
t.Errorf("second jarld on the same socket: %v", err)
|
||||
}
|
||||
ln.Close()
|
||||
// A file that isn't a socket is never removed.
|
||||
os.WriteFile(path, []byte("x"), 0o600)
|
||||
if _, err := listenSocket(path); err == nil {
|
||||
t.Error("replaced a regular file")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user