initial: ZeroTier-like P2P mesh VPN server with multi-tenant Web UI

This commit is contained in:
xieyao
2026-06-17 03:50:23 +08:00
commit ee5f036325
74 changed files with 9517 additions and 0 deletions

113
internal/service/auth.go Normal file
View File

@@ -0,0 +1,113 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"zeromesh/internal/config"
"zeromesh/internal/middleware"
"zeromesh/internal/model"
"zeromesh/internal/repository"
)
type AuthService struct {
userRepo *repository.UserRepo
jwtCfg config.JWTConfig
}
func NewAuthService(userRepo *repository.UserRepo, jwtCfg config.JWTConfig) *AuthService {
return &AuthService{userRepo: userRepo, jwtCfg: jwtCfg}
}
func (s *AuthService) Login(username, password string) (string, *model.User, error) {
user, err := s.userRepo.FindByUsername(username)
if err != nil {
return "", nil, errors.New("invalid credentials")
}
// Migrate old SHA256 hashes to bcrypt on login
if len(user.Password) < 4 || user.Password[:4] != "$2a$" {
if sha256Hash(password) != user.Password {
return "", nil, errors.New("invalid credentials")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", nil, fmt.Errorf("migrate password: %w", err)
}
user.Password = string(hashed)
_ = s.userRepo.UpdatePassword(user.ID, user.Password)
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return "", nil, errors.New("invalid credentials")
}
token, err := middleware.GenerateToken(s.jwtCfg.Secret, user.ID, user.Username, user.Role, s.jwtCfg.ExpireHours)
if err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
user.Token = token
return token, user, nil
}
func (s *AuthService) Register(username, password string) (*model.User, string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("hash password: %w", err)
}
user := &model.User{
Username: username,
Password: string(hashed),
Role: "user",
}
if err := s.userRepo.Create(user); err != nil {
return nil, "", err
}
token, err := middleware.GenerateToken(s.jwtCfg.Secret, user.ID, user.Username, user.Role, s.jwtCfg.ExpireHours)
if err != nil {
return nil, "", fmt.Errorf("generate token: %w", err)
}
user.Token = token
return user, token, nil
}
func (s *AuthService) InitAdmin(username, password string) (*model.User, string, error) {
count, err := s.userRepo.Count()
if err != nil {
return nil, "", err
}
if count > 0 {
return nil, "", fmt.Errorf("admin already exists")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("hash password: %w", err)
}
user := &model.User{
Username: username,
Password: string(hashed),
Role: "admin",
QuotaNetworks: -1,
QuotaNodes: -1,
}
if err := s.userRepo.Create(user); err != nil {
return nil, "", err
}
token, err := middleware.GenerateToken(s.jwtCfg.Secret, user.ID, user.Username, user.Role, s.jwtCfg.ExpireHours)
if err != nil {
return nil, "", fmt.Errorf("generate token: %w", err)
}
user.Token = token
return user, token, nil
}
func sha256Hash(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}
func (s *AuthService) CheckAdmin() (bool, error) {
return s.userRepo.HasAdmin()
}

54
internal/service/log.go Normal file
View File

@@ -0,0 +1,54 @@
package service
import (
"zeromesh/internal/model"
"zeromesh/internal/repository"
)
type LogService struct {
repo *repository.LogRepo
}
func NewLogService(repo *repository.LogRepo) *LogService {
return &LogService{repo: repo}
}
func (s *LogService) ListApiLogs(page, pageSize int, ip, path, method string, statusCode int) ([]model.ApiLog, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
return s.repo.ListApiLogs(page, pageSize, ip, path, method, statusCode)
}
func (s *LogService) ListErrorLogs(page, pageSize int, ip, path, method string, statusCode int) ([]model.ErrorLog, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
return s.repo.ListErrorLogs(page, pageSize, ip, path, method, statusCode)
}
func (s *LogService) ListApiLogGrouped() ([]map[string]interface{}, error) {
return s.repo.ListApiLogGrouped()
}
func (s *LogService) InsertApiLog(log *model.ApiLog) error {
return s.repo.InsertApiLog(log)
}
func (s *LogService) InsertErrorLog(log *model.ErrorLog) error {
return s.repo.InsertErrorLog(log)
}
func (s *LogService) ClearApiLogs() error {
return s.repo.ClearApiLogs()
}
func (s *LogService) ClearErrorLogs() error {
return s.repo.ClearErrorLogs()
}

