Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 47 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon?

# Xcode
build/
DerivedData/
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcuserstate
*.xcscmblueprint
*.xccheckout
*.moved-aside
*.hmap
*.ipa
*.dSYM.zip
*.dSYM

# XcodeGen-managed: project file is generated from project.yml on demand.
macapp/Sidecar.xcodeproj/
macapp/Sidecar.xcworkspace/

# Swift Package Manager
.swiftpm/
.build/
Packages/
Package.resolved

# Go
agent/bin/
agent/dist/
*.test
*.out
coverage.txt
vendor/

# Editors
.vscode/
.idea/
*.swp
*~

# Env
.env
.env.local
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Sidecar

一个面向 Tailscale 网络的轻量级设备监控 dashboard,原生 macOS 形态,桌面 widget 优先。

## 起源

我有好几台设备同时挂在 Tailscale 网络里 —— Win、Mac、Linux 都有,我希望能在主力 Mac 上有一个常驻的面板,持续看到这些设备的基础状态(在线/离线、CPU/内存/流量等),必要时可以展开看详情。

我没找到特别趁手的现成方案 —— 要么太重(Grafana 全家桶),要么只看在线状态没看系统指标,要么不是 macOS 原生形态。所以做这个:

- **形态贴合 macOS** — 桌面 widget 驻留,可选 menu bar,主 app 用于详情展开
- **科技美学** — 暗色为主、等宽数字、网格背景、细发光描边,多个 theme 可切换
- **多视图** — 设备网格概览 / 单设备详情卡片 / widget 时间线

## 架构总览

```
┌─────────────────────┐ tailnet (MagicDNS) ┌───────────────────────┐
│ sidecar-agent │ │ sidecar-mac │
│ (Win/Mac/Linux) │ ─── HTTP /info /metrics ────────► │ SwiftUI + WidgetKit │
│ │ │ │
│ - 采集 CPU/Mem │ │ - 设备网格 │
│ - 采集 Disk/Net │ │ - 详情卡片 │
│ - 暴露 :8765 │ │ - 桌面 widgets │
└─────────────────────┘ └───────────────────────┘
▲ │
│ │
└────────── 都通过 tailscaled 加入 tailnet ──────────────────┘
```

- agent 是一个 Go 写的单二进制,跨平台编译,依赖宿主机已经装好的 Tailscale 客户端(tailscaled),
只在 `tailscale0` 接口监听固定端口,不暴露公网。
- mac app 通过本地 `tailscale status --json` 列出 peer,按约定端口探测,发现 agent 后开始轮询。
- widget 通过 App Group 共享容器读 mac app 写下的最新快照。

## 仓库结构

```
sidecar-mac/
├── agent/ Go 写的跨平台采集 agent(单二进制)
├── macapp/ Xcode 工程:主 app + widget extension
├── docs/
│ ├── architecture.md 架构与设计细节
│ └── protocol.md agent <-> app 的 JSON 协议草案
└── README.md
```

## 当前状态

仓库刚起步,目前只有架构与协议文档。下一步按 milestone 推进。

## Roadmap

- **M0 — 文档与协议** *(当前)*
- [x] README / 架构 / 协议草案
- **M1 — agent 最小可用**
- [ ] Go 项目骨架 + gopsutil 采集
- [ ] HTTP server 绑 tailscale0 监听 :8765
- [ ] `/info`、`/metrics`、`/healthz` 三个端点
- [ ] Win / Mac / Linux 交叉编译
- **M2 — Mac 主 app 骨架**
- [ ] SwiftUI 工程 + 主窗口
- [ ] PeerDiscovery:解析 `tailscale status --json` + 端口探测
- [ ] Poller:定时抓 `/metrics`
- [ ] 设备网格视图(mock + 真实数据切换)
- **M3 — Widget extension**
- [ ] App Group 共享容器
- [ ] Small / Medium / Large 三档 widget
- [ ] TimelineProvider 节流(5–15 分钟)
- **M4 — 视觉与 theme**
- [ ] 主题系统(暗色 + 2–3 个 accent)
- [ ] 单设备详情卡(迷你火花线 / 流量图)
- **M5 — 打包与分发**
- [ ] agent installer / launchd / systemd / Windows service
- [ ] mac app 签名与公证

## 非目标

- 不做 alerting / 通知中心(先把"看"做好)
- 不做长周期历史存储(短窗口环形缓冲即可,长历史去用 Grafana)
- 不做 Tailscale 控制平面管理(不接管 ACL / device approval)
- 不做 Win / Linux 端的 dashboard(其它平台只跑 agent)

## 文档

- [架构与设计](docs/architecture.md)
- [agent 协议草案](docs/protocol.md)
77 changes: 77 additions & 0 deletions agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# sidecar-agent

Cross-platform metrics agent for Sidecar. Single Go binary; runs on every
device in the tailnet you want to monitor.

See [../docs/protocol.md](../docs/protocol.md) for the JSON contract and
[../docs/architecture.md](../docs/architecture.md) for design background.

## Build

Local build for the current platform:

