From e3147ac06fbb27cef07e0e182579fd9a6d603fda Mon Sep 17 00:00:00 2001 From: xieyao Date: Wed, 17 Jun 2026 03:59:45 +0800 Subject: [PATCH] feat: add Go SDK package (REST client + VL1 agent + TAP device) --- sdk/agent.go | 529 +++++++++++++++++++++++++++++++++++++++++++++++ sdk/api.go | 210 +++++++++++++++++++ sdk/identity.go | 121 +++++++++++ sdk/sdk.go | 214 +++++++++++++++++++ sdk/tap_linux.go | 75 +++++++ sdk/tap_stub.go | 26 +++ 6 files changed, 1175 insertions(+) create mode 100644 sdk/agent.go create mode 100644 sdk/api.go create mode 100644 sdk/identity.go create mode 100644 sdk/sdk.go create mode 100644 sdk/tap_linux.go create mode 100644 sdk/tap_stub.go diff --git a/sdk/agent.go b/sdk/agent.go new file mode 100644 index 0000000..8bb53ba --- /dev/null +++ b/sdk/agent.go @@ -0,0 +1,529 @@ +package sdk + +import ( + "context" + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "log/slog" + "net" + "sync" + "time" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + vl1Version = 1 + vl1HeaderSize = 8 + pktHandshake = byte(1) + pktData = byte(2) + pktKeepalive = byte(3) + keepaliveInterval = 25 * time.Second + peerTimeout = 90 * time.Second + maintenanceTick = 10 * time.Second + macTableCap = 4096 + macTableExpiry = 5 * time.Minute +) + +type Agent struct { + cfg Config + client *Client + identity *Identity + udpConn *net.UDPConn + peers *peerManager + tapDev *tapInterface + log *slog.Logger + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + localMAC [6]byte + macTable *macTable +} + +func NewAgent(client *Client) *Agent { + ctx, cancel := context.WithCancel(context.Background()) + a := &Agent{ + cfg: client.cfg, + client: client, + identity: client.identity, + log: slog.Default().With("component", "sdk-agent"), + ctx: ctx, + cancel: cancel, + } + if a.identity == nil { + a.identity = GenerateIdentity() + } + a.peers = newPeerManager() + a.macTable = newMACTable() + a.localMAC = generateMAC(a.cfg.NetworkID, a.identity.Address) + return a +} + +func (a *Agent) Start() error { + port := a.cfg.ListenPort + if port == 0 { + port = 0 // OS auto-assign + } + udpAddr := &net.UDPAddr{Port: port} + conn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return fmt.Errorf("bind udp: %w", err) + } + a.udpConn = conn + a.log.Info("udp bound", "port", conn.LocalAddr().(*net.UDPAddr).Port) + + tap, err := openTap(a.cfg.TapName, a.cfg.TapMTU) + if err != nil { + a.udpConn.Close() + return fmt.Errorf("open tap: %w", err) + } + a.tapDev = tap + a.log.Info("tap device opened", "name", tap.Name, "mtu", tap.MTU) + + a.wg.Add(3) + go a.udpReadLoop() + go a.tapReadLoop() + go a.maintenanceLoop() + return nil +} + +func (a *Agent) Stop() { + a.cancel() + if a.tapDev != nil { + a.tapDev.Close() + } + if a.udpConn != nil { + a.udpConn.Close() + } + a.wg.Wait() + a.log.Info("agent stopped") +} + +func (a *Agent) LocalPort() int { + if a.udpConn == nil { + return 0 + } + return a.udpConn.LocalAddr().(*net.UDPAddr).Port +} + +func (a *Agent) udpReadLoop() { + defer a.wg.Done() + buf := make([]byte, 65535) + for { + select { + case <-a.ctx.Done(): + return + default: + } + a.udpConn.SetReadDeadline(time.Now().Add(1 * time.Second)) + n, addr, err := a.udpConn.ReadFromUDP(buf) + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + continue + } + data := make([]byte, n) + copy(data, buf[:n]) + a.handlePacket(data, addr) + } +} + +func (a *Agent) handlePacket(data []byte, addr *net.UDPAddr) { + if len(data) < vl1HeaderSize { + return + } + pktType := data[1] + netID := binary.BigEndian.Uint32(data[2:6]) + _ = netID + payloadLen := int(binary.BigEndian.Uint16(data[6:8])) + if vl1HeaderSize+payloadLen > len(data) { + return + } + payload := data[vl1HeaderSize : vl1HeaderSize+payloadLen] + + switch pktType { + case pktHandshake: + a.handleHandshake(payload, addr) + case pktData: + a.handleData(payload, addr) + case pktKeepalive: + a.peers.touchByEndpoint(addr) + } +} + +func (a *Agent) handleHandshake(payload []byte, addr *net.UDPAddr) { + if len(payload) < 32 { + return + } + var pubKey [32]byte + copy(pubKey[:], payload[:32]) + peerAddr := AddressFromPublicKey(pubKey[:]) + peer := a.peers.upsert(peerAddr, pubKey, addr) + peer.Touch() + a.log.Info("handshake from peer", "addr", peerAddr, "endpoint", addr) + + sendKey, recvKey := deriveKeys([]byte(a.cfg.PSK), a.identity.PublicKey, pubKey[:]) + c, err := chacha20poly1305.New(sendKey[:]) + if err != nil { + return + } + _ = c + peer.sendCipher, err = chacha20poly1305.New(sendKey[:]) + if err != nil { + return + } + peer.recvCipher, err = chacha20poly1305.New(recvKey[:]) + if err != nil { + return + } + + hello := make([]byte, 32) + copy(hello, a.identity.PublicKey) + a.sendPacket(pktHandshake, 0, hello, addr) +} + +func (a *Agent) handleData(payload []byte, addr *net.UDPAddr) { + peer := a.peers.getByEndpoint(addr) + if peer == nil || peer.recvCipher == nil { + return + } + peer.Touch() + + nonce := make([]byte, 12) + if len(payload) < 8 { + return + } + binary.BigEndian.PutUint64(nonce[4:], binary.BigEndian.Uint64(payload[:8])) + decrypted, err := peer.recvCipher.Open(nil, nonce, payload[8:], nil) + if err != nil { + return + } + frame := decrypted + if len(frame) < 14 { + return + } + + a.macTable.learn(frame[6:12], peer.addr) + + dstMAC := frame[:6] + srcMAC := frame[6:12] + _ = srcMAC + + if isLocalMAC(dstMAC, a.localMAC[:]) { + if a.tapDev != nil { + a.tapDev.Write(frame) + } + return + } + if isBroadcastMAC(dstMAC) { + if a.tapDev != nil { + a.tapDev.Write(frame) + } + a.broadcastToPeers(frame, peer.addr) + return + } + + dstAddr := a.macTable.lookup(string(dstMAC)) + if dstAddr != nil { + if p := a.peers.getByAddr(*dstAddr); p != nil { + a.sendToPeer(frame, p) + } else { + a.broadcastToPeers(frame, peer.addr) + } + } else { + a.broadcastToPeers(frame, peer.addr) + } +} + +func (a *Agent) tapReadLoop() { + defer a.wg.Done() + buf := make([]byte, a.cfg.TapMTU+14+64) + for { + select { + case <-a.ctx.Done(): + return + default: + } + n, err := a.tapDev.Read(buf) + if err != nil { + continue + } + frame := make([]byte, n) + copy(frame, buf[:n]) + if len(frame) < 14 { + continue + } + a.macTable.learn(frame[6:12], zeroAddr) + + dstMAC := frame[:6] + if isBroadcastMAC(dstMAC) { + a.broadcastToPeers(frame, zeroAddr) + continue + } + dstAddr := a.macTable.lookup(string(dstMAC)) + if dstAddr != nil { + if p := a.peers.getByAddr(*dstAddr); p != nil { + a.sendToPeer(frame, p) + } else { + a.broadcastToPeers(frame, zeroAddr) + } + } else { + a.broadcastToPeers(frame, zeroAddr) + } + } +} + +func (a *Agent) maintenanceLoop() { + defer a.wg.Done() + ticker := time.NewTicker(maintenanceTick) + defer ticker.Stop() + for { + select { + case <-a.ctx.Done(): + return + case <-ticker.C: + a.peers.cleanDead() + a.macTable.clean() + for _, p := range a.peers.all() { + if p.NeedsKeepalive() { + a.sendPacket(pktKeepalive, 0, nil, p.Endpoint) + p.LastSend = time.Now() + } + if !p.Connected && p.sendCipher != nil { + p.Connected = true + } + if !p.Connected { + a.sendHello(p) + } + } + } + } +} + +func (a *Agent) sendPacket(pktType byte, netID uint32, payload []byte, addr *net.UDPAddr) { + if addr == nil { + return + } + total := vl1HeaderSize + len(payload) + buf := make([]byte, total) + buf[0] = vl1Version + buf[1] = pktType + binary.BigEndian.PutUint32(buf[2:6], netID) + binary.BigEndian.PutUint16(buf[6:8], uint16(len(payload))) + copy(buf[vl1HeaderSize:], payload) + a.udpConn.WriteTo(buf, addr) +} + +func (a *Agent) sendToPeer(frame []byte, p *peer) { + if p == nil || p.sendCipher == nil { + return + } + p.LastSend = time.Now() + nonceBuf := make([]byte, 12) + binary.BigEndian.PutUint64(nonceBuf[4:], p.sendNonce) + p.sendNonce++ + encrypted := p.sendCipher.Seal(nil, nonceBuf, frame, nil) + header := make([]byte, 8) + binary.BigEndian.PutUint64(header, binary.BigEndian.Uint64(nonceBuf[4:])) + payload := append(header, encrypted...) + a.sendPacket(pktData, a.cfg.NetworkID, payload, p.Endpoint) +} + +func (a *Agent) broadcastToPeers(frame []byte, exclude Address) { + for _, p := range a.peers.all() { + if p.addr == exclude { + continue + } + if p.sendCipher == nil { + continue + } + a.sendToPeer(frame, p) + } +} + +func (a *Agent) sendHello(p *peer) { + payload := make([]byte, 32) + copy(payload, a.identity.PublicKey) + a.sendPacket(pktHandshake, 0, payload, p.Endpoint) +} + +var zeroAddr Address + +type peer struct { + addr Address + pubKey [32]byte + Endpoint *net.UDPAddr + sendCipher cipher.AEAD + recvCipher cipher.AEAD + Connected bool + LastSeen time.Time + LastSend time.Time + sendNonce uint64 +} + +func (p *peer) Touch() { p.LastSeen = time.Now() } +func (p *peer) NeedsKeepalive() bool { return time.Since(p.LastSend) > keepaliveInterval } +func (p *peer) IsAlive() bool { return time.Since(p.LastSeen) < peerTimeout } + +type peerManager struct { + mu sync.RWMutex + peers map[Address]*peer + epIdx map[string]*peer +} + +func newPeerManager() *peerManager { + return &peerManager{ + peers: make(map[Address]*peer), + epIdx: make(map[string]*peer), + } +} + +func (pm *peerManager) upsert(addr Address, pubKey [32]byte, ep *net.UDPAddr) *peer { + pm.mu.Lock() + defer pm.mu.Unlock() + if p, ok := pm.peers[addr]; ok { + p.Endpoint = ep + pm.epIdx[ep.String()] = p + return p + } + p := &peer{addr: addr, pubKey: pubKey, Endpoint: ep} + pm.peers[addr] = p + pm.epIdx[ep.String()] = p + return p +} + +func (pm *peerManager) getByEndpoint(ep *net.UDPAddr) *peer { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.epIdx[ep.String()] +} + +func (pm *peerManager) getByAddr(addr Address) *peer { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.peers[addr] +} + +func (pm *peerManager) touchByEndpoint(ep *net.UDPAddr) { + pm.mu.Lock() + defer pm.mu.Unlock() + if p, ok := pm.epIdx[ep.String()]; ok { + p.Touch() + } +} + +func (pm *peerManager) all() []*peer { + pm.mu.RLock() + defer pm.mu.RUnlock() + out := make([]*peer, 0, len(pm.peers)) + for _, p := range pm.peers { + out = append(out, p) + } + return out +} + +func (pm *peerManager) cleanDead() { + pm.mu.Lock() + defer pm.mu.Unlock() + for addr, p := range pm.peers { + if !p.IsAlive() { + delete(pm.epIdx, p.Endpoint.String()) + delete(pm.peers, addr) + } + } +} + +type macEntry struct { + peerAddr Address + added time.Time +} + +type macTable struct { + mu sync.RWMutex + tbl map[string]macEntry +} + +func newMACTable() *macTable { + return &macTable{tbl: make(map[string]macEntry)} +} + +func (mt *macTable) learn(mac []byte, peerAddr Address) { + mt.mu.Lock() + defer mt.mu.Unlock() + if len(mt.tbl) >= macTableCap { + for k, v := range mt.tbl { + if time.Since(v.added) > macTableExpiry { + delete(mt.tbl, k) + } + } + } + mt.tbl[string(mac)] = macEntry{peerAddr: peerAddr, added: time.Now()} +} + +func (mt *macTable) lookup(mac string) *Address { + mt.mu.RLock() + defer mt.mu.RUnlock() + e, ok := mt.tbl[mac] + if !ok { + return nil + } + zero := Address{} + if e.peerAddr == zero { + return nil + } + return &e.peerAddr +} + +func (mt *macTable) clean() { + mt.mu.Lock() + defer mt.mu.Unlock() + for k, v := range mt.tbl { + if time.Since(v.added) > macTableExpiry { + delete(mt.tbl, k) + } + } +} + +func generateMAC(networkID uint32, addr Address) [6]byte { + var mac [6]byte + mac[0] = 0x02 + binary.BigEndian.PutUint32(mac[1:5], networkID) + mac[5] = addr[4] + return mac +} + +func isLocalMAC(dst, local []byte) bool { + return dst[0] == local[0] && dst[1] == local[1] && dst[2] == local[2] && + dst[3] == local[3] && dst[4] == local[4] && dst[5] == local[5] +} + +func isBroadcastMAC(dst []byte) bool { + for _, b := range dst { + if b != 0xff { + return false + } + } + return true +} + +func deriveKeys(psk, localPub, remotePub []byte) ([32]byte, [32]byte) { + h := sha256.New() + h.Write(psk) + h.Write(localPub) + h.Write(remotePub) + var sendKey [32]byte + copy(sendKey[:], h.Sum(nil)) + + h2 := sha256.New() + h2.Write(sendKey[:]) + h2.Write([]byte("reverse")) + var recvKey [32]byte + copy(recvKey[:], h2.Sum(nil)) + return sendKey, recvKey +} + + diff --git a/sdk/api.go b/sdk/api.go new file mode 100644 index 0000000..c7a9a61 --- /dev/null +++ b/sdk/api.go @@ -0,0 +1,210 @@ +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +func (c *Client) doReq(method, path string, body, out interface{}) error { + url := strings.TrimRight(c.cfg.ControllerURL, "/") + path + var r io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + r = bytes.NewReader(data) + } + req, err := http.NewRequest(method, url, r) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + if c.cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + } + if r != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpc.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + respData, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= 400 { + var errResp struct{ Error string `json:"error"` } + if json.Unmarshal(respData, &errResp) == nil && errResp.Error != "" { + return fmt.Errorf("%s", errResp.Error) + } + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respData)) + } + if out != nil { + if err := json.Unmarshal(respData, out); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + } + return nil +} + +func (c *Client) Login(username, password string) error { + var resp AuthResponse + if err := c.doReq("POST", "/api/v1/auth/login", map[string]string{ + "username": username, + "password": password, + }, &resp); err != nil { + return err + } + c.cfg.Token = resp.Token + return nil +} + +func (c *Client) Register(username, password string) (*User, error) { + var resp AuthResponse + if err := c.doReq("POST", "/api/v1/auth/register", map[string]string{ + "username": username, + "password": password, + }, &resp); err != nil { + return nil, err + } + c.cfg.Token = resp.Token + return resp.User, nil +} + +func (c *Client) InitAdmin(username, password string) (*User, error) { + var resp AuthResponse + if err := c.doReq("POST", "/api/v1/auth/init", map[string]string{ + "username": username, + "password": password, + }, &resp); err != nil { + return nil, err + } + c.cfg.Token = resp.Token + return resp.User, nil +} + +func (c *Client) CheckAdmin() (bool, error) { + var resp struct{ AdminExists bool `json:"admin_exists"` } + if err := c.doReq("GET", "/api/v1/admin/check", nil, &resp); err != nil { + return false, err + } + return resp.AdminExists, nil +} + +func (c *Client) Dashboard() (*DashboardStats, error) { + var stats DashboardStats + if err := c.doReq("GET", "/api/v1/dashboard", nil, &stats); err != nil { + return nil, err + } + return &stats, nil +} + +func (c *Client) AdminDashboard() (*DashboardStats, error) { + var stats DashboardStats + if err := c.doReq("GET", "/api/v1/admin/dashboard", nil, &stats); err != nil { + return nil, err + } + return &stats, nil +} + +func (c *Client) Profile() (*ProfileResponse, error) { + var resp ProfileResponse + if err := c.doReq("GET", "/api/v1/user/profile", nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) CreateNetwork(name, ipRange string) (*Network, error) { + body := map[string]string{"name": name} + if ipRange != "" { + body["ip_range"] = ipRange + } + var resp struct{ Network *Network `json:"network"` } + if err := c.doReq("POST", "/api/v1/network/create", body, &resp); err != nil { + return nil, err + } + return resp.Network, nil +} + +func (c *Client) ListNetworks() ([]Network, error) { + var resp NetworkListResponse + if err := c.doReq("GET", "/api/v1/network/list", nil, &resp); err != nil { + return nil, err + } + return resp.Networks, nil +} + +func (c *Client) GetNetwork(id uint32) (*Network, error) { + var resp struct{ Network *Network `json:"network"` } + if err := c.doReq("GET", fmt.Sprintf("/api/v1/network/%d", id), nil, &resp); err != nil { + return nil, err + } + return resp.Network, nil +} + +func (c *Client) DeleteNetwork(id uint32) error { + return c.doReq("DELETE", fmt.Sprintf("/api/v1/network/%d", id), nil, nil) +} + +func (c *Client) NetworkMembers(id uint32) ([]NetworkMember, error) { + var resp MembersResponse + if err := c.doReq("GET", fmt.Sprintf("/api/v1/network/%d/members", id), nil, &resp); err != nil { + return nil, err + } + return resp.Members, nil +} + +func (c *Client) AuthorizeMember(networkID uint32, nodeID string) error { + return c.doReq("POST", fmt.Sprintf("/api/v1/network/%d/authorize", networkID), + map[string]string{"node_id": nodeID}, nil) +} + +func (c *Client) DeauthorizeMember(networkID uint32, nodeID string) error { + return c.doReq("POST", fmt.Sprintf("/api/v1/network/%d/deauthorize", networkID), + map[string]string{"node_id": nodeID}, nil) +} + +func (c *Client) RegisterNode(nodeID, publicKey, name, ipAddr string, port int, version string) (*Node, error) { + body := map[string]interface{}{ + "node_id": nodeID, + "public_key": publicKey, + "name": name, + } + if ipAddr != "" { + body["ip_address"] = ipAddr + } + if port > 0 { + body["port"] = port + } + if version != "" { + body["version"] = version + } + var resp struct{ Node *Node `json:"node"` } + if err := c.doReq("POST", "/api/v1/node/register", body, &resp); err != nil { + return nil, err + } + return resp.Node, nil +} + +func (c *Client) ListNodes() ([]Node, error) { + var resp NodeListResponse + if err := c.doReq("GET", "/api/v1/node/list", nil, &resp); err != nil { + return nil, err + } + return resp.Nodes, nil +} + +func (c *Client) ListOnlineNodes() ([]Node, error) { + var resp NodeListResponse + if err := c.doReq("GET", "/api/v1/node/online", nil, &resp); err != nil { + return nil, err + } + return resp.Nodes, nil +} diff --git a/sdk/identity.go b/sdk/identity.go new file mode 100644 index 0000000..fa28d7a --- /dev/null +++ b/sdk/identity.go @@ -0,0 +1,121 @@ +package sdk + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +func LoadOrGenerateIdentity(path string) (*Identity, error) { + data, err := os.ReadFile(path) + if err == nil { + return parseIdentity(strings.TrimSpace(string(data))) + } + if !os.IsNotExist(err) { + return nil, err + } + id := GenerateIdentity() + encoded, err := serializeIdentity(id) + if err != nil { + return nil, err + } + dir := dirname(path) + if dir != "" { + if e := os.MkdirAll(dir, 0755); e != nil { + return nil, e + } + } + if e := os.WriteFile(path, []byte(encoded), 0600); e != nil { + return nil, e + } + return id, nil +} + +type identityJSON struct { + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + Address string `json:"address"` +} + +func serializeIdentity(id *Identity) (string, error) { + data, err := json.Marshal(identityJSON{ + PublicKey: id.PublicKeyHex(), + PrivateKey: id.PrivateKeyHex(), + Address: id.Address.String(), + }) + if err != nil { + return "", err + } + return string(data), nil +} + +func parseIdentity(data string) (*Identity, error) { + var j identityJSON + if err := json.Unmarshal([]byte(data), &j); err != nil { + return nil, fmt.Errorf("parse identity: %w", err) + } + if j.PrivateKey == "" { + return nil, fmt.Errorf("invalid identity: no private key") + } + pub, err := hexDecode(j.PublicKey) + if err != nil { + return nil, fmt.Errorf("decode public key: %w", err) + } + priv, err := hexDecode(j.PrivateKey) + if err != nil { + return nil, fmt.Errorf("decode private key: %w", err) + } + return &Identity{ + PublicKey: pub, + PrivateKey: priv, + Address: AddressFromPublicKey(pub), + }, nil +} + +func hexDecode(s string) ([]byte, error) { + out, err := hexDecodeSimple(s) + if err != nil { + return nil, err + } + return out, nil +} + +func hexDecodeSimple(s string) ([]byte, error) { + h := func(c byte) (byte, error) { + switch { + case '0' <= c && c <= '9': + return c - '0', nil + case 'a' <= c && c <= 'f': + return c - 'a' + 10, nil + case 'A' <= c && c <= 'F': + return c - 'A' + 10, nil + default: + return 0, fmt.Errorf("invalid hex") + } + } + if len(s)%2 != 0 { + return nil, fmt.Errorf("odd hex length") + } + out := make([]byte, len(s)/2) + for i := 0; i < len(s); i += 2 { + hi, e1 := h(s[i]) + lo, e2 := h(s[i+1]) + if e1 != nil || e2 != nil { + return nil, fmt.Errorf("invalid hex char") + } + out[i/2] = hi<<4 | lo + } + return out, nil +} + +func dirname(path string) string { + idx := strings.LastIndex(path, "/") + if idx == -1 { + idx = strings.LastIndex(path, "\\") + } + if idx == -1 { + return "" + } + return path[:idx] +} diff --git a/sdk/sdk.go b/sdk/sdk.go new file mode 100644 index 0000000..49506e2 --- /dev/null +++ b/sdk/sdk.go @@ -0,0 +1,214 @@ +package sdk + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "net" + "net/http" + "time" +) + +type Config struct { + ControllerURL string + Token string + PSK string + NetworkID uint32 + ListenPort int + TapName string + TapMTU int + IdentityPath string + HTTPTimeout time.Duration +} + +func DefaultConfig() Config { + return Config{ + ControllerURL: "http://127.0.0.1:10001", + ListenPort: 0, + TapName: "zeromesh0", + TapMTU: 2800, + IdentityPath: "./data/agent.identity", + HTTPTimeout: 30 * time.Second, + } +} + +type Client struct { + cfg Config + httpc *http.Client + identity *Identity +} + +func New(cfg Config) *Client { + if cfg.HTTPTimeout == 0 { + cfg.HTTPTimeout = 30 * time.Second + } + if cfg.TapName == "" { + cfg.TapName = "zeromesh0" + } + if cfg.TapMTU == 0 { + cfg.TapMTU = 2800 + } + if cfg.IdentityPath == "" { + cfg.IdentityPath = "./data/agent.identity" + } + return &Client{ + cfg: cfg, + httpc: &http.Client{ + Timeout: cfg.HTTPTimeout, + Transport: &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 60 * time.Second, + DisableCompression: false, + }, + }, + } +} + +func (c *Client) Config() Config { return c.cfg } +func (c *Client) HTTPClient() *http.Client { return c.httpc } +func (c *Client) Identity() *Identity { return c.identity } +func (c *Client) SetToken(tok string) { c.cfg.Token = tok } +func (c *Client) Token() string { return c.cfg.Token } + +type Address [5]byte + +func (a Address) String() string { return hex.EncodeToString(a[:]) } + +func AddressFromPublicKey(pub []byte) Address { + var a Address + h := hashBytes(pub) + copy(a[:], h[:5]) + return a +} + +type Identity struct { + PublicKey ed25519.PublicKey + PrivateKey ed25519.PrivateKey + Address Address +} + +func GenerateIdentity() *Identity { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + return &Identity{ + PublicKey: pub, + PrivateKey: priv, + Address: AddressFromPublicKey(pub), + } +} + +func (id *Identity) PublicKeyHex() string { return hex.EncodeToString(id.PublicKey) } +func (id *Identity) PrivateKeyHex() string { return hex.EncodeToString(id.PrivateKey) } + +func hashBytes(data []byte) []byte { + h := make([]byte, 32) + for i, b := range data { + h[i%32] ^= b + } + for round := 0; round < 3; round++ { + for i := 0; i < 32; i++ { + h[i] = h[i] ^ h[(i+1)%32] ^ h[(i+7)%32] + h[i] = (h[i] << 3) | (h[i] >> 5) + } + } + return h +} + +type AuthResponse struct { + Token string `json:"token"` + User *User `json:"user"` +} + +type User struct { + ID uint `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + QuotaNetworks int `json:"quota_networks"` + QuotaNodes int `json:"quota_nodes"` +} + +type Network struct { + ID uint `json:"id"` + NetworkID uint32 `json:"network_id"` + UserID uint `json:"user_id"` + Name string `json:"name"` + IPRange string `json:"ip_range"` + MTU int `json:"mtu"` + Private bool `json:"private"` + Members []NetworkMember `json:"members,omitempty"` +} + +type NetworkMember struct { + ID uint `json:"id"` + NetworkID uint32 `json:"network_id"` + NodeID string `json:"node_id"` + IPAddress string `json:"ip_address"` + Authorized bool `json:"authorized"` + Label string `json:"label"` +} + +type Node struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + NodeID string `json:"node_id"` + PublicKey string `json:"public_key"` + Name string `json:"name"` + IPAddress string `json:"ip_address"` + Port int `json:"port"` + Online bool `json:"online"` + LastSeen *string `json:"last_seen"` + Version string `json:"version"` +} + +type DashboardStats struct { + AuthorizedMembers int `json:"authorized_members"` + NodesTotal int `json:"nodes_total"` + NodesOnline int `json:"nodes_online"` + NetworksCount int `json:"networks_count"` +} + +type NetworkListResponse struct { + Networks []Network `json:"networks"` +} + +type NodeListResponse struct { + Nodes []Node `json:"nodes"` +} + +type MembersResponse struct { + Members []NetworkMember `json:"members"` +} + +type ProfileResponse struct { + User User `json:"user"` + UsedNetworks int `json:"used_networks"` + UsedNodes int `json:"used_nodes"` +} + +func (a Address) MarshalText() ([]byte, error) { + return []byte(a.String()), nil +} + +func (a *Address) UnmarshalText(text []byte) error { + decoded, err := hex.DecodeString(string(text)) + if err != nil { + return err + } + if len(decoded) != 5 { + return err + } + copy(a[:], decoded) + return nil +} + +func GetPreferredIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "0.0.0.0" + } + defer conn.Close() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP.String() +} diff --git a/sdk/tap_linux.go b/sdk/tap_linux.go new file mode 100644 index 0000000..ae23371 --- /dev/null +++ b/sdk/tap_linux.go @@ -0,0 +1,75 @@ +//go:build linux + +package sdk + +import ( + "fmt" + "syscall" + "unsafe" +) + +type tapInterface struct { + Name string + MTU int + fd int +} + +func openTap(name string, mtu int) (*tapInterface, error) { + fd, err := syscall.Open("/dev/net/tun", syscall.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("open /dev/net/tun: %w", err) + } + var ifr struct { + name [16]byte + flags uint16 + } + copy(ifr.name[:], []byte(name)) + ifr.flags = 0x0002 | 0x1000 // IFF_TAP | IFF_NO_PI + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), 0x400454ca, uintptr(unsafe.Pointer(&ifr))); errno != 0 { + syscall.Close(fd) + return nil, fmt.Errorf("TUNSETIFF: %w", errno) + } + devName := string(ifr.name[:]) + if idx := indexOfZero(devName); idx >= 0 { + devName = devName[:idx] + } + ti := &tapInterface{Name: devName, MTU: mtu, fd: fd} + + // Set MTU via socket ioctl + s, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) + if err == nil { + defer syscall.Close(s) + var mtuReq struct { + name [16]byte + mtu int32 + } + copy(mtuReq.name[:], []byte(devName)) + mtuReq.mtu = int32(mtu) + syscall.Syscall(syscall.SYS_IOCTL, uintptr(s), 0x400c4a52, uintptr(unsafe.Pointer(&mtuReq))) // SIOCSIFMTU + } + return ti, nil +} + +func (ti *tapInterface) Read(buf []byte) (int, error) { + return syscall.Read(ti.fd, buf) +} + +func (ti *tapInterface) Write(buf []byte) error { + _, err := syscall.Write(ti.fd, buf) + return err +} + +func (ti *tapInterface) Close() error { + return syscall.Close(ti.fd) +} + +func indexOfZero(s string) int { + for i, c := range []byte(s) { + if c == 0 { + return i + } + } + return -1 +} + + diff --git a/sdk/tap_stub.go b/sdk/tap_stub.go new file mode 100644 index 0000000..c2d3d94 --- /dev/null +++ b/sdk/tap_stub.go @@ -0,0 +1,26 @@ +//go:build !linux + +package sdk + +import "fmt" + +type tapInterface struct { + Name string + MTU int +} + +func openTap(name string, mtu int) (*tapInterface, error) { + return nil, fmt.Errorf("TAP device requires Linux") +} + +func (ti *tapInterface) Read(buf []byte) (int, error) { + return 0, fmt.Errorf("not supported") +} + +func (ti *tapInterface) Write(buf []byte) error { + return fmt.Errorf("not supported") +} + +func (ti *tapInterface) Close() error { + return nil +}