163
internal/service/network.go Normal file
View File

@@ -0,0 +1,163 @@
package service
import (
"fmt"
"math/rand"
"net"
"zeromesh/internal/model"
"zeromesh/internal/repository"
)
type NetworkService struct {
netRepo *repository.NetworkRepo
nodeRepo *repository.NodeRepo
userRepo *repository.UserRepo
}
func NewNetworkService(netRepo *repository.NetworkRepo, nodeRepo *repository.NodeRepo, userRepo *repository.UserRepo) *NetworkService {
return &NetworkService{netRepo: netRepo, nodeRepo: nodeRepo, userRepo: userRepo}
}
func randomPrivateSubnet() string {
const (
_10 = iota // 10.x.y.0/24
_172 // 172.16-31.y.0/24
_192 // 192.168.y.0/24
)
class := rand.Intn(3)
var b1, b2 byte
switch class {
case _10:
b1 = byte(rand.Intn(256))
b2 = byte(rand.Intn(256))
return fmt.Sprintf("10.%d.%d.0/24", b1, b2)
case _172:
b1 = byte(16 + rand.Intn(16)) // 16-31
b2 = byte(rand.Intn(256))
return fmt.Sprintf("172.%d.%d.0/24", b1, b2)
default:
b2 = byte(rand.Intn(256))
return fmt.Sprintf("192.168.%d.0/24", b2)
}
}
func (s *NetworkService) CreateNetwork(userID uint, name, ipRange string) (*model.Network, error) {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
if user.QuotaNetworks >= 0 {
used, _ := s.netRepo.CountByUser(userID)
if used >= int64(user.QuotaNetworks) {
return nil, fmt.Errorf("network quota exceeded (%d)", user.QuotaNetworks)
}
}
if ipRange == "" {
ipRange = randomPrivateSubnet()
}
id := rand.Uint32()
network := &model.Network{
UserID: userID,
NetworkID: id,
Name: name,
IPRange: ipRange,
MTU: 2800,
Multicast: true,
Private: true,
}
if err := s.netRepo.Create(network); err != nil {
return nil, err
}
return network, nil
}
func (s *NetworkService) ListNetworks(userID uint) ([]model.Network, error) {
if userID == 0 {
return s.netRepo.List()
}
return s.netRepo.ListByUser(userID)
}
func (s *NetworkService) GetNetwork(networkID uint32, userID uint) (*model.Network, error) {
if userID == 0 {
return s.netRepo.FindByNetworkID(networkID)
}
return s.netRepo.FindByNetworkIDForUser(networkID, userID)
}
func (s *NetworkService) DeleteNetwork(networkID uint32, userID uint) error {
net, err := s.GetNetwork(networkID, userID)
if err != nil {
return err
}
return s.netRepo.Delete(net.NetworkID)
}
func (s *NetworkService) AuthorizeMember(networkID uint32, nodeID string, userID uint) error {
network, err := s.GetNetwork(networkID, userID)
if err != nil {
return err
}
node, err := s.nodeRepo.FindByNodeID(nodeID)
if err != nil {
return err
}
if node.UserID != userID && userID != 0 {
return fmt.Errorf("node does not belong to user")
}
ip, err := s.allocateIP(network.IPRange, networkID, nodeID)
if err != nil {
return err
}
member := &model.NetworkMember{
NetworkID: networkID,
NodeID: nodeID,
IPAddress: ip,
Authorized: true,
Label: node.Name,
}
if err := s.netRepo.AddMember(member); err != nil {
return fmt.Errorf("add member: %w", err)
}
return nil
}
func (s *NetworkService) DeauthorizeMember(networkID uint32, nodeID string, userID uint) error {
_, err := s.GetNetwork(networkID, userID)
if err != nil {
return err
}
return s.netRepo.RemoveMember(networkID, nodeID)
}
func (s *NetworkService) ListMembers(networkID uint32, userID uint) ([]model.NetworkMember, error) {
_, err := s.GetNetwork(networkID, userID)
if err != nil {
return nil, err
}
return s.netRepo.FindMembers(networkID)
}
func (s *NetworkService) allocateIP(cidr string, networkID uint32, nodeID string) (string, error) {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
ones, bits := ipNet.Mask.Size()
ip := ipNet.IP.To4()
if ip == nil {
return "", fmt.Errorf("IPv4 only")
}
// simple allocation: use last 2 bytes of nodeID hash
h := uint32(0)
for _, b := range []byte(nodeID) {
h = h*31 + uint32(b)
}
hostBits := bits - ones
maxHosts := (1 << uint(hostBits)) - 2
hostOffset := int(h%uint32(maxHosts)) + 1
ip[2] = byte(hostOffset >> 8)
ip[3] = byte(hostOffset)
return ip.String(), nil
}

