Skip to content

Commit b680ce0

Browse files
committed
feat(flipctl): implement robust flipd daemon with secure AF_UNIX IPC and systemd sandboxing
1 parent 97e1246 commit b680ce0

23 files changed

Lines changed: 3100 additions & 0 deletions

Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ ENV TZ=UTC
66
ENV IMG_OUT=/artifacts/images
77
ENV UBOOT_OUT=/artifacts/u-boot
88
ENV LINUX_OUT=/artifacts/linux
9+
ENV BIN_OUT=/artifacts/bin
910

1011
# Add arm64 architecture for cross-compilation
1112
RUN dpkg --add-architecture arm64 && apt-get update
@@ -68,5 +69,19 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/* ~/.cargo ~/go
6869
WORKDIR /flipperone-linux-build-scripts
6970
RUN git clone --depth=1 https://github.com/flipperdevices/flipperone-linux-build-scripts .
7071

72+
# Build the flipctl + flipd Go binaries for the Flipper One target
73+
# (arm64, statically linked, stripped). Cached as a separate layer:
74+
# any change to tools/flipctl/*.go invalidates only this step.
75+
# The host sees the resulting binaries at out/bin/flipctl and out/bin/flipd
76+
# because the run command mounts $(pwd)/out to /artifacts.
77+
RUN mkdir -p /artifacts/bin && \
78+
cd tools/flipctl && \
79+
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
80+
go build -trimpath -ldflags="-s -w" \
81+
-o /artifacts/bin/flipctl ./cmd/flipctl && \
82+
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
83+
go build -trimpath -ldflags="-s -w" \
84+
-o /artifacts/bin/flipd ./cmd/flipd
85+
7186
# Entry point
7287
ENTRYPOINT ./build-uboot.sh && ./build-kernel-mainline.sh && ./build-kernel-bsp.sh && ./build-images.sh

debian-rk3576-img.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{{ $sectorsize := or .sectorsize 512 }}
22
{{ $imagesize := or .imagesize "4GiB" }}
33
{{ $kerneldir := or .kerneldir "prebuilt/linux" }}
4+
{{ $bindir := or .bindir "out/bin" }}
45
{{ $cmdline := or .cmdline "audit=0 console=tty1 console=ttyS0,1500000n8" }}
56

67
architecture: arm64
@@ -43,3 +44,23 @@ actions:
4344
description: Install Linux kernel packages inside the target
4445
chroot: true
4546
command: find /var/cache/linux-kernel -name linux-image-\*.deb -a ! -name \*-dbg_\* -print0 | xargs -0 apt install -y
47+
48+
- action: overlay
49+
description: Install flipctl + flipd Go binaries into /usr/local/bin
50+
source: {{ $bindir }}
51+
origin: artifacts
52+
destination: /usr/local/bin
53+
54+
- action: run
55+
description: Ensure flipctl + flipd binaries are executable on the target
56+
chroot: false
57+
command: install -m 0755 /usr/local/bin/flipctl /usr/local/bin/flipctl && install -m 0755 /usr/local/bin/flipd /usr/local/bin/flipd
58+
59+
- action: run
60+
description: Enable the flipd systemd unit for boot-time startup
61+
# IMPORTANT: this runs WITHOUT chroot because debos's chroot has no
62+
# running PID 1 — systemctl's D-Bus talker would fail. The --root=/
63+
# flag tells systemctl to operate on the just-mounted target rootfs
64+
# directly without talking to any running daemon.
65+
chroot: false
66+
command: systemctl --root=/ enable flipd.service
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
[Unit]
2+
Description=Flipper One Type-C / UCSI state daemon
3+
Documentation=man:flipd(8)
4+
After=systemd-udevd.service network-pre.target
5+
Wants=systemd-udevd.service
6+
7+
[Service]
8+
Type=simple
9+
# Tunable poll interval. flipd logs role transitions *only*, so the
10+
# default 500ms is safe for systemd-journald's ~10 lines/s rate-limit.
11+
# Operators can override via `systemctl edit flipd.service`.
12+
Environment=POLL_INTERVAL=500ms
13+
Environment=POLL_INTERVAL=500ms
14+
Environment=SOCKET_PATH=/run/flipd/daemon.sock
15+
ExecStart=/usr/local/bin/flipd -poll=${POLL_INTERVAL} -socket=${SOCKET_PATH}
16+
Restart=on-failure
17+
RestartSec=5s
18+
19+
# RuntimeDirectory=flipd creates /run/flipd/ owned User=flipctl
20+
# Group=flipctl at unit start and prunes it on stop. flipd's
21+
# daemon.sock lands in that directory with mode 0660; the
22+
# group ownership is the first auth barrier (kernel connect()
23+
# refuses EACCES for non-members). systemd's directory creation
24+
# BEFORE ExecStart also means a stale socket file from a crashed
25+
# flipd cannot block a fresh start.
26+
RuntimeDirectory=flipd
27+
RuntimeDirectoryMode=0750
28+
29+
# Run as the dedicated flipctl user created in debian-rk3576-ospack.yaml.
30+
# The user only needs read access to /sys/class/typec/* which is
31+
# world-readable (mode 0444); IPC write methods (role-swap) are
32+
# authorised by SO_PEERCRED against the `flipctl` supplementary group.
33+
User=flipctl
34+
Group=flipctl
35+
36+
StandardOutput=journal
37+
StandardError=journal
38+
SyslogIdentifier=flipd
39+
40+
# Sandboxing per systemd.exec(5). flipd currently does NOT do any
41+
# networking — it is a pure sysfs poller — so the address-family
42+
# whitelist is AF_UNIX (forthcoming flipctl<->flipd IPC) and AF_NETLINK
43+
# (uevent subscriptions we may want later). Lock that down now while
44+
# the surface is small; widening later is easier than narrowing.
45+
NoNewPrivileges=true
46+
ProtectSystem=strict
47+
ProtectHome=true
48+
PrivateTmp=true
49+
ProtectKernelTunables=true
50+
ProtectKernelModules=true
51+
ProtectControlGroups=true
52+
RestrictAddressFamilies=AF_UNIX AF_NETLINK
53+
RestrictNamespaces=true
54+
RestrictRealtime=true
55+
LockPersonality=true
56+
MemoryDenyWriteExecute=true
57+
58+
[Install]
59+
WantedBy=multi-user.target

tools/flipctl/cmd/flipctl/main.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Command flipctl is the user-facing CLI for inspecting and
2+
// controlling the Flipper One's USB Type-C / UCSI subsystem.
3+
//
4+
// flipctl is intentionally thin: simple reads (status) parse sysfs
5+
// directly via pkg/ucsi; write commands (role-swap) talk to the
6+
// flipd daemon over a Unix Domain Socket via pkg/ipc. It is NOT
7+
// long-running; the daemon equivalent is the `flipd` binary.
8+
package main
9+
10+
import (
11+
"context"
12+
"fmt"
13+
"io"
14+
"log"
15+
"os"
16+
"os/signal"
17+
"syscall"
18+
"time"
19+
20+
"github.com/spf13/cobra"
21+
22+
"flipd/pkg/ipc"
23+
)
24+
25+
func main() {
26+
if err := rootCmd.Execute(); err != nil {
27+
log.Fatalf("flipctl: %v", err)
28+
}
29+
}
30+
31+
// rootCmd is the top-level cobra command. Subcommands register
32+
// themselves via init() in their own files; nothing else lives here.
33+
var rootCmd = &cobra.Command{
34+
Use: "flipctl",
35+
Short: "Control the Flipper One Type-C / UCSI subsystem",
36+
Long: `flipctl is the user-facing CLI for inspecting and controlling
37+
the Flipper One's USB Type-C port state.
38+
39+
It reads sysfs values from /sys/class/typec via the ucsi package and
40+
forwards role-swap commands to the flipd daemon over a Unix Domain
41+
Socket. flipctl is stateless and exits after every invocation; flipd
42+
is the daemon equivalent that watches role transitions.`,
43+
SilenceUsage: true,
44+
SilenceErrors: true,
45+
}
46+
47+
func init() {
48+
rootCmd.PersistentFlags().String(
49+
"socket", ipc.DefaultSocketPath,
50+
"path of the flipd Unix Domain Socket (used by role-swap)")
51+
rootCmd.PersistentFlags().Duration(
52+
"timeout", 5*time.Second,
53+
"per-call deadline for IPC round-trips")
54+
rootCmd.AddCommand(statusCmd)
55+
rootCmd.AddCommand(roleSwapCmd)
56+
rootCmd.AddCommand(pingCmd)
57+
}
58+
59+
// signalContext returns a context cancelled on SIGINT/SIGTERM, scoped
60+
// to the supplied parent. We don't bind it in cobra's PersistentPreRun
61+
// because not every command needs it; the leaf commands that do
62+
// request it on entry.
63+
func signalContext(parent context.Context) (context.Context, context.CancelFunc) {
64+
return signal.NotifyContext(parent,
65+
os.Interrupt, syscall.SIGTERM)
66+
}
67+
68+
// writeOut returns the cobra command's stdout — bound to a local
69+
// variable so helpers take io.Writer and stay unit-testable.
70+
func writeOut(cmd *cobra.Command) io.Writer { return cmd.OutOrStdout() }
71+
72+
// errOut returns stderr for flushable error reporting.
73+
func errOut() io.Writer { return os.Stderr }
74+
75+
// briefError prints a one-line stderr message suitable for shell
76+
// scripts (no fancy ANSI, no help text).
77+
func briefError(format string, a ...any) {
78+
fmt.Fprintf(errOut(), "flipctl: "+format+"\n", a...)
79+
}

tools/flipctl/cmd/flipctl/ping.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package main
2+
3+
// ping.go — flipctl ping
4+
//
5+
// Sends a "ping" Request to the flipd daemon; useful to verify
6+
// that the daemon is alive and the auth path is working without
7+
// touching sysfs.
8+
9+
import (
10+
"context"
11+
"encoding/json"
12+
"fmt"
13+
14+
"github.com/spf13/cobra"
15+
16+
"flipd/pkg/ipc"
17+
)
18+
19+
var pingCmd = &cobra.Command{
20+
Use: "ping",
21+
Short: "Health-check the flipd daemon over its Unix Domain Socket",
22+
RunE: runPing,
23+
}
24+
25+
func runPing(cmd *cobra.Command, args []string) error {
26+
ctx, cancel := signalContext(cmd.Context())
27+
defer cancel()
28+
29+
socketPath, _ := cmd.Flags().GetString("socket")
30+
timeout, _ := cmd.Flags().GetDuration("timeout")
31+
32+
dctx, dcancel := context.WithTimeout(ctx, timeout)
33+
defer dcancel()
34+
35+
client, err := ipc.Dial(dctx, socketPath)
36+
if err != nil {
37+
return fmt.Errorf("connect: %w", err)
38+
}
39+
defer client.Close()
40+
41+
resp, err := client.Call(dctx, &ipc.Request{Method: "ping"})
42+
if err != nil {
43+
return fmt.Errorf("ping: %w", err)
44+
}
45+
var p struct {
46+
Pong string `json:"pong"`
47+
Protocol int `json:"protocol"`
48+
}
49+
if err := json.Unmarshal(resp.Result, &p); err != nil {
50+
return fmt.Errorf("decode ping result: %w", err)
51+
}
52+
fmt.Fprintf(cmd.OutOrStdout(),
53+
"pong=%s protocol=%d\n", p.Pong, p.Protocol)
54+
return nil
55+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package main
2+
3+
// roleswap.go — flipctl role-swap <port> [--to host|device | --flip]
4+
//
5+
// Sends a "role-swap" Request to the flipd daemon over a Unix
6+
// Domain Socket and prints the prev → next transition on success.
7+
// The CLI never writes to /sys/class/typec directly — all sysfs
8+
// mutations go through flipd so the audit trail (daemon log) and
9+
// authorisation (SO_PEERCRED against the flipctl group) live in one
10+
// place.
11+
12+
import (
13+
"context"
14+
"encoding/json"
15+
"errors"
16+
"fmt"
17+
18+
"github.com/spf13/cobra"
19+
20+
"flipd/pkg/ipc"
21+
)
22+
23+
var roleSwapCmd = &cobra.Command{
24+
Use: "role-swap <port>",
25+
Short: "Swap (or set) the data role of a Type-C port via the flipd daemon",
26+
Long: `Send a role-swap request to the flipd daemon.
27+
28+
Examples:
29+
flipctl role-swap port0 --flip # host → device, or device → host
30+
flipctl role-swap port0 --to host # force into host mode
31+
flipctl role-swap port0 --to device # force into device mode
32+
33+
Exactly one of --flip and --to is required. The daemon validates
34+
the request against the kernel's current state and returns the
35+
previous and new role.`,
36+
Args: cobra.ExactArgs(1),
37+
RunE: runRoleSwap,
38+
}
39+
40+
var roleSwapFlags struct {
41+
To string
42+
Flip bool
43+
}
44+
45+
func init() {
46+
roleSwapCmd.Flags().StringVar(&roleSwapFlags.To, "to", "",
47+
"set data_role to this value (host or device)")
48+
roleSwapCmd.Flags().BoolVar(&roleSwapFlags.Flip, "flip", false,
49+
"swap to the opposite of the current role")
50+
roleSwapCmd.MarkFlagsOneRequired("to", "flip")
51+
roleSwapCmd.MarkFlagsMutuallyExclusive("to", "flip")
52+
}
53+
54+
func runRoleSwap(cmd *cobra.Command, args []string) error {
55+
port := args[0]
56+
if roleSwapFlags.To != "" && roleSwapFlags.To != "host" && roleSwapFlags.To != "device" {
57+
return fmt.Errorf("--to must be 'host' or 'device' (got %q)", roleSwapFlags.To)
58+
}
59+
60+
ctx, cancel := signalContext(cmd.Context())
61+
defer cancel()
62+
63+
socketPath, _ := cmd.Flags().GetString("socket")
64+
timeout, _ := cmd.Flags().GetDuration("timeout")
65+
66+
dctx, dcancel := context.WithTimeout(ctx, timeout)
67+
defer dcancel()
68+
69+
client, err := ipc.Dial(dctx, socketPath)
70+
if err != nil {
71+
// Surface "flipd is not running" before any other error so
72+
// the user understands their environment, not the daemon.
73+
if errors.Is(err, ipc.ErrNotRunning) {
74+
return fmt.Errorf("flipd daemon not reachable at %s "+
75+
"(is the systemd unit enabled?): %w", socketPath, err)
76+
}
77+
return fmt.Errorf("connect %s: %w", socketPath, err)
78+
}
79+
defer client.Close()
80+
81+
params := map[string]any{"port": port}
82+
if roleSwapFlags.Flip {
83+
params["flip"] = true
84+
} else {
85+
params["to"] = roleSwapFlags.To
86+
}
87+
88+
req := &ipc.Request{
89+
Method: "role-swap",
90+
Params: marshalOrDie(params),
91+
}
92+
resp, callErr := client.Call(dctx, req)
93+
if callErr != nil {
94+
return friendlyRoleSwapErr(callErr)
95+
}
96+
97+
var result struct {
98+
Port string `json:"port"`
99+
Prev string `json:"prev"`
100+
Next string `json:"next"`
101+
Method string `json:"method"`
102+
}
103+
if err := json.Unmarshal(resp.Result, &result); err != nil {
104+
return fmt.Errorf("decode role-swap result: %w (raw: %s)",
105+
err, string(resp.Result))
106+
}
107+
108+
fmt.Fprintf(cmd.OutOrStdout(),
109+
"port %s: data %s → %s (method=%s)\n",
110+
result.Port, result.Prev, result.Next, result.Method)
111+
return nil
112+
}
113+
114+
// friendlyRoleSwapErr maps typed sentinels to operator-facing
115+
// guidance. Untyped errors bubble through unchanged so genuine bugs
116+
// aren't masked.
117+
func friendlyRoleSwapErr(err error) error {
118+
switch {
119+
case errors.Is(err, ipc.ErrPortNotFound):
120+
return fmt.Errorf("port not found by flipd: %w", err)
121+
case errors.Is(err, ipc.ErrUnauthorized):
122+
return fmt.Errorf("flipd rejected the call "+
123+
"(are you in the 'flipctl' group?): %w", err)
124+
case errors.Is(err, ipc.ErrInvalidParams):
125+
return fmt.Errorf("invalid arguments sent to flipd: %w", err)
126+
case errors.Is(err, ipc.ErrUnsupportedRole):
127+
return fmt.Errorf("kernel returned an unrecognised role: %w", err)
128+
case errors.Is(err, ipc.ErrInternal):
129+
return fmt.Errorf("flipd internal error: %w", err)
130+
}
131+
return fmt.Errorf("role-swap: %w", err)
132+
}
133+
134+
// marshalOrDie marshals v and panics on failure. For our use
135+
// (plain map[string]any with primitive values) Marshal never
136+
// actually fails; panicking here is loud, fast, and unmistakable.
137+
func marshalOrDie(v any) json.RawMessage {
138+
b, err := json.Marshal(v)
139+
if err != nil {
140+
panic("flipctl: marshal request params: " + err.Error())
141+
}
142+
return b
143+
}

0 commit comments

Comments
 (0)