PortRelay:把内网服务临时、可控地开放给同事

边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
This commit is contained in:
DeepFal committed 2026-10-13 16:33:17 +08:00
commit 86e159b3f0
67 files changed
+13888

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
// mkgif 把逐帧 PNG 合成为循环播放的 GIF,用于 README 动图。
//
// go run ./tools/ui-snapshots/mkgif -o docs/images/live-traffic.gif tools/ui-snapshots/out/gif-*.png
//
// 所有帧共用一套中位切分调色板;第二帧起只编码与上一帧不同的区域,
// 区域内未变化的像素写成透明色,界面类动图体积因此很小。
package main
import (
"flag"
"image"
"image/color"
"image/gif"
"image/png"
"log"
"os"
"sort"
)
func main() {
out := flag.String("o", "out.gif", "output file")
delay := flag.Int("delay", 40, "frame delay in 1/100 s")
hold := flag.Int("hold", 120, "extra delay on the last frame in 1/100 s")
shrink := flag.Int("shrink", 1, "downscale factor (box filter)")
flag.Parse()
if flag.NArg() == 0 {
log.Fatal("no input frames")
}
var frames []*image.RGBA
for _, path := range flag.Args() {
frames = append(frames, downscale(load(path), *shrink))
}
bounds := frames[0].Bounds()
pal := medianCut(histogram(frames), 255)
transparent := uint8(len(pal))
pal = append(pal, color.RGBA{})
idx := newMapper(pal[:transparent])
anim := &gif.GIF{LoopCount: 0, Config: image.Config{ColorModel: pal, Width: bounds.Dx(), Height: bounds.Dy()}}
var prev *image.Paletted
for i, f := range frames {
cur := image.NewPaletted(bounds, pal)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
cur.SetColorIndex(x, y, idx.index(f.RGBAAt(x, y)))
}
}
frame := cur
if prev != nil {
frame = diff(prev, cur, transparent)
}
d := *delay
if i == len(frames)-1 {
d += *hold
}
anim.Image = append(anim.Image, frame)
anim.Delay = append(anim.Delay, d)
anim.Disposal = append(anim.Disposal, gif.DisposalNone)
prev = cur
}
w, err := os.Create(*out)
if err != nil {
log.Fatal(err)
}
if err := gif.EncodeAll(w, anim); err != nil {
log.Fatal(err)
}
if err := w.Close(); err != nil {
log.Fatal(err)
}
st, _ := os.Stat(*out)
log.Printf("%s: %d frames, %dx%d, %d KB", *out, len(frames), bounds.Dx(), bounds.Dy(), st.Size()/1024)
}
func load(path string) *image.RGBA {
f, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
log.Fatalf("%s: %v", path, err)
}
b := img.Bounds()
rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
rgba.Set(x, y, img.At(b.Min.X+x, b.Min.Y+y))
}
}
return rgba
}
func downscale(src *image.RGBA, k int) *image.RGBA {
if k <= 1 {
return src
}
b := src.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()/k, b.Dy()/k))
for y := 0; y < dst.Bounds().Dy(); y++ {
for x := 0; x < dst.Bounds().Dx(); x++ {
var r, g, bl, a int
for dy := 0; dy < k; dy++ {
for dx := 0; dx < k; dx++ {
c := src.RGBAAt(x*k+dx, y*k+dy)
r, g, bl, a = r+int(c.R), g+int(c.G), bl+int(c.B), a+int(c.A)
}
}
n := k * k
dst.SetRGBA(x, y, color.RGBA{uint8(r / n), uint8(g / n), uint8(bl / n), uint8(a / n)})
}
}
return dst
}
type bucket struct {
c [3]uint8
count int
}
func histogram(frames []*image.RGBA) []bucket {
counts := map[[3]uint8]int{}
for _, f := range frames {
for i := 0; i+3 < len(f.Pix); i += 4 {
counts[[3]uint8{f.Pix[i], f.Pix[i+1], f.Pix[i+2]}]++
}
}
out := make([]bucket, 0, len(counts))
for c, n := range counts {
out = append(out, bucket{c, n})
}
return out
}
// medianCut 反复把"像素数 × 色彩跨度"最大的盒子沿最长轴按像素中位数切开。
func medianCut(colors []bucket, n int) color.Palette {
boxes := [][]bucket{colors}
for len(boxes) < n {
best, bestScore, bestAxis := -1, 0, 0
for i, b := range boxes {
if len(b) < 2 {
continue
}
axis, span := longestAxis(b)
total := 0
for _, c := range b {
total += c.count
}
if score := span * total; score > bestScore {
best, bestScore, bestAxis = i, score, axis
}
}
if best < 0 {
break
}
b := boxes[best]
sort.Slice(b, func(i, j int) bool { return b[i].c[bestAxis] < b[j].c[bestAxis] })
total := 0
for _, c := range b {
total += c.count
}
cut, acc := 1, 0
for i, c := range b {
acc += c.count
if acc*2 >= total {
cut = i + 1
break
}
}
if cut >= len(b) {
cut = len(b) - 1
}
boxes[best] = b[:cut]
boxes = append(boxes, b[cut:])
}
pal := make(color.Palette, 0, len(boxes))
for _, b := range boxes {
var r, g, bl, total int
for _, c := range b {
r += int(c.c[0]) * c.count
g += int(c.c[1]) * c.count
bl += int(c.c[2]) * c.count
total += c.count
}
pal = append(pal, color.RGBA{uint8(r / total), uint8(g / total), uint8(bl / total), 255})
}
return pal
}
func longestAxis(b []bucket) (axis, span int) {
for a := 0; a < 3; a++ {
lo, hi := 255, 0
for _, c := range b {
v := int(c.c[a])
lo, hi = min(lo, v), max(hi, v)
}
if hi-lo > span {
axis, span = a, hi-lo
}
}
return axis, span
}
type mapper struct {
pal color.Palette
cache map[color.RGBA]uint8
}
func newMapper(pal color.Palette) *mapper {
return &mapper{pal: pal, cache: map[color.RGBA]uint8{}}
}
func (m *mapper) index(c color.RGBA) uint8 {
c.A = 255
if i, ok := m.cache[c]; ok {
return i
}
best, bestD := 0, int(^uint(0)>>1)
for i, p := range m.pal {
q := p.(color.RGBA)
dr, dg, db := int(c.R)-int(q.R), int(c.G)-int(q.G), int(c.B)-int(q.B)
if d := 3*dr*dr + 4*dg*dg + 2*db*db; d < bestD {
best, bestD = i, d
}
}
m.cache[c] = uint8(best)
return uint8(best)
}
// diff 返回只覆盖变化区域的帧,区域内未变化的像素为透明色。
func diff(prev, cur *image.Paletted, transparent uint8) *image.Paletted {
b := cur.Bounds()
minX, minY, maxX, maxY := b.Max.X, b.Max.Y, b.Min.X-1, b.Min.Y-1
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
if prev.ColorIndexAt(x, y) != cur.ColorIndexAt(x, y) {
minX, minY, maxX, maxY = min(minX, x), min(minY, y), max(maxX, x), max(maxY, y)
}
}
}
if maxX < minX {
minX, minY, maxX, maxY = 0, 0, 0, 0
}
r := image.Rect(minX, minY, maxX+1, maxY+1)
out := image.NewPaletted(r, cur.Palette)
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
if v := cur.ColorIndexAt(x, y); v != prev.ColorIndexAt(x, y) {
out.SetColorIndex(x, y, v)
} else {
out.SetColorIndex(x, y, transparent)
}
}
}
return out
}