89
internal/service/node.go Normal file
View File

@@ -0,0 +1,89 @@
package service
import (
"fmt"
"time"
"zeromesh/internal/model"
"zeromesh/internal/repository"
)
type NodeService struct {
nodeRepo *repository.NodeRepo
userRepo *repository.UserRepo
}
func NewNodeService(nodeRepo *repository.NodeRepo, userRepo *repository.UserRepo) *NodeService {
return &NodeService{nodeRepo: nodeRepo, userRepo: userRepo}
}
func (s *NodeService) Register(nodeID, publicKey, name, ipAddress string, port int, version string, userID uint) (*model.Node, error) {
if userID > 0 {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
if user.QuotaNodes >= 0 {
used, _ := s.nodeRepo.CountByUser(userID)
if used >= int64(user.QuotaNodes) {
return nil, fmt.Errorf("node quota exceeded (%d)", user.QuotaNodes)
}
}
}
node := &model.Node{
UserID: userID,
NodeID: nodeID,
PublicKey: publicKey,
Name: name,
IPAddress: ipAddress,
Port: port,
Online: true,
LastSeen: now(),
Version: version,
}
if err := s.nodeRepo.Upsert(node); err != nil {
return nil, err
}
return node, nil
}
func (s *NodeService) SetOnline(nodeID string, online bool) error {
return s.nodeRepo.SetOnline(nodeID, online)
}
func (s *NodeService) ListNode(userID uint) ([]model.Node, error) {
if userID == 0 {
return s.nodeRepo.List()
}
return s.nodeRepo.ListByUser(userID)
}
func (s *NodeService) ListOnline(userID uint) ([]model.Node, error) {
if userID == 0 {
return s.nodeRepo.ListOnline()
}
nodes, err := s.nodeRepo.ListByUser(userID)
if err != nil {
return nil, err
}
var online []model.Node
for _, n := range nodes {
if n.Online {
online = append(online, n)
}
}
return online, nil
}
func (s *NodeService) GetNode(nodeID string) (*model.Node, error) {
return s.nodeRepo.FindByNodeID(nodeID)
}
func (s *NodeService) CountByUser(userID uint) (int64, error) {
return s.nodeRepo.CountByUser(userID)
}
func now() *time.Time {
t := time.Now()
return &t
}

25
internal/service/user.go Normal file
View File

@@ -0,0 +1,25 @@
package service
import (
"zeromesh/internal/database"
)
type UserService struct {
db *database.DB
}
func NewUserService(db *database.DB) *UserService {
return &UserService{db: db}
}
func (s *UserService) Count() (int64, error) {
var count int64
err := s.db.Model(&struct{}{}).Table("users").Count(&count).Error
return count, err
}
func (s *UserService) List(offset, limit int) ([]map[string]interface{}, error) {
var users []map[string]interface{}
err := s.db.Table("users").Offset(offset).Limit(limit).Order("id desc").Find(&users).Error
return users, err
}