```sh
go build ./cmd/sidecar-agent
```

Cross-compile all four targets:

```sh
for tuple in linux/amd64 darwin/arm64 darwin/amd64 windows/amd64; do
GOOS=${tuple%/*} GOARCH=${tuple#*/} CGO_ENABLED=0 \
go build -ldflags="-s -w -X main.Version=0.1.0" \
-o "bin/sidecar-agent-${tuple%/*}-${tuple#*/}" ./cmd/sidecar-agent
done
```

CGO is not required; binaries are statically linked.

## Run

By default the agent auto-detects the host's Tailscale interface (any
interface with an IP in `100.64.0.0/10` or `fd7a:115c:a1e0::/48`) and binds
**only** to those addresses on port 8765:

```sh
./sidecar-agent
```

For local development without Tailscale, override with `--bind`:

```sh
./sidecar-agent --bind 127.0.0.1 --port 18765
```

Flags:

| Flag | Default | Notes |
| -------------- | ------- | -------------------------------------------------------- |
| `--port` | `8765` | TCP port for each Tailscale address |
| `--bind` | *(auto)*| Comma-separated `host` or `host:port` overrides |
| `--sample` | `1s` | Metrics sampling interval |
| `--print-bind` | `false` | Print the addresses that would be bound and exit |

## Endpoints

| Path | Returns |
| ----------- | ------------------------------------------------ |
| `/healthz` | `200 ok` plain text |
| `/info` | Static host / agent / tailnet info (JSON) |
| `/metrics` | Latest snapshot (JSON, see protocol.md) |

All responses include `X-Sidecar-Agent: v0.1` (protocol version).

## Tailscale interface detection

The agent identifies the Tailscale interface by **IP range**, not by name —
so it works whether the OS named it `tailscale0` (Linux), `utun*` (older
macOS), or `Tailscale` (Windows). If your Tailscale node has an IP in the
CGNAT range, the agent will find it.

## Security

- The agent never binds to `0.0.0.0`. It refuses to start if the only
available addresses are non-Tailscale ones (use `--bind` explicitly to
opt out).
- All endpoints are read-only `GET`. There is no application-layer auth in
v0; tailnet ACLs are the trust boundary.
89 changes: 89 additions & 0 deletions agent/cmd/sidecar-agent/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"os/signal"
"strings"
"syscall"
"time"

"github.com/firenzemc/sidecar-mac/agent/internal/collector"
"github.com/firenzemc/sidecar-mac/agent/internal/netif"
"github.com/firenzemc/sidecar-mac/agent/internal/server"
)

// Version is the agent's semver string. Override with -ldflags="-X main.Version=...".
var Version = "0.1.0-dev"

func main() {
var (
port = flag.Int("port", 8765, "TCP port to bind on each Tailscale address")
bind = flag.String("bind", "", "comma-separated host:port overrides (skips Tailscale auto-detect)")
sample = flag.Duration("sample", time.Second, "metrics sampling interval")
printOnly = flag.Bool("print-bind", false, "print bind addresses and exit")
)
flag.Parse()

addrs, err := resolveBindAddrs(*bind, *port)
if err != nil {
log.Fatalf("bind: %v", err)
}

if *printOnly {
for _, a := range addrs {
fmt.Println(a)
}
return
}

ts, _ := netif.Detect() // best-effort, may be nil if --bind override is used

col := collector.New(*sample)

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

go col.Run(ctx)

srv := server.New(Version, col, ts)
log.Printf("sidecar-agent %s starting; bind=%v", Version, addrs)
if err := srv.ListenAndServe(ctx, addrs); err != nil {
log.Fatalf("server: %v", err)
}
log.Printf("sidecar-agent stopped")
}

// resolveBindAddrs returns the list of host:port to listen on. Either:
// - explicit --bind override: comma-separated list, each entry is "host" or "host:port".
// Bare hosts use the global --port.
// - auto-detected Tailscale addresses (CGNAT v4 and Tailscale ULA v6 on a single interface).
func resolveBindAddrs(override string, port int) ([]string, error) {
if override != "" {
var out []string
for _, raw := range strings.Split(override, ",") {
a := strings.TrimSpace(raw)
if a == "" {
continue
}
if !strings.Contains(a, ":") || (strings.HasPrefix(a, "[") && strings.HasSuffix(a, "]")) {
a = fmt.Sprintf("%s:%d", a, port)
}
out = append(out, a)
}
if len(out) == 0 {
return nil, fmt.Errorf("--bind was set but produced no addresses")
}
return out, nil
}

ts, err := netif.Detect()
if err != nil {
return nil, fmt.Errorf("auto-detect Tailscale interface: %w (use --bind to override)", err)
}
addrs := ts.BindAddrs(port)
log.Printf("detected Tailscale interface: %s", ts.Summary())
return addrs, nil
}
16 changes: 16 additions & 0 deletions agent/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/firenzemc/sidecar-mac/agent

go 1.24.7

require github.com/shirou/gopsutil/v3 v3.24.5

require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.20.0 // indirect
)
36 changes: 36 additions & 0 deletions agent/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading