//go:build linux package host import ( "bufio" "bytes" "context" "encoding/json" "errors" "io" "net" "os" "path/filepath" "sync" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/controlsock" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "golang.org/x/sys/unix" ) const filesSeedSocket = "/run/carlos/files-seed.sock" type filesSeedRequest struct { Verb string `json:"verb"` Host string `json:"host,omitempty"` } type filesSeedReply struct { Text string `json:"text,omitempty"` Code *int `json:"code,omitempty"` } type filesSeedServer struct { listener *net.UnixListener cancel context.CancelFunc done chan struct{} handlers sync.WaitGroup finalize func(context.Context) error } type filesSeedStream struct { conn net.Conn encoder *json.Encoder } func (s filesSeedStream) Write(p []byte) (int, error) { s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) if err := s.encoder.Encode(filesSeedReply{Text: string(p)}); err != nil { return 0, err } return len(p), nil } func startFilesSeedSocket(ctx context.Context, ready <-chan struct{}, path string, auth func(context.Context, *net.UnixConn) error, run func(context.Context, string, io.Writer) error) (*filesSeedServer, error) { select { case <-ctx.Done(): return nil, ctx.Err() case <-ready: } if err := ctx.Err(); err != nil { return nil, err } if auth == nil || run == nil { return nil, hostbox.ErrUnsupported } parent, err := os.Lstat(filepath.Dir(path)) if err != nil { return nil, err } var dir unix.Stat_t if err := unix.Lstat(filepath.Dir(path), &dir); err != nil { return nil, err } if !parent.IsDir() || parent.Mode()&os.ModeSymlink != 0 || parent.Mode().Perm()&0022 != 0 || int(dir.Uid) != os.Geteuid() { return nil, ErrClaimStopIdentity } if err := removeStaleFilesSeedSocket(ctx, path); err != nil { return nil, err } listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) if err != nil { return nil, err } if err := os.Chmod(path, 0600); err != nil { listener.Close() return nil, err } if err := ctx.Err(); err != nil { listener.Close() return nil, err } live, cancel := context.WithCancel(ctx) live, finish := activator.WithCleanupBudget(live, 30*time.Second) s := &filesSeedServer{listener: listener, cancel: cancel, done: make(chan struct{})} stop := context.AfterFunc(live, func() { listener.Close() }) go func() { defer close(s.done) defer finish() defer stop() defer listener.Close() busy := make(chan struct{}, 1) for { conn, err := listener.AcceptUnix() if err != nil { break } select { case busy <- struct{}{}: default: conn.Close() continue } s.handlers.Add(1) go func() { defer s.handlers.Done() defer func() { <-busy }() defer conn.Close() serveFilesSeed(live, conn, auth, run) }() } s.handlers.Wait() }() return s, nil } func (s *filesSeedServer) close(ctx context.Context) error { if s == nil { return nil } s.cancel() s.listener.Close() select { case <-s.done: if s.finalize != nil { return s.finalize(ctx) } return nil case <-ctx.Done(): return ctx.Err() } } func serveFilesSeed(parent context.Context, conn *net.UnixConn, auth func(context.Context, *net.UnixConn) error, run func(context.Context, string, io.Writer) error) { ctx, cancel := context.WithCancel(parent) defer cancel() ctx, finish := activator.WithCleanupBudget(ctx, 30*time.Second) defer finish() stop := context.AfterFunc(ctx, func() { conn.Close() }) defer stop() conn.SetReadDeadline(time.Now().Add(5 * time.Second)) if err := auth(ctx, conn); err != nil { return } reader := bufio.NewReaderSize(conn, 1024) line, err := reader.ReadSlice('\n') if err != nil || len(line) > 1024 { return } var q filesSeedRequest decoder := json.NewDecoder(bytes.NewReader(line)) decoder.DisallowUnknownFields() if err := decoder.Decode(&q); err != nil { return } // Canonical encoding also rejects duplicate fields and trailing JSON. canonical, _ := json.Marshal(q) var compact bytes.Buffer if json.Compact(&compact, line) != nil || !bytes.Equal(compact.Bytes(), canonical) { return } if q.Verb != "seed" { return } if q.Host != "" { if _, err := hostclaim.Key(q.Host); err != nil { return } } if err := auth(ctx, conn); err != nil { return } conn.SetReadDeadline(time.Time{}) monitor := make(chan struct{}) go func() { defer close(monitor); _, _ = reader.ReadByte(); cancel() }() stream := filesSeedStream{conn: conn, encoder: json.NewEncoder(conn)} result := run(ctx, q.Host, stream) code := filesSeedExitCode(result) if result != nil { _, _ = stream.Write([]byte(result.Error() + "\n")) } conn.SetWriteDeadline(time.Now().Add(time.Second)) _ = stream.encoder.Encode(filesSeedReply{Code: &code}) conn.Close() <-monitor } func filesSeedClient(ctx context.Context, host string, out io.Writer, uid int) error { var st unix.Stat_t if err := unix.Lstat(filesSeedSocket, &st); err != nil { return errors.Join(errFilesSeedDelegation, err) } if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Mode&0777 != 0600 || st.Uid != uint32(uid) { return ErrClaimStopIdentity } conn, err := (&net.Dialer{}).DialContext(ctx, "unix", filesSeedSocket) if err != nil { return errors.Join(errFilesSeedDelegation, err) } defer conn.Close() stop := context.AfterFunc(ctx, func() { conn.Close() }) defer stop() if err := authenticateFilesSeedPeer(ctx, conn.(*net.UnixConn), uid, true); err != nil { return err } var after unix.Stat_t if err := unix.Lstat(filesSeedSocket, &after); err != nil || st.Dev != after.Dev || st.Ino != after.Ino { return ErrClaimStopIdentity } if err := json.NewEncoder(conn).Encode(filesSeedRequest{Verb: "seed", Host: host}); err != nil { return err } reader := bufio.NewReaderSize(conn, 64*1024) for { line, err := reader.ReadSlice('\n') if err != nil { return err } var reply filesSeedReply if err := json.Unmarshal(line, &reply); err != nil { return err } if reply.Text != "" { if _, err := io.WriteString(out, reply.Text); err != nil { return err } } if reply.Code != nil { switch *reply.Code { case 0: return nil case 2: return filesSeedIncompleteError{hostbox.ErrChanged} default: return hostbox.ErrChanged } } } } func startAgentFilesSeed(ctx context.Context, ready <-chan struct{}, svc *activator.Service) (*filesSeedServer, error) { entry, _ := ctx.Value(claimEntryKey{}).(claimEntry) resources := entry.Resources source, ok := productionClaimFilesSource().(*movesFilesSource) if !ok || entry.Assembly == nil || svc == nil || resources.Manager == nil { return nil, hostbox.ErrUnsupported } if err := NewClaimRoleRootClient(controlsock.Path).KindCapability(ctx, "files-seed"); err != nil { return nil, err } executor := &filesSeedExecutor{assembly: entry.Assembly, service: svc, registry: resources.Registry, source: source} server, err := startFilesSeedSocket(ctx, ready, filesSeedSocket, func(ctx context.Context, c *net.UnixConn) error { return authenticateFilesSeedPeer(ctx, c, os.Geteuid(), false) }, executor.run) if server != nil { server.finalize = executor.close } return server, err } // Recovery removes only an unchanged, service-owned socket which the kernel // refused to connect to. A live or uncertain listener always wins. func removeStaleFilesSeedSocket(ctx context.Context, path string) error { var before unix.Stat_t err := unix.Lstat(path, &before) if errors.Is(err, unix.ENOENT) { return nil } if err != nil { return err } if before.Mode&unix.S_IFMT != unix.S_IFSOCK || before.Mode&0777 != 0600 || int(before.Uid) != os.Geteuid() { return ErrClaimStopIdentity } conn, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, "unix", path) if err == nil { conn.Close() return hostbox.ErrChanged } if !errors.Is(err, unix.ECONNREFUSED) { return err } var after unix.Stat_t if err := unix.Lstat(path, &after); err != nil { return err } if before.Dev != after.Dev || before.Ino != after.Ino || before.Ctim != after.Ctim { return ErrClaimStopIdentity } return os.Remove(path) }