diff --git a/.agents/skills/localshare-server-deploy/SKILL.md b/.agents/skills/localshare-server-deploy/SKILL.md new file mode 100644 index 0000000..5215000 --- /dev/null +++ b/.agents/skills/localshare-server-deploy/SKILL.md @@ -0,0 +1,107 @@ +--- +name: localshare-server-deploy +description: 在 Linux 主机上安装、配置、升级或排查自托管 LocalShare Server,并验证 systemd、健康检查和一次性配对 key。用于用户要求部署 LocalShare 远程分享 Server;不用于 macOS Client 或纯局域网分享。 +license: MIT +metadata: + author: rrbe + version: "1.0.0" +--- + +# LocalShare Server 部署 + +部署一个可持久运行的 `localshare-server`,验证公开地址可用,并为 LocalShare Client 生成一次性 Enrollment Key。Server 只转发请求与文件流,不保存 Mac 上的分享内容。 + +目标环境是使用 systemd 的 Linux,需具备 `curl`、sudo 或 root 权限,以及访问 GitHub Releases 的出站 HTTPS 网络。 + +## 确认部署边界 + +- 只操作用户明确指定的主机。用户说“当前服务器”时操作当前 Linux 主机;未指定远程目标时不要猜测 SSH 主机。 +- 开始写入前确定公开基地址、监听端口,以及使用 HTTP 还是 Server 直接终止 TLS。公开基地址不能带额外 path 或 query。 +- HTTPS 需要域名匹配的证书和私钥路径。不要自行申请证书、配置反向代理或修改 DNS,除非用户明确要求。 +- 修改防火墙、覆盖已有 systemd unit、替换已有二进制或调整已有 TLS 文件前,先展示检测结果并取得确认。 +- Enrollment Key、Device Token 和 Share Token 不得写入公开日志或代码仓库。 + +## 只读预检 + +1. 检查 `uname -s` 为 Linux,并确认 `uname -m`:`x86_64` 映射为 `amd64`,`aarch64` 或 `arm64` 映射为 `arm64`。其他架构停止并报告不支持。 +2. 确认 `systemctl`、`curl`、`sha256sum`、`sudo` 或 root 权限可用。 +3. 检查目标端口是否被占用,并读取现有的 `localshare-server.service`、`/usr/local/bin/localshare-server` 和 `/var/lib/localshare` 状态。 +4. 如果发现已有部署,保留 `/var/lib/localshare/state.json`;在用户确认升级或修复方案之前不要覆盖文件。 + +## 下载并校验 Release + +从 `https://github.com/rrbe/LocalShare/releases/latest` 解析最新 `vX.Y.Z` tag,去掉 `v` 后得到版本号。下载: + +```text +LocalShare-Server--linux- +LocalShare-Server--checksums.txt +``` + +在 `mktemp -d` 创建的临时目录中下载文件,用 checksums 文件和 `sha256sum -c` 校验目标二进制。只有校验通过后,才使用 `sudo install -m 0755` 安装到 `/usr/local/bin/localshare-server`。 + +如果最新 Release 没有对应 Server 资产,停止并说明情况;可以询问用户是否改为从源码构建,但不要静默下载未经发布的构建。 + +## 创建运行账户和状态目录 + +如不存在,创建系统用户: + +```bash +sudo useradd --system --home /var/lib/localshare --shell /usr/sbin/nologin localshare +sudo install -d -o localshare -g localshare -m 0700 /var/lib/localshare +``` + +状态目录必须持久化。不得删除、清空或重新生成已有 `state.json`。保持状态目录权限为 `0700`;Server 会把状态文件和锁文件权限收敛为 `0600`。 +Server 默认使用 `/var/lib/localshare`;只有用户明确选择其他目录时才追加 `--state-dir`,并同步调整 `ReadWritePaths`。 + +## 配置 systemd + +使用 `/etc/systemd/system/localshare-server.service`。基础 `ExecStart` 为: + +```text +/usr/local/bin/localshare-server serve --listen : --public-url +``` + +Server 直接终止 TLS 时追加 `--tls-cert --tls-key `,并确认 `localshare` 用户可以读取这两个文件。 + +Service 至少使用: + +```ini +[Unit] +Description=LocalShare Server +After=network-online.target +Wants=network-online.target + +[Service] +User=localshare +Group=localshare +ExecStart= +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/localshare + +[Install] +WantedBy=multi-user.target +``` + +写入后运行 `systemctl daemon-reload` 和 `systemctl enable --now localshare-server`。 + +## 验证并生成配对 key + +1. 确认 `systemctl is-active localshare-server` 返回 `active`。 +2. 请求 `/healthz`,必须返回 HTTP 200 和包含 `{"status":"ok"}` 的 JSON。 +3. 如果失败,查看 `journalctl -u localshare-server --no-pager -n 100`,修复真实原因后重新验证;不要在健康检查失败时生成 key 或宣布完成。 +4. 健康检查通过后运行: + + ```bash + sudo -u localshare /usr/local/bin/localshare-server key create \ + --name + ``` + +5. 把一次性 Enrollment Key 只返回给用户,并提醒在 macOS Client 的“设置 → 远程访问”中填写公开地址和 key。 + +## 交付结果 + +报告已安装版本和架构、公开地址、监听端口、TLS 模式、systemd 状态、健康检查结果、状态目录,以及 Enrollment Key。明确列出任何没有执行的防火墙、DNS、证书或外部连通性工作。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ce9b9a..381d9c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,19 @@ on: pull_request: jobs: + server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.23' + - name: 测试 LocalShare Server + working-directory: server + run: | + go test -race ./... + go vet ./... + smoke: runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc595dd..8060378 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,22 @@ jobs: } echo "VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV" + - uses: actions/setup-go@v7 + if: ${{ hashFiles('server/go.mod') != '' }} + with: + go-version-file: server/go.mod + + - name: 编译 LocalShare Server(Linux amd64 / arm64) + if: ${{ hashFiles('server/go.mod') != '' }} + working-directory: server + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w" -o "../LocalShare-Server-$VERSION-linux-amd64" . + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -trimpath -ldflags "-s -w" -o "../LocalShare-Server-$VERSION-linux-arm64" . + cd .. + shasum -a 256 LocalShare-Server-* > "LocalShare-Server-$VERSION-checksums.txt" + - name: 写入版本号到 Info.plist run: | /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" bundle/Info.plist @@ -94,6 +110,9 @@ jobs: CHANGELOG="$(git tag -l --format='%(contents)' "$RELEASE_TAG")" fi printf '**LocalShare %s** · 局域网文件 / 文件夹只读分享,扫码即看。\nUniversal(Apple Silicon + Intel),要求 macOS 13+。\n' "$VERSION" > release_body.md + if [ -f "LocalShare-Server-$VERSION-linux-amd64" ]; then + printf '\n远程分享 Server 同时提供 Linux amd64 / arm64 单文件二进制及 SHA-256 校验文件。\n' >> release_body.md + fi if [ -n "$CHANGELOG" ]; then printf '\n## 更新内容\n\n%s\n' "$CHANGELOG" >> release_body.md fi @@ -115,7 +134,10 @@ jobs: with: tag_name: ${{ env.RELEASE_TAG }} name: LocalShare ${{ env.VERSION }} - files: LocalShare-${{ env.VERSION }}.dmg + files: | + LocalShare-${{ env.VERSION }}.dmg + LocalShare-Server-${{ env.VERSION }}-linux-* + LocalShare-Server-${{ env.VERSION }}-checksums.txt body_path: release_body.md - name: EdDSA 签名 DMG + 生成 appcast.xml 挂到 Release diff --git a/.gitignore b/.gitignore index 55c1238..13534f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .build/ .swiftpm/ +/server/localshare-server dist/ *.app .DS_Store diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..6caa732 --- /dev/null +++ b/server/README.md @@ -0,0 +1,193 @@ +# LocalShare Server + +LocalShare Server 是远程分享的控制面和数据中继。它只转发请求与文件流,不保存 Mac 上的分享内容。Server 使用一个端口,要求 Go 1.23+。 + +## 一、下载或编译 + +GitHub Release 提供 Linux amd64 / arm64 单文件二进制和 SHA-256 校验文件。下载与服务器架构匹配的 +`LocalShare-Server--linux-` 后安装: + +```bash +chmod +x LocalShare-Server-*-linux-* +sudo install -m 0755 LocalShare-Server-*-linux-* /usr/local/bin/localshare-server +``` + +也可以在仓库根目录从源码编译: + +```bash +cd server +go test ./... +go test -race ./... +go build -trimpath -ldflags "-s -w" -o localshare-server . +``` + +如果部署到 Linux,可以在 macOS 上交叉编译: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w" -o localshare-server . +``` + +把生成的 `localshare-server` 复制到目标服务器即可,不需要安装 SSH/frp/Nginx 或其他 LocalShare 运行时组件。 + +## 二、本机快速测试 + +打开两个终端。 + +终端 A:生成测试目录和一次性配对 key: + +```bash +cd server +state_dir=$(mktemp -d) +./localshare-server key create --name test-mac --state-dir "$state_dir" +``` + +复制命令输出的 `key`。它只允许成功配对一次。 + +终端 A 继续启动 Server: + +```bash +./localshare-server serve \ + --listen 127.0.0.1:18080 \ + --public-url http://127.0.0.1:18080 \ + --state-dir "$state_dir" +``` + +终端 B 检查 Server: + +```bash +curl http://127.0.0.1:18080/healthz +``` + +预期返回包含: + +```json +{"status":"ok"} +``` + +这里的 `127.0.0.1` 只适合 Server 和 LocalShare Client 在同一台 Mac 上测试。若 Client 在另一台 Mac,Server 必须监听可访问地址,例如 `--listen :18080`,并使用服务器局域网地址作为 Client 的 Server address。 + +## 三、配置 LocalShare Client + +1. 编译并启动 macOS Client: + + ```bash + cd .. + swift build + .build/debug/LocalShare + ``` + + 或使用 `./build.sh` 生成,再执行 `open dist/LocalShare.app`。 + +2. 在 Client 中选择一个文件或文件夹,让本地分享先运行。 +3. 打开「设置 → 远程访问」: + + - `Server address`:填写 `http://127.0.0.1:18080`,或远程服务器的可访问地址; + - `Enrollment key`:填写终端 A 生成的 `key`。 + +4. 点击保存,回到分享票据,点击 `Connect`。 +5. 配对成功后,Client 会把 Device Token 保存到 macOS Keychain;Enrollment Key 不会持久化。 +6. 连接成功后,分享票据上会显示 Server 生成的远程访问地址。用浏览器打开这个地址即可测试文件浏览和下载。 + +之后再次使用只需要点击 `Connect`,不需要重新填写 key。`Disconnect` 只断开当前长连接,`Forget Device` 才会删除 Keychain 中的 Device Token。 + +Client 重启后不会自动公开分享;选择文件并点击 `Connect` 即可使用已经配对的设备凭据重新连接。 + +## 四、Linux 服务器部署 + +生产部署只需要持久化二进制、状态目录和一个监听端口: + +```bash +sudo install -m 0755 localshare-server /usr/local/bin/localshare-server +sudo useradd --system --home /var/lib/localshare --shell /usr/sbin/nologin localshare +sudo install -d -o localshare -g localshare -m 0700 /var/lib/localshare +``` + +Server 默认使用 `/var/lib/localshare`;只有使用其他目录时才需要传 `--state-dir`。 + +先生成配对 key: + +```bash +sudo -u localshare /usr/local/bin/localshare-server key create \ + --name macbook +``` + +然后启动: + +```bash +sudo -u localshare /usr/local/bin/localshare-server serve \ + --listen :18080 \ + --public-url http://server.example:18080 +``` + +`--public-url` 必须是浏览器和 Client 使用的 Server 基地址,不能带额外路径或 query。`state-dir` 必须持久化;删除其中的 `state.json` 会使已配对设备全部失效。 + +如果使用 HTTPS,Server 可以直接终止 TLS: + +```bash +sudo -u localshare /usr/local/bin/localshare-server serve \ + --listen :18443 \ + --public-url https://ls.example.com \ + --tls-cert /etc/localshare/server.crt \ + --tls-key /etc/localshare/server.key +``` + +`https` 的 `--public-url` 必须同时提供 `--tls-cert` 和 `--tls-key`;证书的域名必须匹配 Server address。HTTP 配置不应传入 TLS 参数。 + +如使用 systemd,可创建 `/etc/systemd/system/localshare-server.service`: + +```ini +[Unit] +Description=LocalShare Server +After=network-online.target +Wants=network-online.target + +[Service] +User=localshare +Group=localshare +ExecStart=/usr/local/bin/localshare-server serve --listen :18080 --public-url http://server.example:18080 +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/localshare + +[Install] +WantedBy=multi-user.target +``` + +启动并检查: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now localshare-server +curl http://server.example:18080/healthz +sudo journalctl -u localshare-server -f +``` + +防火墙只需要允许 Server 的这个端口。Client 不需要把 Mac 的 FileServer 端口暴露给外部;Client 会主动向 Server 建立 WebSocket 长连接。 + +## 五、管理命令 + +```bash +sudo -u localshare /usr/local/bin/localshare-server key list +sudo -u localshare /usr/local/bin/localshare-server key revoke +sudo -u localshare /usr/local/bin/localshare-server device list +sudo -u localshare /usr/local/bin/localshare-server device revoke +``` + +Server 只在 `state.json` 中保存 Enrollment Key 和 Device Token 的 SHA-256,不保存明文 token。Enrollment Key、Device Token 和 Share Token 都不应写入公开日志或提交到代码仓库。 +运行中的 Server 与管理 CLI 通过状态目录里的 `.state.lock` 串行化修改,避免创建 Key、配对和撤销并发时丢更新;目录权限会收敛为 `0700`,`state.json` 与锁文件为 `0600`。 + +## 六、排查顺序 + +1. `curl /healthz` 是否返回 200。 +2. Server address 是否为基地址,例如 `http://server.example:18080`,没有额外 path/query。 +3. Enrollment Key 是否已经被使用;已使用时重新运行 `key create`。 +4. `device list` 是否能看到配对设备。 +5. Client 是否已经选择文件并点击 `Connect`;空分享时 Connect 会被禁用。 +6. Client 状态是否为 `Connected`,并且分享票据是否出现远程 URL。 +7. 远程 URL 是否仍然对应当前连接;Disconnect、换分享内容或 Server 重启后旧 Share Token 都会失效。 + +完整架构、消息协议和取舍见 [`docs/REMOTE_SHARING_PLAN.md`](../docs/REMOTE_SHARING_PLAN.md)。 diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..e66be03 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,5 @@ +module github.com/rrbe/LocalShare/server + +go 1.23 + +require github.com/coder/websocket v1.8.15 diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..4e0a48d --- /dev/null +++ b/server/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..6f3f150 --- /dev/null +++ b/server/main.go @@ -0,0 +1,863 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/coder/websocket" +) + +const protocolVersion = 1 +const maxWebSocketMessageSize = 8 << 20 +const defaultRelayIdleTimeout = 60 * time.Second +const webSocketWriteTimeout = 10 * time.Second +const defaultStateDir = "/var/lib/localshare" + +type credential struct { + ID string `json:"id"` + Name string `json:"name"` + Hash string `json:"hash"` + Used bool `json:"used"` + CreatedAt time.Time `json:"created_at"` +} + +type device struct { + ID string `json:"id"` + Name string `json:"name"` + TokenHash string `json:"token_hash"` + Revoked bool `json:"revoked"` + CreatedAt time.Time `json:"created_at"` +} + +type state struct { + EnrollmentKeys []credential `json:"enrollment_keys"` + Devices []device `json:"devices"` +} + +type stateStore struct { + path string + lockPath string + mu sync.Mutex +} + +func newStateStore(dir string) (*stateStore, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + if err := os.Chmod(dir, 0o700); err != nil { + return nil, err + } + return &stateStore{ + path: filepath.Join(dir, "state.json"), + lockPath: filepath.Join(dir, ".state.lock"), + }, nil +} + +func (s *stateStore) load() (state, error) { + var current state + err := s.withFileLock(false, func() error { + var err error + current, err = s.loadUnlocked() + return err + }) + return current, err +} + +func (s *stateStore) loadUnlocked() (state, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return state{}, nil + } + if err != nil { + return state{}, err + } + var current state + if err := json.Unmarshal(data, ¤t); err != nil { + return state{}, fmt.Errorf("read state: %w", err) + } + return current, nil +} + +func (s *stateStore) saveUnlocked(current state) error { + data, err := json.MarshalIndent(current, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return err + } + if _, err := file.Write(data); 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(tmp, s.path) +} + +func (s *stateStore) withFileLock(exclusive bool, work func() error) error { + s.mu.Lock() + defer s.mu.Unlock() + lockFile, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lockFile.Close() + if err := lockFile.Chmod(0o600); err != nil { + return err + } + mode := syscall.LOCK_SH + if exclusive { + mode = syscall.LOCK_EX + } + if err := syscall.Flock(int(lockFile.Fd()), mode); err != nil { + return err + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + return work() +} + +func (s *stateStore) update(work func(*state) error) error { + return s.withFileLock(true, func() error { + current, err := s.loadUnlocked() + if err != nil { + return err + } + if err := work(¤t); err != nil { + return err + } + return s.saveUnlocked(current) + }) +} + +func randomToken(prefix string) (string, error) { + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(data), nil +} + +func tokenHash(value string) string { + hash := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func (s *stateStore) createEnrollmentKey(name string) (string, string, error) { + raw, err := randomToken("ls_enroll_") + if err != nil { + return "", "", err + } + id, err := randomToken("key_") + if err != nil { + return "", "", err + } + if err := s.update(func(current *state) error { + current.EnrollmentKeys = append(current.EnrollmentKeys, credential{ + ID: id, Name: name, Hash: tokenHash(raw), CreatedAt: time.Now().UTC(), + }) + return nil + }); err != nil { + return "", "", err + } + return id, raw, nil +} + +func (s *stateStore) enroll(raw, name string) (string, string, error) { + deviceID, err := randomToken("device_") + if err != nil { + return "", "", err + } + deviceToken, err := randomToken("ls_device_") + if err != nil { + return "", "", err + } + hash := tokenHash(raw) + if err := s.update(func(current *state) error { + keyIndex := -1 + for i := range current.EnrollmentKeys { + if subtle.ConstantTimeCompare([]byte(current.EnrollmentKeys[i].Hash), []byte(hash)) == 1 && !current.EnrollmentKeys[i].Used { + keyIndex = i + break + } + } + if keyIndex < 0 { + return errors.New("invalid or already used enrollment key") + } + current.EnrollmentKeys[keyIndex].Used = true + current.Devices = append(current.Devices, device{ + ID: deviceID, Name: name, TokenHash: tokenHash(deviceToken), CreatedAt: time.Now().UTC(), + }) + return nil + }); err != nil { + return "", "", err + } + return deviceID, deviceToken, nil +} + +func (s *stateStore) deviceForToken(raw string) (device, bool, error) { + current, err := s.load() + if err != nil { + return device{}, false, err + } + hash := tokenHash(raw) + for _, item := range current.Devices { + if subtle.ConstantTimeCompare([]byte(item.TokenHash), []byte(hash)) == 1 { + return item, !item.Revoked, nil + } + } + return device{}, false, nil +} + +func (s *stateStore) revokeDevice(id string) error { + return s.update(func(current *state) error { + for i := range current.Devices { + if current.Devices[i].ID == id { + current.Devices[i].Revoked = true + return nil + } + } + return errors.New("device not found") + }) +} + +type wireMessage struct { + Type string `json:"type"` + Protocol int `json:"protocol,omitempty"` + RequestID string `json:"request_id,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Status int `json:"status,omitempty"` + Error string `json:"error,omitempty"` + ShareURL string `json:"share_url,omitempty"` + ShareID string `json:"share_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + Enrollment string `json:"enrollment_key,omitempty"` +} + +type relayMessage struct { + kind string + status int + headers map[string]string + data []byte + err error +} + +type pendingRequest struct { + ch chan relayMessage + done chan struct{} +} + +type agent struct { + hub *hub + device device + conn *websocket.Conn + sendMu sync.Mutex + pendingMu sync.Mutex + pending map[string]*pendingRequest +} + +type share struct { + token string + shareID string + deviceID string + agent *agent +} + +type hub struct { + store *stateStore + publicURL string + mu sync.Mutex + agents map[string]*agent + shares map[string]*share + requestIdleTimeout time.Duration +} + +func newHub(store *stateStore, publicURL string) *hub { + return &hub{ + store: store, publicURL: strings.TrimRight(publicURL, "/"), agents: map[string]*agent{}, + shares: map[string]*share{}, requestIdleTimeout: defaultRelayIdleTimeout, + } +} + +func normalizePublicURL(value string) (string, error) { + u, err := url.ParseRequestURI(strings.TrimSpace(value)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || + u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("--public-url must be an http(s) base URL without path or query") + } + return strings.TrimRight(u.String(), "/"), nil +} + +func (h *hub) enrollHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var request wireMessage + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&request); err != nil || request.Enrollment == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid enrollment request"}) + return + } + deviceID, deviceToken, err := h.store.enroll(request.Enrollment, request.DeviceName) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"device_id": deviceID, "device_token": deviceToken}) +} + +func (h *hub) agentHandler(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r.Header.Get("Authorization")) + device, valid, err := h.store.deviceForToken(token) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "state unavailable"}) + return + } + if !valid { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid device token"}) + return + } + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + conn.SetReadLimit(maxWebSocketMessageSize) + a := &agent{hub: h, device: device, conn: conn, pending: map[string]*pendingRequest{}} + h.mu.Lock() + if old := h.agents[device.ID]; old != nil { + _ = old.close(websocket.StatusGoingAway, "replaced") + } + h.agents[device.ID] = a + h.mu.Unlock() + defer h.removeAgent(a) + _ = a.send(wireMessage{Type: "hello", Protocol: protocolVersion}) + a.readLoop(r.Context()) +} + +func (h *hub) removeAgent(a *agent) { + h.mu.Lock() + if h.agents[a.device.ID] == a { + delete(h.agents, a.device.ID) + } + for token, item := range h.shares { + if item.agent == a { + delete(h.shares, token) + } + } + h.mu.Unlock() + a.failPending() +} + +func (h *hub) startShare(a *agent, shareID string) (string, error) { + if shareID == "" { + return "", errors.New("share id is required") + } + token, err := randomToken("share_") + if err != nil { + return "", err + } + h.mu.Lock() + for oldToken, item := range h.shares { + if item.agent == a { + delete(h.shares, oldToken) + } + } + h.shares[token] = &share{token: token, shareID: shareID, deviceID: a.device.ID, agent: a} + h.mu.Unlock() + return h.publicURL + "/share/" + token + "/", nil +} + +func (h *hub) stopShare(a *agent) { + h.mu.Lock() + defer h.mu.Unlock() + for token, item := range h.shares { + if item.agent == a { + delete(h.shares, token) + } + } +} + +func (h *hub) shareHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + pathPart := strings.TrimPrefix(r.URL.EscapedPath(), "/share/") + parts := strings.SplitN(pathPart, "/", 2) + if len(parts) == 0 || parts[0] == "" { + http.NotFound(w, r) + return + } + token := parts[0] + if len(parts) == 1 { + target := r.URL.EscapedPath() + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusPermanentRedirect) + return + } + localPath := "/" + if len(parts) == 2 { + localPath += parts[1] + if !strings.HasPrefix(localPath, "/") { + localPath = "/" + localPath + } + } + query := r.URL.Query() + query.Del("t") + if encodedQuery := query.Encode(); encodedQuery != "" { + localPath += "?" + encodedQuery + } + h.mu.Lock() + item := h.shares[token] + h.mu.Unlock() + if item == nil { + http.NotFound(w, r) + return + } + requestID, err := randomToken("req_") + if err != nil { + http.Error(w, "request unavailable", http.StatusInternalServerError) + return + } + messages := item.agent.beginRequest(wireMessage{ + Type: "request.begin", RequestID: requestID, Method: r.Method, Path: localPath, + Headers: forwardedRequestHeaders(r), ShareID: item.shareID, + }) + defer item.agent.finishRequest(requestID) + idleTimer := time.NewTimer(h.requestIdleTimeout) + defer idleTimer.Stop() + resetIdleTimer := func() { + if !idleTimer.Stop() { + select { + case <-idleTimer.C: + default: + } + } + idleTimer.Reset(h.requestIdleTimeout) + } + started := false + for { + select { + case <-r.Context().Done(): + item.agent.cancelRequest(requestID) + return + case message, ok := <-messages.ch: + if !ok { + if !started { + http.Error(w, "agent unavailable", http.StatusBadGateway) + } + return + } + resetIdleTimer() + switch message.kind { + case "headers": + if started { + continue + } + started = true + copyResponseHeaders(w, message.headers, token) + if message.status == 0 { + message.status = http.StatusOK + } + w.WriteHeader(message.status) + case "data": + if !started { + http.Error(w, "invalid agent response", http.StatusBadGateway) + return + } + if _, err := w.Write(message.data); err != nil { + item.agent.cancelRequest(requestID) + return + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + case "error": + if !started { + http.Error(w, message.err.Error(), http.StatusBadGateway) + } + return + case "end": + if !started { + http.Error(w, "invalid agent response", http.StatusBadGateway) + } + return + } + case <-messages.done: + if !started { + http.Error(w, "agent unavailable", http.StatusBadGateway) + } + return + case <-idleTimer.C: + item.agent.cancelRequest(requestID) + if !started { + w.WriteHeader(http.StatusGatewayTimeout) + } + return + } + } +} + +func (a *agent) readLoop(ctx context.Context) { + for { + typ, payload, err := a.conn.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageBinary { + id, data, ok := decodeData(payload) + if ok { + a.deliver(id, relayMessage{kind: "data", data: data}) + } + continue + } + var message wireMessage + if json.Unmarshal(payload, &message) != nil { + continue + } + switch message.Type { + case "share.start": + shareURL, err := a.hub.startShare(a, message.ShareID) + if err != nil { + _ = a.send(wireMessage{Type: "error", Error: err.Error()}) + } else { + _ = a.send(wireMessage{Type: "share.ready", ShareURL: shareURL}) + } + case "share.stop": + a.hub.stopShare(a) + case "response.begin": + a.deliver(message.RequestID, relayMessage{kind: "headers", status: message.Status, headers: message.Headers}) + case "response.end": + a.deliver(message.RequestID, relayMessage{kind: "end"}) + case "error": + a.deliver(message.RequestID, relayMessage{kind: "error", err: errors.New(message.Error)}) + } + } +} + +func (a *agent) send(message wireMessage) error { + data, err := json.Marshal(message) + if err != nil { + return err + } + a.sendMu.Lock() + defer a.sendMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), webSocketWriteTimeout) + defer cancel() + return a.conn.Write(ctx, websocket.MessageText, data) +} + +func (a *agent) close(code websocket.StatusCode, reason string) error { + return a.conn.Close(code, reason) +} + +func (a *agent) beginRequest(request wireMessage) *pendingRequest { + pending := &pendingRequest{ch: make(chan relayMessage, 1), done: make(chan struct{})} + a.pendingMu.Lock() + a.pending[request.RequestID] = pending + a.pendingMu.Unlock() + if err := a.send(request); err != nil { + a.deliver(request.RequestID, relayMessage{kind: "error", err: err}) + } + return pending +} + +func (a *agent) finishRequest(requestID string) { + a.pendingMu.Lock() + if pending := a.pending[requestID]; pending != nil { + delete(a.pending, requestID) + close(pending.done) + } + a.pendingMu.Unlock() +} + +func (a *agent) cancelRequest(requestID string) { + _ = a.send(wireMessage{Type: "request.cancel", RequestID: requestID}) +} + +func (a *agent) deliver(requestID string, message relayMessage) { + a.pendingMu.Lock() + pending := a.pending[requestID] + a.pendingMu.Unlock() + if pending != nil { + select { + case pending.ch <- message: + case <-pending.done: + } + } +} + +func (a *agent) failPending() { + a.pendingMu.Lock() + defer a.pendingMu.Unlock() + for id, pending := range a.pending { + delete(a.pending, id) + close(pending.done) + } +} + +func forwardedRequestHeaders(r *http.Request) map[string]string { + allowed := []string{"Accept", "Accept-Language", "If-Modified-Since", "If-None-Match", "Range", "User-Agent"} + result := make(map[string]string) + for _, name := range allowed { + if value := r.Header.Get(name); value != "" { + result[name] = value + } + } + return result +} + +func copyResponseHeaders(w http.ResponseWriter, headers map[string]string, shareToken string) { + for name, value := range headers { + lower := strings.ToLower(name) + if lower == "set-cookie" || lower == "connection" || lower == "transfer-encoding" { + continue + } + if strings.EqualFold(name, "Location") { + value = rewriteLocation(value, shareToken) + } + w.Header().Set(name, value) + } +} + +func rewriteLocation(value, shareToken string) string { + if strings.HasPrefix(value, "/") { + return "/share/" + shareToken + value + } + return value +} + +func encodeData(requestID string, data []byte) []byte { + id := []byte(requestID) + frame := make([]byte, 4+len(id)+len(data)) + binary.BigEndian.PutUint32(frame[:4], uint32(len(id))) + copy(frame[4:], id) + copy(frame[4+len(id):], data) + return frame +} + +func decodeData(frame []byte) (string, []byte, bool) { + if len(frame) < 4 { + return "", nil, false + } + idLen := int(binary.BigEndian.Uint32(frame[:4])) + if idLen <= 0 || 4+idLen > len(frame) { + return "", nil, false + } + return string(frame[4 : 4+idLen]), frame[4+idLen:], true +} + +func bearerToken(value string) string { + parts := strings.Fields(value) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return parts[1] + } + return "" +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func runServe(args []string) error { + flags := flag.NewFlagSet("serve", flag.ContinueOnError) + listen := flags.String("listen", ":8080", "HTTP and WebSocket listen address") + publicURL := flags.String("public-url", "", "public base URL used in share links") + stateDir := flags.String("state-dir", defaultStateDir, "state directory") + tlsCert := flags.String("tls-cert", "", "TLS certificate PEM file (required for an https public URL)") + tlsKey := flags.String("tls-key", "", "TLS private key PEM file (required for an https public URL)") + if err := flags.Parse(args); err != nil { + return err + } + if *publicURL == "" { + return errors.New("--public-url is required") + } + baseURL, err := normalizePublicURL(*publicURL) + if err != nil { + return err + } + useTLS := strings.HasPrefix(baseURL, "https://") + if (*tlsCert == "") != (*tlsKey == "") { + return errors.New("--tls-cert and --tls-key must be provided together") + } + if useTLS && (*tlsCert == "" || *tlsKey == "") { + return errors.New("https --public-url requires --tls-cert and --tls-key") + } + if !useTLS && (*tlsCert != "" || *tlsKey != "") { + return errors.New("--tls-cert and --tls-key require an https --public-url") + } + store, err := newStateStore(*stateDir) + if err != nil { + return err + } + h := newHub(store, baseURL) + server := &http.Server{Addr: *listen, Handler: newHTTPHandler(h), ReadHeaderTimeout: 10 * time.Second} + log.Printf("localshare-server listening on %s, public URL %s", *listen, baseURL) + if useTLS { + return server.ListenAndServeTLS(*tlsCert, *tlsKey) + } + return server.ListenAndServe() +} + +func newHTTPHandler(h *hub) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/api/v1/enroll", h.enrollHandler) + mux.HandleFunc("/api/v1/agent", h.agentHandler) + mux.HandleFunc("/share/", h.shareHandler) + return mux +} + +func runKey(args []string) error { + if len(args) == 0 { + return errors.New("key command requires create, list, or revoke") + } + flags := flag.NewFlagSet("key", flag.ContinueOnError) + stateDir := flags.String("state-dir", defaultStateDir, "state directory") + name := flags.String("name", "", "key label") + if err := flags.Parse(args[1:]); err != nil { + return err + } + store, err := newStateStore(*stateDir) + if err != nil { + return err + } + switch args[0] { + case "create": + id, value, err := store.createEnrollmentKey(*name) + if err != nil { + return err + } + fmt.Printf("id: %s\nkey: %s\n", id, value) + return nil + case "list": + current, err := store.load() + if err != nil { + return err + } + for _, item := range current.EnrollmentKeys { + fmt.Printf("%s\t%s\tused=%t\n", item.ID, item.Name, item.Used) + } + return nil + case "revoke": + if flags.NArg() != 1 { + return errors.New("key revoke requires an id") + } + return revokeEnrollmentKey(store, flags.Arg(0)) + default: + return fmt.Errorf("unknown key command %q", args[0]) + } +} + +func revokeEnrollmentKey(store *stateStore, id string) error { + return store.update(func(current *state) error { + for i := range current.EnrollmentKeys { + if current.EnrollmentKeys[i].ID == id { + current.EnrollmentKeys[i].Used = true + return nil + } + } + return errors.New("key not found") + }) +} + +func runDevice(args []string) error { + if len(args) == 0 || (args[0] != "list" && args[0] != "revoke") || (args[0] == "revoke" && len(args) < 2) { + return errors.New("device command requires list or revoke ") + } + flags := flag.NewFlagSet("device", flag.ContinueOnError) + stateDir := flags.String("state-dir", defaultStateDir, "state directory") + if err := flags.Parse(args[1:]); err != nil { + return err + } + positional := flags.Args() + store, err := newStateStore(*stateDir) + if err != nil { + return err + } + if args[0] == "list" { + if len(positional) != 0 { + return errors.New("device list does not accept an id") + } + current, err := store.load() + if err != nil { + return err + } + for _, item := range current.Devices { + fmt.Printf("%s\t%s\trevoked=%t\n", item.ID, item.Name, item.Revoked) + } + return nil + } + if len(positional) != 1 { + return errors.New("device revoke requires an id") + } + return store.revokeDevice(positional[0]) +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: localshare-server serve --public-url [--listen :8080] [--state-dir dir] [--tls-cert file --tls-key file]") + fmt.Fprintln(os.Stderr, " localshare-server key create|list|revoke [--name name] [--state-dir dir]") + fmt.Fprintln(os.Stderr, " localshare-server device list|revoke [--state-dir dir] []") +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "serve": + err = runServe(os.Args[2:]) + case "key": + err = runKey(os.Args[2:]) + case "device": + err = runDevice(os.Args[2:]) + default: + usage() + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Print(err) + os.Exit(1) + } +} diff --git a/server/main_test.go b/server/main_test.go new file mode 100644 index 0000000..0feb6e5 --- /dev/null +++ b/server/main_test.go @@ -0,0 +1,443 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" +) + +func TestDataFrameRoundTrip(t *testing.T) { + frame := encodeData("req_123", []byte("hello")) + id, data, ok := decodeData(frame) + if !ok || id != "req_123" || !bytes.Equal(data, []byte("hello")) { + t.Fatalf("round trip failed: %q %q %t", id, data, ok) + } + if _, _, ok := decodeData([]byte{0, 0, 0, 9, 'x'}); ok { + t.Fatal("accepted truncated frame") + } +} + +func TestDefaultStateDir(t *testing.T) { + if defaultStateDir != "/var/lib/localshare" { + t.Fatalf("state dir = %q", defaultStateDir) + } +} + +func TestEnrollmentKeyIsOneTime(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + _, key, err := store.createEnrollmentKey("test") + if err != nil { + t.Fatal(err) + } + if _, _, err := store.enroll(key, "Mac"); err != nil { + t.Fatal(err) + } + if _, _, err := store.enroll(key, "Mac"); err == nil { + t.Fatal("enrollment key was reusable") + } +} + +func TestRewriteLocationKeepsSharePrefix(t *testing.T) { + if got := rewriteLocation("/folder/", "share_123"); got != "/share/share_123/folder/" { + t.Fatalf("got %q", got) + } + if got := rewriteLocation("https://example.com/x", "share_123"); got != "https://example.com/x" { + t.Fatalf("rewrote absolute URL: %q", got) + } +} + +func TestNormalizePublicURLRequiresBaseHTTPURL(t *testing.T) { + if got, err := normalizePublicURL("https://ls.example.com/"); err != nil || got != "https://ls.example.com" { + t.Fatalf("normalized URL = %q, err = %v", got, err) + } + for _, value := range []string{"", "ls.example.com", "https://ls.example.com/path", "https://ls.example.com/?x=1"} { + if _, err := normalizePublicURL(value); err == nil { + t.Fatalf("accepted invalid public URL %q", value) + } + } +} + +func TestPublicShareQueryCannotOverrideLocalToken(t *testing.T) { + req := httptest.NewRequest("GET", "http://server/share/share_1/a.txt?t=attacker&raw=1", nil) + query := req.URL.Query() + query.Del("t") + if got := query.Encode(); got != "raw=1" { + t.Fatalf("forwarded query = %q", got) + } +} + +func TestWebSocketRelayStreamsAgentResponse(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + hub := newHub(store, "") + server := httptest.NewServer(newHTTPHandler(hub)) + defer server.Close() + hub.publicURL = server.URL + + _, enrollmentKey, err := store.createEnrollmentKey("test") + if err != nil { + t.Fatal(err) + } + _, deviceToken, err := store.enroll(enrollmentKey, "Mac") + if err != nil { + t.Fatal(err) + } + conn, _, err := websocket.Dial(context.Background(), server.URL+"/api/v1/agent", &websocket.DialOptions{ + HTTPHeader: http.Header{"Authorization": []string{"Bearer " + deviceToken}}, + }) + if err != nil { + t.Fatal(err) + } + defer conn.Close(websocket.StatusNormalClosure, "done") + + ctx := context.Background() + typ, payload, err := conn.Read(ctx) + if err != nil || typ != websocket.MessageText { + t.Fatalf("hello = %v %q", err, payload) + } + var hello wireMessage + if err := json.Unmarshal(payload, &hello); err != nil || hello.Type != "hello" { + t.Fatalf("invalid hello: %q", payload) + } + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"share.start","protocol":1,"share_id":"local_1"}`)); err != nil { + t.Fatal(err) + } + _, payload, err = conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var ready wireMessage + if err := json.Unmarshal(payload, &ready); err != nil || ready.Type != "share.ready" { + t.Fatalf("invalid share response: %q", payload) + } + if !strings.HasPrefix(ready.ShareURL, server.URL+"/share/") { + t.Fatalf("unexpected share URL: %q", ready.ShareURL) + } + + result := make(chan []byte, 1) + errors := make(chan error, 1) + go func() { + response, err := http.Get(ready.ShareURL + "/hello.txt") + if err != nil { + errors <- err + return + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + errors <- fmt.Errorf("browser status = %d", response.StatusCode) + return + } + body, err := io.ReadAll(response.Body) + if err != nil { + errors <- err + return + } + result <- body + }() + + _, payload, err = conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var request wireMessage + if err := json.Unmarshal(payload, &request); err != nil || request.Type != "request.begin" { + t.Fatalf("invalid request: %q", payload) + } + if request.ShareID != "local_1" { + t.Fatalf("request share id = %q", request.ShareID) + } + body := bytes.Repeat([]byte("x"), 128<<10) + responseBegin, _ := json.Marshal(wireMessage{ + Type: "response.begin", RequestID: request.RequestID, Status: http.StatusOK, + Headers: map[string]string{"Content-Type": "text/plain", "Content-Length": fmt.Sprint(len(body))}, + }) + if err := conn.Write(ctx, websocket.MessageText, responseBegin); err != nil { + t.Fatal(err) + } + if err := conn.Write(ctx, websocket.MessageBinary, encodeData(request.RequestID, body)); err != nil { + t.Fatal(err) + } + responseEnd, _ := json.Marshal(wireMessage{Type: "response.end", RequestID: request.RequestID}) + if err := conn.Write(ctx, websocket.MessageText, responseEnd); err != nil { + t.Fatal(err) + } + select { + case err := <-errors: + t.Fatal(err) + case got := <-result: + if !bytes.Equal(got, body) { + t.Fatalf("browser body = %q", got) + } + } +} + +func TestStateStorePermissionsAndCrossInstanceLock(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + first, err := newStateStore(dir) + if err != nil { + t.Fatal(err) + } + second, err := newStateStore(dir) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o700 { + t.Fatalf("state dir mode = %v", info.Mode().Perm()) + } + + locked := make(chan struct{}) + release := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + firstDone <- first.withFileLock(true, func() error { + close(locked) + <-release + return nil + }) + }() + <-locked + secondDone := make(chan error, 1) + go func() { + _, _, err := second.createEnrollmentKey("second") + secondDone <- err + }() + select { + case err := <-secondDone: + t.Fatalf("second store bypassed file lock: %v", err) + case <-time.After(30 * time.Millisecond): + } + close(release) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } + info, err = os.Stat(filepath.Join(dir, "state.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("state file mode = %v", info.Mode().Perm()) + } +} + +func TestStartingNewShareInvalidatesOldToken(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + h := newHub(store, "http://example.test") + a := &agent{device: device{ID: "device_1"}} + oldURL, err := h.startShare(a, "local_old") + if err != nil { + t.Fatal(err) + } + newURL, err := h.startShare(a, "local_new") + if err != nil { + t.Fatal(err) + } + if oldURL == newURL || !strings.HasSuffix(newURL, "/") { + t.Fatalf("share URLs = %q, %q", oldURL, newURL) + } + oldToken := strings.TrimSuffix(strings.TrimPrefix(oldURL, "http://example.test/share/"), "/") + h.mu.Lock() + old := h.shares[oldToken] + var current *share + for _, item := range h.shares { + current = item + } + h.mu.Unlock() + if old != nil || current == nil || current.shareID != "local_new" { + t.Fatalf("old=%v current=%+v", old, current) + } +} + +func TestSharePathWithoutSlashRedirectsBeforeRelay(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + h := newHub(store, "http://example.test") + req := httptest.NewRequest(http.MethodGet, "http://example.test/share/share_1?raw=1", nil) + recorder := httptest.NewRecorder() + h.shareHandler(recorder, req) + if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "/share/share_1/?raw=1" { + t.Fatalf("redirect = %d %q", recorder.Code, recorder.Header().Get("Location")) + } +} + +func TestRelayTimeoutSendsCancel(t *testing.T) { + h, server, conn, shareURL := openTestAgent(t) + defer server.Close() + defer conn.Close(websocket.StatusNormalClosure, "done") + h.requestIdleTimeout = 30 * time.Millisecond + + status := make(chan int, 1) + go func() { + response, err := http.Get(shareURL + "slow.txt") + if err != nil { + status <- 0 + return + } + defer response.Body.Close() + status <- response.StatusCode + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, payload, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var begin wireMessage + if json.Unmarshal(payload, &begin) != nil || begin.Type != "request.begin" { + t.Fatalf("begin = %q", payload) + } + _, payload, err = conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var cancelMessage wireMessage + if json.Unmarshal(payload, &cancelMessage) != nil || cancelMessage.Type != "request.cancel" || cancelMessage.RequestID != begin.RequestID { + t.Fatalf("cancel = %q", payload) + } + if got := <-status; got != http.StatusGatewayTimeout { + t.Fatalf("status = %d", got) + } +} + +func TestBrowserCancellationPropagatesToAgent(t *testing.T) { + _, server, conn, shareURL := openTestAgent(t) + defer server.Close() + defer conn.Close(websocket.StatusNormalClosure, "done") + ctx, cancelBrowser := context.WithCancel(context.Background()) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, shareURL+"cancel.txt", nil) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + response, err := http.DefaultClient.Do(request) + if response != nil { + response.Body.Close() + } + done <- err + }() + readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second) + defer cancelRead() + _, payload, err := conn.Read(readCtx) + if err != nil { + t.Fatal(err) + } + var begin wireMessage + if json.Unmarshal(payload, &begin) != nil || begin.Type != "request.begin" { + t.Fatalf("begin = %q", payload) + } + cancelBrowser() + _, payload, err = conn.Read(readCtx) + if err != nil { + t.Fatal(err) + } + var cancelMessage wireMessage + if json.Unmarshal(payload, &cancelMessage) != nil || cancelMessage.Type != "request.cancel" || cancelMessage.RequestID != begin.RequestID { + t.Fatalf("cancel = %q", payload) + } + if err := <-done; err == nil { + t.Fatal("browser request unexpectedly succeeded") + } +} + +func TestRevokedDeviceCannotOpenNewWebSocket(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + h := newHub(store, "") + server := httptest.NewServer(newHTTPHandler(h)) + defer server.Close() + _, enrollmentKey, err := store.createEnrollmentKey("test") + if err != nil { + t.Fatal(err) + } + deviceID, deviceToken, err := store.enroll(enrollmentKey, "Mac") + if err != nil { + t.Fatal(err) + } + if err := store.revokeDevice(deviceID); err != nil { + t.Fatal(err) + } + conn, response, err := websocket.Dial(context.Background(), server.URL+"/api/v1/agent", &websocket.DialOptions{ + HTTPHeader: http.Header{"Authorization": []string{"Bearer " + deviceToken}}, + }) + if conn != nil { + conn.Close(websocket.StatusNormalClosure, "done") + } + if err == nil || response == nil || response.StatusCode != http.StatusUnauthorized { + t.Fatalf("dial err=%v response=%v", err, response) + } +} + +func openTestAgent(t *testing.T) (*hub, *httptest.Server, *websocket.Conn, string) { + t.Helper() + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + h := newHub(store, "") + server := httptest.NewServer(newHTTPHandler(h)) + h.publicURL = server.URL + _, enrollmentKey, err := store.createEnrollmentKey("test") + if err != nil { + t.Fatal(err) + } + _, deviceToken, err := store.enroll(enrollmentKey, "Mac") + if err != nil { + t.Fatal(err) + } + conn, _, err := websocket.Dial(context.Background(), server.URL+"/api/v1/agent", &websocket.DialOptions{ + HTTPHeader: http.Header{"Authorization": []string{"Bearer " + deviceToken}}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, _, err := conn.Read(ctx); err != nil { + t.Fatal(err) + } + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"share.start","protocol":1,"share_id":"local_1"}`)); err != nil { + t.Fatal(err) + } + _, payload, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var ready wireMessage + if json.Unmarshal(payload, &ready) != nil || ready.Type != "share.ready" { + t.Fatalf("ready = %q", payload) + } + return h, server, conn, ready.ShareURL +}