Initial commit (code only without large binaries)

This commit is contained in:
robin
2026-02-15 18:58:44 +08:00
commit 35df75498f
9442 changed files with 1495866 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
//go:build plus
package accesslogs
import (
"encoding/json"
"fmt"
"github.com/TeaOSLab/EdgeCommon/pkg/configutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"strconv"
"time"
)
type BaseStorage struct {
isOk bool
version int
firewallOnly bool
}
func (this *BaseStorage) SetVersion(version int) {
this.version = version
}
func (this *BaseStorage) Version() int {
return this.version
}
func (this *BaseStorage) IsOk() bool {
return this.isOk
}
func (this *BaseStorage) SetOk(isOk bool) {
this.isOk = isOk
}
func (this *BaseStorage) SetFirewallOnly(firewallOnly bool) {
this.firewallOnly = firewallOnly
}
// Marshal 对日志进行编码
func (this *BaseStorage) Marshal(accessLog *pb.HTTPAccessLog) ([]byte, error) {
return json.Marshal(accessLog)
}
// FormatVariables 格式化字符串中的变量
func (this *BaseStorage) FormatVariables(s string) string {
var now = time.Now()
return configutils.ParseVariables(s, func(varName string) (value string) {
switch varName {
case "year":
return strconv.Itoa(now.Year())
case "month":
return fmt.Sprintf("%02d", now.Month())
case "week":
_, week := now.ISOWeek()
return fmt.Sprintf("%02d", week)
case "day":
return fmt.Sprintf("%02d", now.Day())
case "hour":
return fmt.Sprintf("%02d", now.Hour())
case "minute":
return fmt.Sprintf("%02d", now.Minute())
case "second":
return fmt.Sprintf("%02d", now.Second())
case "date":
return fmt.Sprintf("%d%02d%02d", now.Year(), now.Month(), now.Day())
}
return varName
})
}

View File

@@ -0,0 +1,101 @@
//go:build plus
package accesslogs
import (
"bytes"
"errors"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/logs"
"os/exec"
"sync"
)
// CommandStorage 通过命令行存储
type CommandStorage struct {
BaseStorage
config *serverconfigs.AccessLogCommandStorageConfig
writeLocker sync.Mutex
}
func NewCommandStorage(config *serverconfigs.AccessLogCommandStorageConfig) *CommandStorage {
return &CommandStorage{config: config}
}
func (this *CommandStorage) Config() interface{} {
return this.config
}
// Start 启动
func (this *CommandStorage) Start() error {
if len(this.config.Command) == 0 {
return errors.New("'command' should not be empty")
}
return nil
}
// 写入日志
func (this *CommandStorage) Write(accessLogs []*pb.HTTPAccessLog) error {
if len(accessLogs) == 0 {
return nil
}
this.writeLocker.Lock()
defer this.writeLocker.Unlock()
var cmd = exec.Command(this.config.Command, this.config.Args...)
if len(this.config.Dir) > 0 {
cmd.Dir = this.config.Dir
}
var stdout = bytes.NewBuffer([]byte{})
cmd.Stdout = stdout
w, err := cmd.StdinPipe()
if err != nil {
return err
}
err = cmd.Start()
if err != nil {
return err
}
for _, accessLog := range accessLogs {
if this.firewallOnly && accessLog.FirewallPolicyId == 0 {
continue
}
data, err := this.Marshal(accessLog)
if err != nil {
logs.Error(err)
continue
}
_, err = w.Write(data)
if err != nil {
logs.Error(err)
}
_, err = w.Write([]byte("\n"))
if err != nil {
logs.Error(err)
}
}
_ = w.Close()
err = cmd.Wait()
if err != nil {
logs.Error(err)
if stdout.Len() > 0 {
logs.Error(errors.New(stdout.String()))
}
}
return nil
}
// Close 关闭
func (this *CommandStorage) Close() error {
return nil
}

View File

@@ -0,0 +1,65 @@
//go:build plus
package accesslogs
import (
executils "github.com/TeaOSLab/EdgeAPI/internal/utils/exec"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"os"
"testing"
"time"
)
func TestCommandStorage_Write(t *testing.T) {
php, err := executils.LookPath("php")
if err != nil { // not found php, so we can not test
t.Log("php:", err)
return
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
before := time.Now()
storage := NewCommandStorage(&serverconfigs.AccessLogCommandStorageConfig{
Command: php,
Args: []string{cwd + "/tests/command_storage.php"},
})
err = storage.Start()
if err != nil {
t.Fatal(err)
}
err = storage.Write([]*pb.HTTPAccessLog{
{
RequestMethod: "GET",
RequestPath: "/hello",
},
{
RequestMethod: "GET",
RequestPath: "/world",
},
{
RequestMethod: "GET",
RequestPath: "/lu",
},
{
RequestMethod: "GET",
RequestPath: "/ping",
},
})
if err != nil {
t.Fatal(err)
}
err = storage.Close()
if err != nil {
t.Fatal(err)
}
t.Log(time.Since(before).Seconds(), "seconds")
}

View File

@@ -0,0 +1,159 @@
//go:build plus
package accesslogs
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/TeaOSLab/EdgeAPI/internal/remotelogs"
"github.com/TeaOSLab/EdgeAPI/internal/utils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/maps"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// ESStorage ElasticSearch存储策略
type ESStorage struct {
BaseStorage
config *serverconfigs.AccessLogESStorageConfig
}
func NewESStorage(config *serverconfigs.AccessLogESStorageConfig) *ESStorage {
return &ESStorage{config: config}
}
func (this *ESStorage) Config() any {
return this.config
}
// Start 开启
func (this *ESStorage) Start() error {
if len(this.config.Endpoint) == 0 {
return errors.New("'endpoint' should not be nil")
}
if !regexp.MustCompile(`(?i)^(http|https)://`).MatchString(this.config.Endpoint) {
this.config.Endpoint = "http://" + this.config.Endpoint
}
// 去除endpoint中的路径部分
u, err := url.Parse(this.config.Endpoint)
if err == nil && len(u.Path) > 0 {
this.config.Endpoint = u.Scheme + "://" + u.Host
}
if len(this.config.Index) == 0 {
return errors.New("'index' should not be nil")
}
if !this.config.IsDataStream && len(this.config.MappingType) == 0 {
return errors.New("'mappingType' should not be nil")
}
return nil
}
// 写入日志
func (this *ESStorage) Write(accessLogs []*pb.HTTPAccessLog) error {
if len(accessLogs) == 0 {
return nil
}
var bulk = &strings.Builder{}
var indexName = this.FormatVariables(this.config.Index)
var typeName = this.FormatVariables(this.config.MappingType)
for _, accessLog := range accessLogs {
if this.firewallOnly && accessLog.FirewallPolicyId == 0 {
continue
}
if len(accessLog.RequestId) == 0 {
continue
}
var indexMap = map[string]any{
"_index": indexName,
"_id": accessLog.RequestId,
}
if !this.config.IsDataStream {
indexMap["_type"] = typeName
}
opData, err := json.Marshal(map[string]any{
"index": indexMap,
})
if err != nil {
remotelogs.Error("ACCESS_LOG_ES_STORAGE", "write failed: "+err.Error())
continue
}
data, err := this.Marshal(accessLog)
if err != nil {
remotelogs.Error("ACCESS_LOG_ES_STORAGE", "marshal data failed: "+err.Error())
continue
}
bulk.Write(opData)
bulk.WriteString("\n")
bulk.Write(data)
bulk.WriteString("\n")
}
if bulk.Len() == 0 {
return nil
}
req, err := http.NewRequest(http.MethodPost, this.config.Endpoint+"/_bulk", strings.NewReader(bulk.String()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", strings.ReplaceAll(teaconst.ProductName, " ", "-")+"/"+teaconst.Version)
if len(this.config.Username) > 0 || len(this.config.Password) > 0 {
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(this.config.Username+":"+this.config.Password)))
}
var client = utils.SharedHttpClient(10 * time.Second)
defer func() {
_ = req.Body.Close()
}()
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
bodyData, _ := io.ReadAll(resp.Body)
return errors.New("ElasticSearch response status code: " + fmt.Sprintf("%d", resp.StatusCode) + " content: " + string(bodyData))
}
bodyData, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read ElasticSearch response failed: %w", err)
}
var m = maps.Map{}
err = json.Unmarshal(bodyData, &m)
if err == nil {
// 暂不处理非JSON的情况
if m.Has("errors") && m.GetBool("errors") {
return errors.New("ElasticSearch returns '" + string(bodyData) + "'")
}
}
return nil
}
// Close 关闭
func (this *ESStorage) Close() error {
return nil
}

View File

@@ -0,0 +1,55 @@
//go:build plus
package accesslogs
import (
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"testing"
"time"
)
func TestESStorage_Write(t *testing.T) {
storage := NewESStorage(&serverconfigs.AccessLogESStorageConfig{
Endpoint: "http://127.0.0.1:9200",
Index: "logs",
MappingType: "accessLogs",
Username: "hello",
Password: "world",
})
err := storage.Start()
if err != nil {
t.Fatal(err)
}
{
err = storage.Write([]*pb.HTTPAccessLog{
{
RequestMethod: "POST",
RequestPath: "/1",
TimeLocal: time.Now().Format("2/Jan/2006:15:04:05 -0700"),
TimeISO8601: "2018-07-23T22:23:35+08:00",
Header: map[string]*pb.Strings{
"Content-Type": {Values: []string{"text/html"}},
},
},
{
RequestMethod: "GET",
RequestPath: "/2",
TimeLocal: time.Now().Format("2/Jan/2006:15:04:05 -0700"),
TimeISO8601: "2018-07-23T22:23:35+08:00",
Header: map[string]*pb.Strings{
"Content-Type": {Values: []string{"text/css"}},
},
},
})
if err != nil {
t.Fatal(err)
}
}
err = storage.Close()
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,132 @@
//go:build plus
package accesslogs
import (
"errors"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/logs"
"os"
"path/filepath"
"sync"
)
// FileStorage 文件存储策略
type FileStorage struct {
BaseStorage
config *serverconfigs.AccessLogFileStorageConfig
writeLocker sync.Mutex
files map[string]*os.File // path => *File
filesLocker sync.Mutex
}
func NewFileStorage(config *serverconfigs.AccessLogFileStorageConfig) *FileStorage {
return &FileStorage{
config: config,
}
}
func (this *FileStorage) Config() interface{} {
return this.config
}
// Start 开启
func (this *FileStorage) Start() error {
if len(this.config.Path) == 0 {
return errors.New("'path' should not be empty")
}
this.files = map[string]*os.File{}
return nil
}
// Write 写入日志
func (this *FileStorage) Write(accessLogs []*pb.HTTPAccessLog) error {
if len(accessLogs) == 0 {
return nil
}
fp := this.fp()
if fp == nil {
return errors.New("file pointer should not be nil")
}
this.writeLocker.Lock()
defer this.writeLocker.Unlock()
for _, accessLog := range accessLogs {
if this.firewallOnly && accessLog.FirewallPolicyId == 0 {
continue
}
data, err := this.Marshal(accessLog)
if err != nil {
logs.Error(err)
continue
}
_, err = fp.Write(data)
if err != nil {
_ = this.Close()
break
}
_, _ = fp.WriteString("\n")
}
return nil
}
// Close 关闭
func (this *FileStorage) Close() error {
this.filesLocker.Lock()
defer this.filesLocker.Unlock()
var resultErr error
for _, f := range this.files {
err := f.Close()
if err != nil {
resultErr = err
}
}
return resultErr
}
func (this *FileStorage) fp() *os.File {
path := this.FormatVariables(this.config.Path)
this.filesLocker.Lock()
defer this.filesLocker.Unlock()
fp, ok := this.files[path]
if ok {
return fp
}
// 关闭其他的文件
for _, f := range this.files {
_ = f.Close()
}
// 是否创建文件目录
if this.config.AutoCreate {
dir := filepath.Dir(path)
_, err := os.Stat(dir)
if os.IsNotExist(err) {
err = os.MkdirAll(dir, 0777)
if err != nil {
logs.Error(err)
return nil
}
}
}
// 打开新文件
fp, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
logs.Error(err)
return nil
}
this.files[path] = fp
return fp
}

View File

@@ -0,0 +1,72 @@
//go:build plus
package accesslogs
import (
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/Tea"
"testing"
"time"
)
func TestFileStorage_Write(t *testing.T) {
storage := NewFileStorage(&serverconfigs.AccessLogFileStorageConfig{
Path: Tea.Root + "/logs/access-${date}.log",
})
err := storage.Start()
if err != nil {
t.Fatal(err)
}
{
err = storage.Write([]*pb.HTTPAccessLog{
{
RequestPath: "/hello",
},
{
RequestPath: "/world",
},
})
if err != nil {
t.Fatal(err)
}
}
{
err = storage.Write([]*pb.HTTPAccessLog{
{
RequestPath: "/1",
},
{
RequestPath: "/2",
},
})
if err != nil {
t.Fatal(err)
}
}
{
err = storage.Write([]*pb.HTTPAccessLog{
{
RequestMethod: "POST",
RequestPath: "/1",
TimeLocal: time.Now().Format("2/Jan/2006:15:04:05 -0700"),
},
{
RequestMethod: "GET",
RequestPath: "/2",
TimeLocal: time.Now().Format("2/Jan/2006:15:04:05 -0700"),
},
})
if err != nil {
t.Fatal(err)
}
}
err = storage.Close()
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
//go:build plus
package accesslogs
import "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
// StorageInterface 日志存储接口
type StorageInterface interface {
// Version 获取版本
Version() int
// SetVersion 设置版本
SetVersion(version int)
// SetFirewallOnly 设置是否只处理防火墙相关的访问日志
SetFirewallOnly(firewallOnly bool)
IsOk() bool
SetOk(ok bool)
// Config 获取配置
Config() interface{}
// Start 开启
Start() error
// Write 写入日志
Write(accessLogs []*pb.HTTPAccessLog) error
// Close 关闭
Close() error
}

View File

@@ -0,0 +1,260 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
//go:build plus
package accesslogs
import (
"encoding/json"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/TeaOSLab/EdgeAPI/internal/remotelogs"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/lists"
"github.com/iwind/TeaGo/types"
"strings"
"sync"
"time"
)
var SharedStorageManager = NewStorageManager()
type StorageManager struct {
storageMap map[int64]StorageInterface // policyId => Storage
publicPolicyId int64
disableDefaultDB bool
writeTargets *serverconfigs.AccessLogWriteTargets // 公用策略的写入目标
locker sync.Mutex
}
func NewStorageManager() *StorageManager {
return &StorageManager{
storageMap: map[int64]StorageInterface{},
}
}
func (this *StorageManager) Start() {
var ticker = time.NewTicker(1 * time.Minute)
if Tea.IsTesting() {
ticker = time.NewTicker(5 * time.Second)
}
// 启动时执行一次
var err = this.Loop()
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "update error: "+err.Error())
}
// 循环执行
for range ticker.C {
err := this.Loop()
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "update error: "+err.Error())
}
}
}
// Loop 更新
func (this *StorageManager) Loop() error {
var tx *dbs.Tx
// 共用策略
publicPolicyId, err := models.SharedHTTPAccessLogPolicyDAO.FindCurrentPublicPolicyId(tx)
if err != nil {
return err
}
this.publicPolicyId = publicPolicyId
// 所有策略
policies, err := models.SharedHTTPAccessLogPolicyDAO.FindAllEnabledAndOnPolicies(tx)
if err != nil {
return err
}
var foundPolicy = false
var policyIds = []int64{}
for _, policy := range policies {
if policy.IsOn {
policyIds = append(policyIds, int64(policy.Id))
if int64(policy.Id) == publicPolicyId {
this.disableDefaultDB = policy.DisableDefaultDB
this.writeTargets = serverconfigs.ParseWriteTargetsFromPolicy(policy.WriteTargets, policy.Type, policy.DisableDefaultDB)
foundPolicy = true
}
}
}
if !foundPolicy {
this.disableDefaultDB = false
this.writeTargets = nil
}
this.locker.Lock()
defer this.locker.Unlock()
// 关闭不用的
for policyId, storage := range this.storageMap {
if !lists.ContainsInt64(policyIds, policyId) {
err = storage.Close()
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "close '"+types.String(policyId)+"' failed: "+err.Error())
}
delete(this.storageMap, policyId)
remotelogs.Println("ACCESS_LOG_STORAGE_MANAGER", "remove '"+types.String(policyId)+"'")
}
}
for _, policy := range policies {
var policyId = int64(policy.Id)
storage, ok := this.storageMap[policyId]
if ok {
// 检查配置是否有变更
if types.Int(policy.Version) != storage.Version() {
err = storage.Close()
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "close policy '"+types.String(policyId)+"' failed: "+err.Error())
// 继续往下执行
}
if len(policy.Options) > 0 {
err = json.Unmarshal(policy.Options, storage.Config())
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "unmarshal policy '"+types.String(policyId)+"' config failed: "+err.Error())
storage.SetOk(false)
continue
}
}
this.applyFileStorageFallback(policy.Type, storage)
storage.SetVersion(types.Int(policy.Version))
storage.SetFirewallOnly(policy.FirewallOnly == 1)
err = storage.Start()
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "start policy '"+types.String(policyId)+"' failed: "+err.Error())
continue
}
storage.SetOk(true)
remotelogs.Println("ACCESS_LOG_STORAGE_MANAGER", "restart policy '"+types.String(policyId)+"'")
}
} else {
storage, err = this.createStorage(policy.Type, policy.Options)
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "create policy '"+types.String(policyId)+"' failed: "+err.Error())
continue
}
this.applyFileStorageFallback(policy.Type, storage)
storage.SetVersion(types.Int(policy.Version))
storage.SetFirewallOnly(policy.FirewallOnly == 1)
this.storageMap[policyId] = storage
err = storage.Start()
if err != nil {
remotelogs.Error("ACCESS_LOG_STORAGE_MANAGER", "start policy '"+types.String(policyId)+"' failed: "+err.Error())
continue
}
storage.SetOk(true)
remotelogs.Println("ACCESS_LOG_STORAGE_MANAGER", "start policy '"+types.String(policyId)+"'")
}
}
return nil
}
func (this *StorageManager) DisableDefaultDB() bool {
return this.disableDefaultDB
}
// WriteMySQL 公用策略是否写入 MySQL以 writeTargets 为准,无则用 disableDefaultDB
func (this *StorageManager) WriteMySQL() bool {
if this.writeTargets != nil {
return this.writeTargets.MySQL
}
return !this.disableDefaultDB
}
// WriteClickHouse 公用策略是否写入 ClickHouse文件+Fluent Bit 或后续 API 直写)
func (this *StorageManager) WriteClickHouse() bool {
if this.writeTargets != nil {
return this.writeTargets.ClickHouse
}
return false
}
// WriteTargets 返回公用策略的写入目标(供节点配置注入等)
func (this *StorageManager) WriteTargets() *serverconfigs.AccessLogWriteTargets {
return this.writeTargets
}
func (this *StorageManager) createStorage(storageType string, optionsJSON []byte) (StorageInterface, error) {
if serverconfigs.IsFileBasedStorageType(storageType) {
storageType = serverconfigs.AccessLogStorageTypeFile
}
switch storageType {
case serverconfigs.AccessLogStorageTypeFile:
var config = &serverconfigs.AccessLogFileStorageConfig{}
if len(optionsJSON) > 0 {
err := json.Unmarshal(optionsJSON, config)
if err != nil {
return nil, err
}
}
return NewFileStorage(config), nil
case serverconfigs.AccessLogStorageTypeES:
var config = &serverconfigs.AccessLogESStorageConfig{}
if len(optionsJSON) > 0 {
err := json.Unmarshal(optionsJSON, config)
if err != nil {
return nil, err
}
}
return NewESStorage(config), nil
case serverconfigs.AccessLogStorageTypeTCP:
var config = &serverconfigs.AccessLogTCPStorageConfig{}
if len(optionsJSON) > 0 {
err := json.Unmarshal(optionsJSON, config)
if err != nil {
return nil, err
}
}
return NewTCPStorage(config), nil
case serverconfigs.AccessLogStorageTypeSyslog:
var config = &serverconfigs.AccessLogSyslogStorageConfig{}
if len(optionsJSON) > 0 {
err := json.Unmarshal(optionsJSON, config)
if err != nil {
return nil, err
}
}
return NewSyslogStorage(config), nil
case serverconfigs.AccessLogStorageTypeCommand:
var config = &serverconfigs.AccessLogCommandStorageConfig{}
if len(optionsJSON) > 0 {
err := json.Unmarshal(optionsJSON, config)
if err != nil {
return nil, err
}
}
return NewCommandStorage(config), nil
}
return nil, errors.New("invalid policy type '" + storageType + "'")
}
func (this *StorageManager) applyFileStorageFallback(policyType string, storage StorageInterface) {
if !serverconfigs.IsFileBasedStorageType(policyType) {
return
}
config, ok := storage.Config().(*serverconfigs.AccessLogFileStorageConfig)
if !ok || config == nil || strings.TrimSpace(config.Path) != "" {
return
}
// file_clickhouse / file_mysql_clickhouse 未填写 path 时回退到默认文件路径,避免启动失败。
if policyType == serverconfigs.AccessLogStorageTypeFileClickhouse || policyType == serverconfigs.AccessLogStorageTypeFileMySQLClickhouse {
config.Path = Tea.Root + "/logs/access-${date}.log"
config.AutoCreate = true
}
}

View File

@@ -0,0 +1,19 @@
//go:build plus
package accesslogs
import (
"github.com/iwind/TeaGo/dbs"
"testing"
)
func TestStorageManager_Loop(t *testing.T) {
dbs.NotifyReady()
var storage = NewStorageManager()
err := storage.Loop()
if err != nil {
t.Fatal(err)
}
t.Log(storage.storageMap)
}

View File

@@ -0,0 +1,89 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
//go:build plus
package accesslogs
import (
"fmt"
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/TeaOSLab/EdgeAPI/internal/goman"
"github.com/TeaOSLab/EdgeAPI/internal/remotelogs"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"runtime"
)
var httpAccessLogQueue = make(chan []*pb.HTTPAccessLog, 1024)
func init() {
if !teaconst.IsMain {
return
}
// 多进程读取
var threads = runtime.NumCPU() / 2
if threads <= 0 {
threads = 1
}
if threads > 16 {
threads = 16
}
for i := 0; i < threads; i++ {
goman.New(func() {
for pbAccessLogs := range httpAccessLogQueue {
var policyId = SharedStorageManager.publicPolicyId
if policyId <= 0 {
continue
}
_, _, err := SharedStorageManager.WriteToPolicy(policyId, pbAccessLogs)
if err != nil {
remotelogs.Error("HTTP_ACCESS_LOG_POLICY", "write failed: "+err.Error())
}
}
})
}
}
func (this *StorageManager) Write(pbAccessLogs []*pb.HTTPAccessLog) error {
if len(pbAccessLogs) == 0 {
return nil
}
var policyId = this.publicPolicyId
if policyId <= 0 {
return nil
}
select {
case httpAccessLogQueue <- pbAccessLogs:
default:
}
return nil
}
// WriteToPolicy 写入日志到策略
func (this *StorageManager) WriteToPolicy(policyId int64, accessLogs []*pb.HTTPAccessLog) (success bool, failMessage string, err error) {
if !teaconst.IsPlus {
return false, "only works in plus version", nil
}
this.locker.Lock()
storage, ok := this.storageMap[policyId]
this.locker.Unlock()
if !ok {
return false, "the policy has not been started yet", nil
}
if !storage.IsOk() {
return false, "the policy failed to start", nil
}
err = storage.Write(accessLogs)
if err != nil {
return false, "", fmt.Errorf("write access log to policy '%d' failed: %w", policyId, err)
}
return true, "", nil
}

View File

@@ -0,0 +1,150 @@
//go:build plus
package accesslogs
import (
"bytes"
"errors"
"fmt"
"github.com/TeaOSLab/EdgeAPI/internal/remotelogs"
executils "github.com/TeaOSLab/EdgeAPI/internal/utils/exec"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/logs"
"os/exec"
"runtime"
"strconv"
)
type SyslogStorageProtocol = string
const (
SyslogStorageProtocolTCP SyslogStorageProtocol = "tcp"
SyslogStorageProtocolUDP SyslogStorageProtocol = "udp"
SyslogStorageProtocolNone SyslogStorageProtocol = "none"
SyslogStorageProtocolSocket SyslogStorageProtocol = "socket"
)
type SyslogStoragePriority = int
// SyslogStorage syslog存储策略
type SyslogStorage struct {
BaseStorage
config *serverconfigs.AccessLogSyslogStorageConfig
exe string
}
func NewSyslogStorage(config *serverconfigs.AccessLogSyslogStorageConfig) *SyslogStorage {
return &SyslogStorage{config: config}
}
func (this *SyslogStorage) Config() interface{} {
return this.config
}
// Start 开启
func (this *SyslogStorage) Start() error {
if runtime.GOOS != "linux" {
return errors.New("'syslog' storage only works on linux")
}
exe, err := executils.LookPath("logger")
if err != nil {
return err
}
this.exe = exe
return nil
}
// 写入日志
func (this *SyslogStorage) Write(accessLogs []*pb.HTTPAccessLog) error {
if len(accessLogs) == 0 {
return nil
}
var args = []string{}
if len(this.config.Tag) > 0 {
args = append(args, "-t", this.config.Tag)
}
if this.config.Priority >= 0 {
args = append(args, "-p", strconv.Itoa(this.config.Priority))
}
switch this.config.Protocol {
case SyslogStorageProtocolTCP:
args = append(args, "-T")
if len(this.config.ServerAddr) > 0 {
args = append(args, "-n", this.config.ServerAddr)
}
if this.config.ServerPort > 0 {
args = append(args, "-P", strconv.Itoa(this.config.ServerPort))
}
case SyslogStorageProtocolUDP:
args = append(args, "-d")
if len(this.config.ServerAddr) > 0 {
args = append(args, "-n", this.config.ServerAddr)
}
if this.config.ServerPort > 0 {
args = append(args, "-P", strconv.Itoa(this.config.ServerPort))
}
case SyslogStorageProtocolSocket:
args = append(args, "-u")
args = append(args, this.config.Socket)
case SyslogStorageProtocolNone:
// do nothing
}
args = append(args, "-S", "10240")
var cmd = exec.Command(this.exe, args...)
var stderrBuffer = &bytes.Buffer{}
cmd.Stderr = stderrBuffer
w, err := cmd.StdinPipe()
if err != nil {
return err
}
err = cmd.Start()
if err != nil {
return err
}
for _, accessLog := range accessLogs {
if this.firewallOnly && accessLog.FirewallPolicyId == 0 {
continue
}
data, err := this.Marshal(accessLog)
if err != nil {
remotelogs.Error("ACCESS_LOG_POLICY_SYSLOG", "marshal accesslog failed: "+err.Error())
continue
}
_, err = w.Write(data)
if err != nil {
logs.Error(err)
}
_, err = w.Write([]byte("\n"))
if err != nil {
remotelogs.Error("ACCESS_LOG_POLICY_SYSLOG", "write accesslog failed: "+err.Error())
}
}
_ = w.Close()
err = cmd.Wait()
if err != nil {
return fmt.Errorf("send syslog failed: %w, stderr: %s", err, stderrBuffer.String())
}
return nil
}
// Close 关闭
func (this *SyslogStorage) Close() error {
return nil
}

View File

@@ -0,0 +1,116 @@
//go:build plus
package accesslogs
import (
"errors"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/iwind/TeaGo/logs"
"net"
"sync"
)
// TCPStorage TCP存储策略
type TCPStorage struct {
BaseStorage
config *serverconfigs.AccessLogTCPStorageConfig
writeLocker sync.Mutex
connLocker sync.Mutex
conn net.Conn
}
func NewTCPStorage(config *serverconfigs.AccessLogTCPStorageConfig) *TCPStorage {
return &TCPStorage{config: config}
}
func (this *TCPStorage) Config() interface{} {
return this.config
}
// Start 开启
func (this *TCPStorage) Start() error {
if len(this.config.Network) == 0 {
return errors.New("'network' should not be empty")
}
if len(this.config.Addr) == 0 {
return errors.New("'addr' should not be empty")
}
return nil
}
// 写入日志
func (this *TCPStorage) Write(accessLogs []*pb.HTTPAccessLog) error {
if len(accessLogs) == 0 {
return nil
}
err := this.connect()
if err != nil {
return err
}
var conn = this.conn
if conn == nil {
return errors.New("connection should not be nil")
}
this.writeLocker.Lock()
defer this.writeLocker.Unlock()
for _, accessLog := range accessLogs {
if this.firewallOnly && accessLog.FirewallPolicyId == 0 {
continue
}
data, err := this.Marshal(accessLog)
if err != nil {
logs.Error(err)
continue
}
_, err = conn.Write(data)
if err != nil {
_ = this.Close()
break
}
_, err = conn.Write([]byte("\n"))
if err != nil {
_ = this.Close()
break
}
}
return nil
}
// Close 关闭
func (this *TCPStorage) Close() error {
this.connLocker.Lock()
defer this.connLocker.Unlock()
if this.conn != nil {
err := this.conn.Close()
this.conn = nil
return err
}
return nil
}
func (this *TCPStorage) connect() error {
this.connLocker.Lock()
defer this.connLocker.Unlock()
if this.conn != nil {
return nil
}
conn, err := net.Dial(this.config.Network, this.config.Addr)
if err != nil {
return err
}
this.conn = conn
return nil
}

View File

@@ -0,0 +1,74 @@
//go:build plus
package accesslogs
import (
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"net"
"testing"
"time"
)
func TestTCPStorage_Write(t *testing.T) {
go func() {
server, err := net.Listen("tcp", "127.0.0.1:9981")
if err != nil {
t.Error(err)
return
}
for {
conn, err := server.Accept()
if err != nil {
break
}
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if n > 0 {
t.Log(string(buf[:n]))
}
if err != nil {
break
}
}
break
}
_ = server.Close()
}()
storage := NewTCPStorage(&serverconfigs.AccessLogTCPStorageConfig{
Network: "tcp",
Addr: "127.0.0.1:9981",
})
err := storage.Start()
if err != nil {
t.Fatal(err)
}
{
err = storage.Write([]*pb.HTTPAccessLog{
{
RequestMethod: "POST",
RequestPath: "/1",
TimeLocal: time.Now().Format("2/Jan/2006:15:04:05 -0700"),
},
{
RequestMethod: "GET",
RequestPath: "/2",
TimeLocal: time.Now().Format("2/Jan/2006:15:04:05 -0700"),
},
})
if err != nil {
t.Fatal(err)
}
}
time.Sleep(2 * time.Second)
err = storage.Close()
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,24 @@
<?php
// test command storage
// open access log file
$fp = fopen("/tmp/goedge-command-storage.log", "a+");
// read access logs from stdin
$stdin = fopen("php://stdin", "r");
while(true) {
if (feof($stdin)) {
break;
}
$line = fgets($stdin);
// write to access log file
fwrite($fp, $line);
}
// close file pointers
fclose($fp);
fclose($stdin);
?>

View File

@@ -0,0 +1,8 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
package acme
type Account struct {
EABKid string
EABKey string
}

View File

@@ -0,0 +1,147 @@
package acme
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
acmelog "github.com/go-acme/lego/v4/log"
"io"
"log"
"testing"
"github.com/go-acme/lego/v4/registration"
)
// You'll need a user or account type that implements acme.User
type MyUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *MyUser) GetEmail() string {
return u.Email
}
func (u MyUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
type MyProvider struct {
t *testing.T
}
func (this *MyProvider) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)
this.t.Log("provider: domain:", domain, "fqdn:", fqdn, "value:", value)
return nil
}
func (this *MyProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}
// 参考 https://go-acme.github.io/lego/usage/library/
func TestGenerate(t *testing.T) {
acmelog.Logger = log.New(io.Discard, "", log.LstdFlags)
// 生成私钥
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
myUser := &MyUser{
Email: "test1@teaos.cn",
key: privateKey,
}
config := lego.NewConfig(myUser)
config.CADirURL = "https://acme.zerossl.com/v2/DV90"
config.Certificate.KeyType = certcrypto.RSA2048
client, err := lego.NewClient(config)
if err != nil {
t.Fatal(err)
}
err = client.Challenge.SetDNS01Provider(&MyProvider{t: t})
if err != nil {
t.Fatal(err)
}
// New users will need to register
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
t.Fatal(err)
}
myUser.Registration = reg
request := certificate.ObtainRequest{
Domains: []string{"teaos.com"},
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
t.Fatal(err)
}
t.Log(certificates)
}
func TestGenerate_EAB(t *testing.T) {
acmelog.Logger = log.New(io.Discard, "", log.LstdFlags)
// 生成私钥
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
myUser := &MyUser{
Email: "test1@teaos.cn",
key: privateKey,
}
config := lego.NewConfig(myUser)
config.CADirURL = "https://acme.zerossl.com/v2/DV90"
config.Certificate.KeyType = certcrypto.RSA2048
client, err := lego.NewClient(config)
if err != nil {
t.Fatal(err)
}
err = client.Challenge.SetDNS01Provider(&MyProvider{t: t})
if err != nil {
t.Fatal(err)
}
// New users will need to register
var reg *registration.Resource
if client.GetExternalAccountRequired() {
reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: "KID",
HmacEncoded: "HAMC KEY",
})
} else {
reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
}
if err != nil {
t.Fatal(err)
}
myUser.Registration = reg
request := certificate.ObtainRequest{
Domains: []string{"teaos.cn"},
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
t.Fatal(err)
}
t.Log(certificates)
}

View File

@@ -0,0 +1,3 @@
package acme
type AuthCallback func(domain, token, keyAuth string)

View File

@@ -0,0 +1,88 @@
package acme
import (
"fmt"
"github.com/TeaOSLab/EdgeAPI/internal/dnsclients"
"github.com/TeaOSLab/EdgeAPI/internal/dnsclients/dnstypes"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/iwind/TeaGo/lists"
"os"
"strings"
"sync"
"time"
)
type DNSProvider struct {
raw dnsclients.ProviderInterface
dnsDomain string
locker sync.Mutex
deletedRecordNames []string
}
func NewDNSProvider(raw dnsclients.ProviderInterface, dnsDomain string) *DNSProvider {
return &DNSProvider{
raw: raw,
dnsDomain: dnsDomain,
}
}
func (this *DNSProvider) Present(domain, token, keyAuth string) error {
_ = os.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
var info = dns01.GetChallengeInfo(domain, keyAuth)
var fqdn = info.EffectiveFQDN
var value = info.Value
// 设置记录
var index = strings.Index(fqdn, "."+this.dnsDomain)
if index < 0 {
return errors.New("invalid fqdn value")
}
var recordName = fqdn[:index]
// 先删除老的
this.locker.Lock()
var wasDeleted = lists.ContainsString(this.deletedRecordNames, recordName)
this.locker.Unlock()
if !wasDeleted {
records, err := this.raw.QueryRecords(this.dnsDomain, recordName, dnstypes.RecordTypeTXT)
if err != nil {
return fmt.Errorf("query DNS record failed: %w", err)
}
for _, record := range records {
err = this.raw.DeleteRecord(this.dnsDomain, record)
if err != nil {
return err
}
}
this.locker.Lock()
this.deletedRecordNames = append(this.deletedRecordNames, recordName)
this.locker.Unlock()
}
// 添加新的
err := this.raw.AddRecord(this.dnsDomain, &dnstypes.Record{
Id: "",
Name: recordName,
Type: dnstypes.RecordTypeTXT,
Value: value,
Route: this.raw.DefaultRoute(),
TTL: this.raw.MinTTL(),
})
if err != nil {
return fmt.Errorf("create DNS record failed: %w", err)
}
return nil
}
func (this *DNSProvider) Timeout() (timeout, interval time.Duration) {
return 2 * time.Minute, 2 * time.Second
}
func (this *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}

View File

@@ -0,0 +1,23 @@
package acme
type HTTPProvider struct {
onAuth AuthCallback
}
func NewHTTPProvider(onAuth AuthCallback) *HTTPProvider {
return &HTTPProvider{
onAuth: onAuth,
}
}
func (this *HTTPProvider) Present(domain, token, keyAuth string) error {
if this.onAuth != nil {
this.onAuth(domain, token, keyAuth)
}
//http01.ChallengePath()
return nil
}
func (this *HTTPProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}

View File

@@ -0,0 +1,15 @@
package acme
import (
"crypto/x509"
"encoding/base64"
)
func ParsePrivateKeyFromBase64(base64String string) (interface{}, error) {
data, err := base64.StdEncoding.DecodeString(base64String)
if err != nil {
return nil, err
}
return x509.ParsePKCS8PrivateKey(data)
}

View File

@@ -0,0 +1,24 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
package acme
const DefaultProviderCode = "letsencrypt"
type Provider struct {
Name string `json:"name"`
Code string `json:"code"`
Description string `json:"description"`
APIURL string `json:"apiURL"`
TestAPIURL string `json:"testAPIURL"`
RequireEAB bool `json:"requireEAB"`
EABDescription string `json:"eabDescription"`
}
func FindProviderWithCode(code string) *Provider {
for _, provider := range FindAllProviders() {
if provider.Code == code {
return provider
}
}
return nil
}

View File

@@ -0,0 +1,24 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build !plus
package acme
func FindAllProviders() []*Provider {
return []*Provider{
{
Name: "Let's Encrypt",
Code: DefaultProviderCode,
Description: "非盈利组织Let's Encrypt提供的免费证书。",
APIURL: "https://acme-v02.api.letsencrypt.org/directory",
RequireEAB: false,
},
{
Name: "ZeroSSL",
Code: "zerossl",
Description: "相关文档 <a href=\"https://zerossl.com/documentation/acme/\" target=\"_blank\">https://zerossl.com/documentation/acme/</a>。",
APIURL: "https://acme.zerossl.com/v2/DV90",
RequireEAB: true,
EABDescription: "在官网<a href=\"https://app.zerossl.com/developer\" target=\"_blank\">[Developer]</a>页面底部点击\"Generate\"按钮生成。",
},
}
}

View File

@@ -0,0 +1,67 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package acme
import teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
func FindAllProviders() []*Provider {
var providers = []*Provider{
{
Name: "Let's Encrypt",
Code: DefaultProviderCode,
Description: "非盈利组织Let's Encrypt提供的免费证书。",
APIURL: "https://acme-v02.api.letsencrypt.org/directory",
RequireEAB: false,
},
}
// 商业版
if teaconst.IsPlus {
providers = append(providers, []*Provider{
{
Name: "Buypass",
Code: "buypass",
Description: "Buypass提供的免费证书。",
APIURL: "https://api.buypass.com/acme/directory",
TestAPIURL: "https://api.test4.buypass.no/acme/directory",
RequireEAB: false,
},
}...)
}
providers = append(providers, []*Provider{
{
Name: "ZeroSSL",
Code: "zerossl",
Description: "官方相关文档 <a href=\"https://zerossl.com/documentation/acme/\" target=\"_blank\">https://zerossl.com/documentation/acme/</a>。",
APIURL: "https://acme.zerossl.com/v2/DV90",
RequireEAB: true,
EABDescription: "在官网<a href=\"https://app.zerossl.com/developer\" target=\"_blank\">[Developer]</a>页面底部点击\"Generate\"按钮生成。",
},
}...)
// 商业版
if teaconst.IsPlus {
providers = append(providers, []*Provider{
{
Name: "SSL.com",
Code: "sslcom",
Description: "官方相关文档 <a href=\"https://www.ssl.com/guide/ssl-tls-certificate-issuance-and-revocation-with-acme/\" target=\"_blank\">https://www.ssl.com/guide/ssl-tls-certificate-issuance-and-revocation-with-acme/</a>。",
APIURL: "https://acme.ssl.com/sslcom-dv-rsa",
RequireEAB: true,
EABDescription: "登录SSL.com后点击Dashboard中的api credentials链接可以查看和创建密钥EAB Kid对应界面中的Account/ACME KeyEAB HMAC Key对应界面中的HMAC Key。",
},
{
Name: "Google Cloud",
Code: "googleCloud",
Description: "官方相关文档 <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>",
APIURL: "https://dv.acme-v02.api.pki.goog/directory",
RequireEAB: true,
EABDescription: "请根据Google Cloud官方文档运行 <code-label>gcloud publicca external-account-keys create</code-label> 获得Kid对应keyId和Key对应b64MacKey。",
},
}...)
}
return providers
}

View File

@@ -0,0 +1,215 @@
package acme
import (
"fmt"
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
acmelog "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/registration"
"github.com/iwind/TeaGo/Tea"
"io"
"log"
)
type Request struct {
debug bool
task *Task
onAuth AuthCallback
}
func NewRequest(task *Task) *Request {
return &Request{
task: task,
}
}
func (this *Request) Debug() {
this.debug = true
}
func (this *Request) OnAuth(onAuth AuthCallback) {
this.onAuth = onAuth
}
func (this *Request) Run() (certData []byte, keyData []byte, err error) {
if this.task.Provider == nil {
err = errors.New("provider should not be nil")
return
}
if this.task.Provider.RequireEAB && this.task.Account == nil {
err = errors.New("account should not be nil when provider require EAB")
return
}
switch this.task.AuthType {
case AuthTypeDNS:
return this.runDNS()
case AuthTypeHTTP:
return this.runHTTP()
default:
err = errors.New("invalid task type '" + this.task.AuthType + "'")
return
}
}
func (this *Request) runDNS() (certData []byte, keyData []byte, err error) {
if !this.debug {
if !Tea.IsTesting() {
acmelog.Logger = log.New(io.Discard, "", log.LstdFlags)
}
}
if this.task.User == nil {
err = errors.New("'user' must not be nil")
return
}
if this.task.DNSProvider == nil {
err = errors.New("'dnsProvider' must not be nil")
return
}
if len(this.task.DNSDomain) == 0 {
err = errors.New("'dnsDomain' must not be empty")
return
}
if len(this.task.Domains) == 0 {
err = errors.New("'domains' must not be empty")
return
}
var config = lego.NewConfig(this.task.User)
config.Certificate.KeyType = certcrypto.RSA2048
config.CADirURL = this.task.Provider.APIURL
config.UserAgent = teaconst.ProductName + "/" + teaconst.Version
client, err := lego.NewClient(config)
if err != nil {
return nil, nil, err
}
// 注册用户
var resource = this.task.User.GetRegistration()
if resource != nil {
_, err = client.Registration.QueryRegistration()
if err != nil {
return nil, nil, err
}
} else {
if this.task.Provider.RequireEAB {
resource, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: this.task.Account.EABKid,
HmacEncoded: this.task.Account.EABKey,
})
if err != nil {
return nil, nil, fmt.Errorf("register user failed: %w", err)
}
err = this.task.User.Register(resource)
if err != nil {
return nil, nil, err
}
} else {
resource, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, nil, err
}
err = this.task.User.Register(resource)
if err != nil {
return nil, nil, err
}
}
}
err = client.Challenge.SetDNS01Provider(NewDNSProvider(this.task.DNSProvider, this.task.DNSDomain))
if err != nil {
return nil, nil, err
}
// 申请证书
var request = certificate.ObtainRequest{
Domains: this.task.Domains,
Bundle: true,
}
certResource, err := client.Certificate.Obtain(request)
if err != nil {
return nil, nil, fmt.Errorf("obtain cert failed: %w", err)
}
return certResource.Certificate, certResource.PrivateKey, nil
}
func (this *Request) runHTTP() (certData []byte, keyData []byte, err error) {
if !this.debug {
if !Tea.IsTesting() {
acmelog.Logger = log.New(io.Discard, "", log.LstdFlags)
}
}
if this.task.User == nil {
err = errors.New("'user' must not be nil")
return
}
var config = lego.NewConfig(this.task.User)
config.Certificate.KeyType = certcrypto.RSA2048
config.CADirURL = this.task.Provider.APIURL
config.UserAgent = teaconst.ProductName + "/" + teaconst.Version
client, err := lego.NewClient(config)
if err != nil {
return nil, nil, err
}
// 注册用户
var resource = this.task.User.GetRegistration()
if resource != nil {
_, err = client.Registration.QueryRegistration()
if err != nil {
return nil, nil, err
}
} else {
if this.task.Provider.RequireEAB {
resource, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: this.task.Account.EABKid,
HmacEncoded: this.task.Account.EABKey,
})
if err != nil {
return nil, nil, fmt.Errorf("register user failed: %w", err)
}
err = this.task.User.Register(resource)
if err != nil {
return nil, nil, err
}
} else {
resource, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, nil, err
}
err = this.task.User.Register(resource)
if err != nil {
return nil, nil, err
}
}
}
err = client.Challenge.SetHTTP01Provider(NewHTTPProvider(this.onAuth))
if err != nil {
return nil, nil, err
}
// 申请证书
var request = certificate.ObtainRequest{
Domains: this.task.Domains,
Bundle: true,
}
certResource, err := client.Certificate.Obtain(request)
if err != nil {
return nil, nil, err
}
return certResource.Certificate, certResource.PrivateKey, nil
}

View File

@@ -0,0 +1,109 @@
package acme
import (
"encoding/json"
"github.com/TeaOSLab/EdgeAPI/internal/dnsclients"
"github.com/go-acme/lego/v4/registration"
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/maps"
"testing"
)
func TestRequest_Run_DNS(t *testing.T) {
privateKey, err := ParsePrivateKeyFromBase64("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD3xxDXP4YVqHCfub21Yi3QL1Kvgow23J8CKJ7vU3L4+hRANCAARRl5ZKAlgGRc5RETSMYFCTXvjnePDgjALWgtgfClQGLB2rGyRecJvlesAM6Q7LQrDxVxvxdSQQmPGRqJGiBtjd")
if err != nil {
t.Fatal(err)
}
user := NewUser("19644627@qq.com", privateKey, func(resource *registration.Resource) error {
resourceJSON, err := json.Marshal(resource)
if err != nil {
return err
}
t.Log(string(resourceJSON))
return nil
})
regResource := []byte(`{"body":{"status":"valid","contact":["mailto:19644627@qq.com"]},"uri":"https://acme-v02.api.letsencrypt.org/acme/acct/103672877"}`)
err = user.SetRegistration(regResource)
if err != nil {
t.Fatal(err)
}
dnsProvider, err := testDNSPodProvider()
if err != nil {
t.Fatal(err)
}
req := NewRequest(&Task{
User: user,
AuthType: AuthTypeDNS,
DNSProvider: dnsProvider,
DNSDomain: "yun4s.cn",
Domains: []string{"www.yun4s.cn"},
})
certData, keyData, err := req.Run()
if err != nil {
t.Fatal(err)
}
t.Log("cert:", string(certData))
t.Log("key:", string(keyData))
}
func TestRequest_Run_HTTP(t *testing.T) {
privateKey, err := ParsePrivateKeyFromBase64("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD3xxDXP4YVqHCfub21Yi3QL1Kvgow23J8CKJ7vU3L4+hRANCAARRl5ZKAlgGRc5RETSMYFCTXvjnePDgjALWgtgfClQGLB2rGyRecJvlesAM6Q7LQrDxVxvxdSQQmPGRqJGiBtjd")
if err != nil {
t.Fatal(err)
}
user := NewUser("19644627@qq.com", privateKey, func(resource *registration.Resource) error {
resourceJSON, err := json.Marshal(resource)
if err != nil {
return err
}
t.Log(string(resourceJSON))
return nil
})
regResource := []byte(`{"body":{"status":"valid","contact":["mailto:19644627@qq.com"]},"uri":"https://acme-v02.api.letsencrypt.org/acme/acct/103672877"}`)
err = user.SetRegistration(regResource)
if err != nil {
t.Fatal(err)
}
req := NewRequest(&Task{
User: user,
AuthType: AuthTypeHTTP,
Domains: []string{"teaos.cn", "www.teaos.cn", "meloy.cn"},
})
certData, keyData, err := req.runHTTP()
if err != nil {
t.Fatal(err)
}
t.Log(string(certData))
t.Log(string(keyData))
}
func testDNSPodProvider() (dnsclients.ProviderInterface, error) {
db, err := dbs.Default()
if err != nil {
return nil, err
}
one, err := db.FindOne("SELECT * FROM edgeDNSProviders WHERE type='dnspod' ORDER BY id DESC")
if err != nil {
return nil, err
}
apiParams := maps.Map{}
err = json.Unmarshal([]byte(one.GetString("apiParams")), &apiParams)
if err != nil {
return nil, err
}
provider := &dnsclients.DNSPodProvider{}
err = provider.Auth(apiParams)
if err != nil {
return nil, err
}
return provider, nil
}

View File

@@ -0,0 +1,22 @@
package acme
import "github.com/TeaOSLab/EdgeAPI/internal/dnsclients"
type AuthType = string
const (
AuthTypeDNS AuthType = "dns"
AuthTypeHTTP AuthType = "http"
)
type Task struct {
Provider *Provider
Account *Account
User *User
AuthType AuthType
Domains []string
// DNS相关
DNSProvider dnsclients.ProviderInterface
DNSDomain string
}

View File

@@ -0,0 +1,49 @@
package acme
import (
"crypto"
"encoding/json"
"github.com/go-acme/lego/v4/registration"
)
type User struct {
email string
resource *registration.Resource
key crypto.PrivateKey
registerFunc func(resource *registration.Resource) error
}
func NewUser(email string, key crypto.PrivateKey, registerFunc func(resource *registration.Resource) error) *User {
return &User{
email: email,
key: key,
registerFunc: registerFunc,
}
}
func (this *User) GetEmail() string {
return this.email
}
func (this *User) GetRegistration() *registration.Resource {
return this.resource
}
func (this *User) SetRegistration(resourceData []byte) error {
resource := &registration.Resource{}
err := json.Unmarshal(resourceData, resource)
if err != nil {
return err
}
this.resource = resource
return nil
}
func (this *User) GetPrivateKey() crypto.PrivateKey {
return this.key
}
func (this *User) Register(resource *registration.Resource) error {
this.resource = resource
return this.registerFunc(resource)
}

View File

@@ -0,0 +1,300 @@
package apps
import (
"errors"
"fmt"
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/iwind/TeaGo/logs"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/types"
"github.com/iwind/gosock/pkg/gosock"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// AppCmd App命令帮助
type AppCmd struct {
product string
version string
usage string
options []*CommandHelpOption
appendStrings []string
directives []*Directive
sock *gosock.Sock
}
func NewAppCmd() *AppCmd {
return &AppCmd{
sock: gosock.NewTmpSock(teaconst.ProcessName),
}
}
type CommandHelpOption struct {
Code string
Description string
}
// Product 产品
func (this *AppCmd) Product(product string) *AppCmd {
this.product = product
return this
}
// Version 版本
func (this *AppCmd) Version(version string) *AppCmd {
this.version = version
return this
}
// Usage 使用方法
func (this *AppCmd) Usage(usage string) *AppCmd {
this.usage = usage
return this
}
// Option 选项
func (this *AppCmd) Option(code string, description string) *AppCmd {
this.options = append(this.options, &CommandHelpOption{
Code: code,
Description: description,
})
return this
}
// Append 附加内容
func (this *AppCmd) Append(appendString string) *AppCmd {
this.appendStrings = append(this.appendStrings, appendString)
return this
}
// Print 打印
func (this *AppCmd) Print() {
fmt.Println(this.product + " v" + this.version)
usage := this.usage
fmt.Println("Usage:", "\n "+usage)
if len(this.options) > 0 {
fmt.Println("")
fmt.Println("Options:")
spaces := 20
max := 40
for _, option := range this.options {
l := len(option.Code)
if l < max && l > spaces {
spaces = l + 4
}
}
for _, option := range this.options {
if len(option.Code) > max {
fmt.Println("")
fmt.Println(" " + option.Code)
option.Code = ""
}
fmt.Printf(" %-"+strconv.Itoa(spaces)+"s%s\n", option.Code, ": "+option.Description)
}
}
if len(this.appendStrings) > 0 {
fmt.Println("")
for _, s := range this.appendStrings {
fmt.Println(s)
}
}
}
// On 添加指令
func (this *AppCmd) On(arg string, callback func()) {
this.directives = append(this.directives, &Directive{
Arg: arg,
Callback: callback,
})
}
// Run 运行
func (this *AppCmd) Run(main func()) {
// 获取参数
args := os.Args[1:]
if len(args) > 0 {
switch args[0] {
case "-v", "version", "-version", "--version":
this.runVersion()
return
case "?", "help", "-help", "h", "-h":
this.runHelp()
return
case "start":
this.runStart()
return
case "stop":
this.runStop()
return
case "restart":
this.runRestart()
return
case "status":
this.runStatus()
return
}
// 查找指令
for _, directive := range this.directives {
if directive.Arg == args[0] {
directive.Callback()
return
}
}
fmt.Println("unknown command '" + args[0] + "'")
return
}
// 日志
writer := new(LogWriter)
writer.Init()
logs.SetWriter(writer)
// 运行主函数
main()
}
// 版本号
func (this *AppCmd) runVersion() {
fmt.Println(this.product+" v"+this.version, "(build: "+runtime.Version(), runtime.GOOS, runtime.GOARCH, teaconst.Tag+")")
}
// 帮助
func (this *AppCmd) runHelp() {
this.Print()
}
// 启动
func (this *AppCmd) runStart() {
var pid = this.getPID()
if pid > 0 {
fmt.Println(this.product+" already started, pid:", pid)
return
}
var cmd = exec.Command(this.exe())
err := cmd.Start()
if err != nil {
fmt.Println(this.product+" start failed:", err.Error())
return
}
// create symbolic links
_ = this.createSymLinks()
fmt.Println(this.product+" started ok, pid:", cmd.Process.Pid)
}
// 停止
func (this *AppCmd) runStop() {
var pid = this.getPID()
if pid == 0 {
fmt.Println(this.product + " not started yet")
return
}
_, _ = this.sock.Send(&gosock.Command{Code: "stop"})
fmt.Println(this.product+" stopped ok, pid:", types.String(pid))
}
// 重启
func (this *AppCmd) runRestart() {
this.runStop()
time.Sleep(1 * time.Second)
this.runStart()
}
// 状态
func (this *AppCmd) runStatus() {
var pid = this.getPID()
if pid == 0 {
fmt.Println(this.product + " not started yet")
return
}
fmt.Println(this.product + " is running, pid: " + types.String(pid))
}
// 获取当前的PID
func (this *AppCmd) getPID() int {
if !this.sock.IsListening() {
return 0
}
reply, err := this.sock.Send(&gosock.Command{Code: "pid"})
if err != nil {
return 0
}
return maps.NewMap(reply.Params).GetInt("pid")
}
func (this *AppCmd) exe() string {
var exe, _ = os.Executable()
if len(exe) == 0 {
exe = os.Args[0]
}
return exe
}
// 创建软链接
func (this *AppCmd) createSymLinks() error {
if runtime.GOOS != "linux" {
return nil
}
var exe, _ = os.Executable()
if len(exe) == 0 {
return nil
}
var errorList = []string{}
// bin
{
var target = "/usr/bin/" + teaconst.ProcessName
old, _ := filepath.EvalSymlinks(target)
if old != exe {
_ = os.Remove(target)
err := os.Symlink(exe, target)
if err != nil {
errorList = append(errorList, err.Error())
}
}
}
// log
{
var realPath = filepath.Dir(filepath.Dir(exe)) + "/logs/run.log"
var target = "/var/log/" + teaconst.ProcessName + ".log"
old, _ := filepath.EvalSymlinks(target)
if old != realPath {
_ = os.Remove(target)
err := os.Symlink(realPath, target)
if err != nil {
errorList = append(errorList, err.Error())
}
}
}
if len(errorList) > 0 {
return errors.New(strings.Join(errorList, "\n"))
}
return nil
}

View File

@@ -0,0 +1,6 @@
package apps
type Directive struct {
Arg string
Callback func()
}

View File

@@ -0,0 +1,108 @@
package apps
import (
"github.com/TeaOSLab/EdgeAPI/internal/goman"
"github.com/TeaOSLab/EdgeAPI/internal/utils/sizes"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/files"
timeutil "github.com/iwind/TeaGo/utils/time"
"log"
"os"
"runtime"
"strconv"
"strings"
)
type LogWriter struct {
fp *os.File
c chan string
}
func (this *LogWriter) Init() {
// 创建目录
var dir = files.NewFile(Tea.LogDir())
if !dir.Exists() {
err := dir.Mkdir()
if err != nil {
log.Println("[LOG]create log dir failed: " + err.Error())
}
}
// 打开要写入的日志文件
var logPath = Tea.LogFile("run.log")
fp, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Println("[LOG]open log file failed: " + err.Error())
} else {
this.fp = fp
}
this.c = make(chan string, 1024)
// 异步写入文件
var maxFileSize = 2 * sizes.G // 文件最大尺寸,超出此尺寸则清空
if fp != nil {
goman.New(func() {
var totalSize int64 = 0
stat, err := fp.Stat()
if err == nil {
totalSize = stat.Size()
}
for message := range this.c {
totalSize += int64(len(message))
_, err := fp.WriteString(timeutil.Format("Y/m/d H:i:s ") + message + "\n")
if err != nil {
log.Println("[LOG]write log failed: " + err.Error())
} else {
// 如果太大则Truncate
if totalSize > maxFileSize {
_ = fp.Truncate(0)
totalSize = 0
}
}
}
})
}
}
func (this *LogWriter) Write(message string) {
backgroundEnv, _ := os.LookupEnv("EdgeBackground")
if backgroundEnv != "on" {
// 文件和行号
var file string
var line int
if Tea.IsTesting() {
var callDepth = 3
var ok bool
_, file, line, ok = runtime.Caller(callDepth)
if ok {
file = this.packagePath(file)
}
}
if len(file) > 0 {
log.Println(message + " (" + file + ":" + strconv.Itoa(line) + ")")
} else {
log.Println(message)
}
}
this.c <- message
}
func (this *LogWriter) Close() {
if this.fp != nil {
_ = this.fp.Close()
}
close(this.c)
}
func (this *LogWriter) packagePath(path string) string {
var pieces = strings.Split(path, "/")
if len(pieces) >= 2 {
return strings.Join(pieces[len(pieces)-2:], "/")
}
return path
}

View File

@@ -0,0 +1,147 @@
package clickhouse
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client 通过 HTTP 接口执行只读查询SELECT返回 JSONEachRow 解析为 map 或结构体
type Client struct {
cfg *Config
httpCli *http.Client
}
// NewClient 使用共享配置创建客户端
func NewClient() *Client {
cfg := SharedConfig()
transport := &http.Transport{}
if cfg != nil && strings.EqualFold(cfg.Scheme, "https") {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: cfg.TLSSkipVerify,
ServerName: cfg.TLSServerName,
}
}
return &Client{
cfg: cfg,
httpCli: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
}
}
// IsConfigured 是否已配置
func (c *Client) IsConfigured() bool {
return c.cfg != nil && c.cfg.IsConfigured()
}
// Query 执行 SELECT将每行 JSON 解析到 dest 切片dest 元素类型需为 *struct 或 map
func (c *Client) Query(ctx context.Context, query string, dest interface{}) error {
if !c.IsConfigured() {
return fmt.Errorf("clickhouse: not configured")
}
// 强制 JSONEachRow 便于解析
q := strings.TrimSpace(query)
if !strings.HasSuffix(strings.ToUpper(q), "FORMAT JSONEACHROW") {
query = q + " FORMAT JSONEachRow"
}
u := c.buildURL(query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
if c.cfg.User != "" || c.cfg.Password != "" {
req.SetBasicAuth(c.cfg.User, c.cfg.Password)
}
resp, err := c.httpCli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body))
}
dec := json.NewDecoder(resp.Body)
return decodeRows(dec, dest)
}
// QueryRow 执行仅返回一行的查询,将结果解析到 dest*struct 或 *map
func (c *Client) QueryRow(ctx context.Context, query string, dest interface{}) error {
if !c.IsConfigured() {
return fmt.Errorf("clickhouse: not configured")
}
q := strings.TrimSpace(query)
if !strings.HasSuffix(strings.ToUpper(q), "FORMAT JSONEACHROW") {
query = q + " FORMAT JSONEachRow"
}
u := c.buildURL(query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
if c.cfg.User != "" || c.cfg.Password != "" {
req.SetBasicAuth(c.cfg.User, c.cfg.Password)
}
resp, err := c.httpCli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body))
}
dec := json.NewDecoder(resp.Body)
return decodeOneRow(dec, dest)
}
func (c *Client) buildURL(query string) string {
scheme := "http"
if c.cfg != nil && strings.EqualFold(c.cfg.Scheme, "https") {
scheme = "https"
}
rawURL := fmt.Sprintf("%s://%s:%d/?query=%s&database=%s",
scheme, c.cfg.Host, c.cfg.Port, url.QueryEscape(query), url.QueryEscape(c.cfg.Database))
return rawURL
}
// decodeRows 将 JSONEachRow 流解析到 slice元素类型须为 *struct 或 *[]map[string]interface{}
func decodeRows(dec *json.Decoder, dest interface{}) error {
// dest 应为 *[]*SomeStruct 或 *[]map[string]interface{}
switch d := dest.(type) {
case *[]map[string]interface{}:
*d = (*d)[:0]
for {
var row map[string]interface{}
if err := dec.Decode(&row); err != nil {
if err == io.EOF {
return nil
}
return err
}
*d = append(*d, row)
}
default:
return fmt.Errorf("clickhouse: unsupported dest type for Query (use *[]map[string]interface{} or implement decoder)")
}
}
func decodeOneRow(dec *json.Decoder, dest interface{}) error {
switch d := dest.(type) {
case *map[string]interface{}:
if err := dec.Decode(d); err != nil {
return err
}
return nil
default:
return fmt.Errorf("clickhouse: unsupported dest type for QueryRow (use *map[string]interface{})")
}
}

View File

@@ -0,0 +1,134 @@
// Package clickhouse 提供 ClickHouse 只读客户端,用于查询 logs_ingestFluent Bit 写入)。
// 配置优先从后台页面edgeSysSettings.clickhouseConfig读取其次 api.yaml最后环境变量。
package clickhouse
import (
"github.com/TeaOSLab/EdgeAPI/internal/configs"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"os"
"strconv"
"strings"
"sync"
)
const (
envHost = "CLICKHOUSE_HOST"
envPort = "CLICKHOUSE_PORT"
envUser = "CLICKHOUSE_USER"
envPassword = "CLICKHOUSE_PASSWORD"
envDatabase = "CLICKHOUSE_DATABASE"
envScheme = "CLICKHOUSE_SCHEME"
defaultPort = 8443
defaultDB = "default"
defaultScheme = "https"
)
var (
sharedConfig *Config
configOnce sync.Once
configLocker sync.Mutex
)
// Config ClickHouse 连接配置(仅查询,不从代码写库)
type Config struct {
Host string
Port int
User string
Password string
Database string
Scheme string
TLSSkipVerify bool
TLSServerName string
}
// SharedConfig 返回全局配置(优先从后台 DB 读取,其次 api.yaml最后环境变量
func SharedConfig() *Config {
configLocker.Lock()
defer configLocker.Unlock()
if sharedConfig != nil {
return sharedConfig
}
sharedConfig = loadConfig()
return sharedConfig
}
// ResetSharedConfig 清空缓存,下次 SharedConfig() 时重新从 DB/文件/环境变量加载(后台保存 ClickHouse 配置后调用)
func ResetSharedConfig() {
configLocker.Lock()
defer configLocker.Unlock()
sharedConfig = nil
}
func loadConfig() *Config {
cfg := &Config{Port: defaultPort, Database: defaultDB, Scheme: defaultScheme, TLSSkipVerify: true}
// 1) 优先从后台页面配置DB读取
if models.SharedSysSettingDAO != nil {
if dbCfg, err := models.SharedSysSettingDAO.ReadClickHouseConfig(nil); err == nil && dbCfg != nil && dbCfg.Host != "" {
cfg.Host = dbCfg.Host
cfg.Port = dbCfg.Port
cfg.User = dbCfg.User
cfg.Password = dbCfg.Password
cfg.Database = dbCfg.Database
cfg.Scheme = normalizeScheme(dbCfg.Scheme)
cfg.TLSSkipVerify = true
cfg.TLSServerName = ""
if cfg.Port <= 0 {
cfg.Port = defaultPort
}
if cfg.Database == "" {
cfg.Database = defaultDB
}
return cfg
}
}
// 2) 其次 api.yaml
apiConfig, err := configs.SharedAPIConfig()
if err == nil && apiConfig != nil && apiConfig.ClickHouse != nil && apiConfig.ClickHouse.Host != "" {
ch := apiConfig.ClickHouse
cfg.Host = ch.Host
cfg.Port = ch.Port
cfg.User = ch.User
cfg.Password = ch.Password
cfg.Database = ch.Database
cfg.Scheme = normalizeScheme(ch.Scheme)
cfg.TLSSkipVerify = true
cfg.TLSServerName = ""
if cfg.Port <= 0 {
cfg.Port = defaultPort
}
if cfg.Database == "" {
cfg.Database = defaultDB
}
return cfg
}
// 3) 最后环境变量
cfg.Host = os.Getenv(envHost)
cfg.User = os.Getenv(envUser)
cfg.Password = os.Getenv(envPassword)
cfg.Database = os.Getenv(envDatabase)
if cfg.Database == "" {
cfg.Database = defaultDB
}
cfg.Scheme = normalizeScheme(os.Getenv(envScheme))
cfg.TLSServerName = ""
if p := os.Getenv(envPort); p != "" {
if v, err := strconv.Atoi(p); err == nil {
cfg.Port = v
}
}
cfg.TLSSkipVerify = true
return cfg
}
func normalizeScheme(scheme string) string {
s := strings.ToLower(strings.TrimSpace(scheme))
if s == "https" {
return "https"
}
return defaultScheme
}
// IsConfigured 是否已配置Host 非空即视为启用 ClickHouse 查询)
func (c *Config) IsConfigured() bool {
return c != nil && c.Host != ""
}

View File

@@ -0,0 +1,413 @@
// Package clickhouse 提供 logs_ingest 表的只读查询(列表分页),用于访问日志列表优先走 ClickHouse。
package clickhouse
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
)
// LogsIngestRow 对应 ClickHouse logs_ingest 表的一行(用于 List 结果与 RowToPB
type LogsIngestRow struct {
Timestamp time.Time
NodeId uint64
ClusterId uint64
ServerId uint64
Host string
IP string
Method string
Path string
Status uint16
BytesIn uint64
BytesOut uint64
CostMs uint32
UA string
Referer string
LogType string
TraceId string
FirewallPolicyId uint64
FirewallRuleGroupId uint64
FirewallRuleSetId uint64
FirewallRuleId uint64
RequestHeaders string
RequestBody string
ResponseHeaders string
ResponseBody string
}
// ListFilter 列表查询条件(与 ListHTTPAccessLogsRequest 对齐)
type ListFilter struct {
Day string
HourFrom string
HourTo string
Size int64
Reverse bool
HasError bool
HasFirewallPolicy bool
FirewallPolicyId int64
NodeId int64
ClusterId int64
LastRequestId string
ServerIds []int64
NodeIds []int64
// 搜索条件
Keyword string
Ip string
Domain string
}
// LogsIngestStore 封装对 logs_ingest 的只读列表查询
type LogsIngestStore struct {
client *Client
}
// NewLogsIngestStore 创建 store内部使用共享 Client
func NewLogsIngestStore() *LogsIngestStore {
return &LogsIngestStore{client: NewClient()}
}
// Client 返回底层 Client供调用方判断 IsConfigured()
func (s *LogsIngestStore) Client() *Client {
return s.client
}
// List 按条件分页查询 logs_ingest返回行、下一页游标trace_id与错误
func (s *LogsIngestStore) List(ctx context.Context, f ListFilter) (rows []*LogsIngestRow, nextCursor string, err error) {
if !s.client.IsConfigured() {
return nil, "", fmt.Errorf("clickhouse: not configured")
}
if f.Day == "" {
return nil, "", fmt.Errorf("clickhouse: day required")
}
dayNumber, err := normalizeDayNumber(f.Day)
if err != nil {
return nil, "", err
}
table := "logs_ingest"
if s.client.cfg.Database != "" && s.client.cfg.Database != "default" {
table = quoteIdent(s.client.cfg.Database) + "." + quoteIdent("logs_ingest")
} else {
table = quoteIdent(table)
}
conditions := []string{"toYYYYMMDD(timestamp) = " + strconv.Itoa(dayNumber)}
if f.HourFrom != "" {
if _, err := strconv.Atoi(f.HourFrom); err == nil {
conditions = append(conditions, "toHour(timestamp) >= "+f.HourFrom)
}
}
if f.HourTo != "" {
if _, err := strconv.Atoi(f.HourTo); err == nil {
conditions = append(conditions, "toHour(timestamp) <= "+f.HourTo)
}
}
if len(f.ServerIds) > 0 {
parts := make([]string, 0, len(f.ServerIds))
for _, id := range f.ServerIds {
parts = append(parts, strconv.FormatInt(id, 10))
}
conditions = append(conditions, "server_id IN ("+strings.Join(parts, ",")+")")
}
if len(f.NodeIds) > 0 {
parts := make([]string, 0, len(f.NodeIds))
for _, id := range f.NodeIds {
parts = append(parts, strconv.FormatInt(id, 10))
}
conditions = append(conditions, "node_id IN ("+strings.Join(parts, ",")+")")
}
if f.NodeId > 0 {
conditions = append(conditions, "node_id = "+strconv.FormatInt(f.NodeId, 10))
}
if f.ClusterId > 0 {
conditions = append(conditions, "cluster_id = "+strconv.FormatInt(f.ClusterId, 10))
}
if f.HasFirewallPolicy {
conditions = append(conditions, "firewall_policy_id > 0")
}
if f.HasError {
conditions = append(conditions, "status >= 400")
}
if f.FirewallPolicyId > 0 {
conditions = append(conditions, "firewall_policy_id = "+strconv.FormatInt(f.FirewallPolicyId, 10))
}
// 搜索条件
if f.Keyword != "" {
keyword := escapeString(f.Keyword)
// 在 host, path, ip, ua 中模糊搜索
conditions = append(conditions, fmt.Sprintf("(host LIKE '%%%s%%' OR path LIKE '%%%s%%' OR ip LIKE '%%%s%%' OR ua LIKE '%%%s%%')", keyword, keyword, keyword, keyword))
}
if f.Ip != "" {
conditions = append(conditions, "ip = '"+escapeString(f.Ip)+"'")
}
if f.Domain != "" {
conditions = append(conditions, "host LIKE '%"+escapeString(f.Domain)+"%'")
}
// 游标分页:使用 trace_id 作为游标
// Reverse=false历史向后翻页查询更早的数据
// Reverse=true实时增量拉新查询更新的数据
if f.LastRequestId != "" {
if f.Reverse {
conditions = append(conditions, "trace_id > '"+escapeString(f.LastRequestId)+"'")
} else {
conditions = append(conditions, "trace_id < '"+escapeString(f.LastRequestId)+"'")
}
}
where := strings.Join(conditions, " AND ")
// 默认按时间倒序(最新的在前面),与前端默认行为一致
orderDir := "DESC"
if f.Reverse {
orderDir = "ASC"
}
limit := f.Size
if limit <= 0 {
limit = 20
}
if limit > 1000 {
limit = 1000
}
orderBy := fmt.Sprintf("timestamp %s, trace_id %s", orderDir, orderDir)
// 列表查询不 SELECT 大字段request_headers / request_body / response_headers / response_body
// 避免每次翻页读取 GB 级数据。详情查看时通过 FindByTraceId 单独获取。
query := fmt.Sprintf("SELECT timestamp, node_id, cluster_id, server_id, host, ip, method, path, status, bytes_in, bytes_out, cost_ms, ua, referer, log_type, trace_id, firewall_policy_id, firewall_rule_group_id, firewall_rule_set_id, firewall_rule_id FROM %s WHERE %s ORDER BY %s LIMIT %d",
table, where, orderBy, limit+1)
var rawRows []map[string]interface{}
if err = s.client.Query(ctx, query, &rawRows); err != nil {
return nil, "", err
}
rows = make([]*LogsIngestRow, 0, len(rawRows))
for _, m := range rawRows {
r := mapToLogsIngestRow(m)
if r != nil {
rows = append(rows, r)
}
}
if !f.Reverse {
if len(rows) > int(limit) {
nextCursor = rows[limit].TraceId
rows = rows[:limit]
}
return rows, nextCursor, nil
}
if len(rows) > int(limit) {
rows = rows[:limit]
}
if len(rows) > 0 {
nextCursor = rows[len(rows)-1].TraceId
}
// 实时模式统一返回为“最新在前”,与前端显示和 MySQL 语义一致。
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
rows[left], rows[right] = rows[right], rows[left]
}
return rows, nextCursor, nil
}
// FindByTraceId 按 trace_id 查询单条日志详情
func (s *LogsIngestStore) FindByTraceId(ctx context.Context, traceId string) (*LogsIngestRow, error) {
if !s.client.IsConfigured() {
return nil, fmt.Errorf("clickhouse: not configured")
}
if traceId == "" {
return nil, nil
}
table := quoteIdent("logs_ingest")
query := fmt.Sprintf("SELECT timestamp, node_id, cluster_id, server_id, host, ip, method, path, status, bytes_in, bytes_out, cost_ms, ua, referer, log_type, trace_id, firewall_policy_id, firewall_rule_group_id, firewall_rule_set_id, firewall_rule_id, request_headers, request_body, response_headers, response_body FROM %s WHERE trace_id = '%s' LIMIT 1",
table, escapeString(traceId))
var rawRows []map[string]interface{}
if err := s.client.Query(ctx, query, &rawRows); err != nil {
return nil, err
}
if len(rawRows) == 0 {
return nil, nil
}
return mapToLogsIngestRow(rawRows[0]), nil
}
func quoteIdent(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func escapeString(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func normalizeDayNumber(day string) (int, error) {
normalized := strings.TrimSpace(day)
if normalized == "" {
return 0, fmt.Errorf("clickhouse: day required")
}
normalized = strings.ReplaceAll(normalized, "-", "")
if len(normalized) != 8 {
return 0, fmt.Errorf("clickhouse: invalid day '%s'", day)
}
dayNumber, err := strconv.Atoi(normalized)
if err != nil {
return 0, fmt.Errorf("clickhouse: invalid day '%s'", day)
}
return dayNumber, nil
}
func mapToLogsIngestRow(m map[string]interface{}) *LogsIngestRow {
r := &LogsIngestRow{}
u64 := func(key string) uint64 {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case float64:
return uint64(x)
case string:
n, _ := strconv.ParseUint(x, 10, 64)
return n
case json.Number:
n, _ := x.Int64()
return uint64(n)
}
return 0
}
u32 := func(key string) uint32 {
return uint32(u64(key))
}
str := func(key string) string {
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
ts := func(key string) time.Time {
v, ok := m[key]
if ok && v != nil {
switch x := v.(type) {
case string:
t, _ := time.Parse("2006-01-02 15:04:05", x)
return t
case float64:
return time.Unix(int64(x), 0)
case json.Number:
n, _ := x.Int64()
return time.Unix(n, 0)
}
}
return time.Time{}
}
r.Timestamp = ts("timestamp")
r.NodeId = u64("node_id")
r.ClusterId = u64("cluster_id")
r.ServerId = u64("server_id")
r.Host = str("host")
r.IP = str("ip")
r.Method = str("method")
r.Path = str("path")
r.Status = uint16(u64("status"))
r.BytesIn = u64("bytes_in")
r.BytesOut = u64("bytes_out")
r.CostMs = u32("cost_ms")
r.UA = str("ua")
r.Referer = str("referer")
r.LogType = str("log_type")
r.TraceId = str("trace_id")
r.FirewallPolicyId = u64("firewall_policy_id")
r.FirewallRuleGroupId = u64("firewall_rule_group_id")
r.FirewallRuleSetId = u64("firewall_rule_set_id")
r.FirewallRuleId = u64("firewall_rule_id")
r.RequestHeaders = str("request_headers")
r.RequestBody = str("request_body")
r.ResponseHeaders = str("response_headers")
r.ResponseBody = str("response_body")
return r
}
// RowToPB 将 logs_ingest 一行转为 pb.HTTPAccessLog列表展示用+详情展示)
func RowToPB(r *LogsIngestRow) *pb.HTTPAccessLog {
if r == nil {
return nil
}
a := &pb.HTTPAccessLog{
RequestId: r.TraceId,
ServerId: int64(r.ServerId),
NodeId: int64(r.NodeId),
Timestamp: r.Timestamp.Unix(),
Host: r.Host,
RawRemoteAddr: r.IP,
RemoteAddr: r.IP,
RequestMethod: r.Method,
RequestPath: r.Path,
RequestURI: r.Path, // 前端使用 requestURI 显示完整路径
Scheme: "http", // 默认 http日志中未存储实际值
Proto: "HTTP/1.1", // 默认值,日志中未存储实际值
Status: int32(r.Status),
RequestLength: int64(r.BytesIn),
BytesSent: int64(r.BytesOut),
RequestTime: float64(r.CostMs) / 1000,
UserAgent: r.UA,
Referer: r.Referer,
FirewallPolicyId: int64(r.FirewallPolicyId),
FirewallRuleGroupId: int64(r.FirewallRuleGroupId),
FirewallRuleSetId: int64(r.FirewallRuleSetId),
FirewallRuleId: int64(r.FirewallRuleId),
}
if r.TimeISO8601() != "" {
a.TimeISO8601 = r.TimeISO8601()
}
// TimeLocal: 用户友好的时间格式 (e.g., "2026-02-07 23:17:12")
if !r.Timestamp.IsZero() {
a.TimeLocal = r.Timestamp.Format("2006-01-02 15:04:05")
}
// 解析请求头 (JSON -> map[string]*pb.Strings)
// ClickHouse 中存储的是 map[string]string 格式
if r.RequestHeaders != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(r.RequestHeaders), &headers); err == nil {
a.Header = make(map[string]*pb.Strings)
for k, v := range headers {
a.Header[k] = &pb.Strings{Values: []string{v}}
}
}
}
// 解析响应头 (JSON -> map[string]*pb.Strings)
if r.ResponseHeaders != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(r.ResponseHeaders), &headers); err == nil {
a.SentHeader = make(map[string]*pb.Strings)
for k, v := range headers {
a.SentHeader[k] = &pb.Strings{Values: []string{v}}
}
}
}
// 请求体
if r.RequestBody != "" {
a.RequestBody = []byte(r.RequestBody)
}
return a
}
// TimeISO8601 便于 RowToPB 使用
func (r *LogsIngestRow) TimeISO8601() string {
if r.Timestamp.IsZero() {
return ""
}
return r.Timestamp.UTC().Format("2006-01-02T15:04:05Z07:00")
}

View File

@@ -0,0 +1,377 @@
package clickhouse
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
)
// NSLogsIngestRow 对应 dns_logs_ingest 表一行.
type NSLogsIngestRow struct {
Timestamp time.Time
RequestId string
NodeId uint64
ClusterId uint64
DomainId uint64
RecordId uint64
RemoteAddr string
QuestionName string
QuestionType string
RecordName string
RecordType string
RecordValue string
Networking string
IsRecursive bool
Error string
NSRouteCodes []string
ContentJSON string
}
// NSListFilter DNS 日志查询过滤条件.
type NSListFilter struct {
Day string
Size int64
Reverse bool
LastRequestId string
NSClusterId int64
NSNodeId int64
NSDomainId int64
NSRecordId int64
RecordType string
Keyword string
}
// NSLogsIngestStore DNS ClickHouse 查询封装.
type NSLogsIngestStore struct {
client *Client
}
// NewNSLogsIngestStore 创建 NSLogsIngestStore.
func NewNSLogsIngestStore() *NSLogsIngestStore {
return &NSLogsIngestStore{client: NewClient()}
}
// Client 返回底层 client.
func (s *NSLogsIngestStore) Client() *Client {
return s.client
}
// List 列出 DNS 访问日志,返回列表、下一游标与是否有更多.
func (s *NSLogsIngestStore) List(ctx context.Context, f NSListFilter) (rows []*NSLogsIngestRow, nextCursor string, hasMore bool, err error) {
if !s.client.IsConfigured() {
return nil, "", false, fmt.Errorf("clickhouse: not configured")
}
if f.Day == "" {
return nil, "", false, fmt.Errorf("clickhouse: day required")
}
dayNumber, err := normalizeDayNumber(f.Day)
if err != nil {
return nil, "", false, err
}
table := quoteIdent("dns_logs_ingest")
if s.client.cfg.Database != "" && s.client.cfg.Database != "default" {
table = quoteIdent(s.client.cfg.Database) + "." + quoteIdent("dns_logs_ingest")
}
conditions := []string{"toYYYYMMDD(timestamp) = " + strconv.Itoa(dayNumber)}
if f.NSClusterId > 0 {
conditions = append(conditions, "cluster_id = "+strconv.FormatInt(f.NSClusterId, 10))
}
if f.NSNodeId > 0 {
conditions = append(conditions, "node_id = "+strconv.FormatInt(f.NSNodeId, 10))
}
if f.NSDomainId > 0 {
conditions = append(conditions, "domain_id = "+strconv.FormatInt(f.NSDomainId, 10))
}
if f.NSRecordId > 0 {
conditions = append(conditions, "record_id = "+strconv.FormatInt(f.NSRecordId, 10))
}
if f.RecordType != "" {
conditions = append(conditions, "question_type = '"+escapeString(f.RecordType)+"'")
}
if f.Keyword != "" {
keyword := escapeString(f.Keyword)
conditions = append(conditions, fmt.Sprintf("(remote_addr LIKE '%%%s%%' OR question_name LIKE '%%%s%%' OR record_value LIKE '%%%s%%' OR error LIKE '%%%s%%')", keyword, keyword, keyword, keyword))
}
// 游标分页reverse=false 查更旧reverse=true 查更新。
if f.LastRequestId != "" {
if f.Reverse {
conditions = append(conditions, "request_id > '"+escapeString(f.LastRequestId)+"'")
} else {
conditions = append(conditions, "request_id < '"+escapeString(f.LastRequestId)+"'")
}
}
orderDir := "DESC"
if f.Reverse {
orderDir = "ASC"
}
limit := f.Size
if limit <= 0 {
limit = 20
}
if limit > 1000 {
limit = 1000
}
// 列表查询不 SELECT content_json 大字段,减少翻页时的数据传输量。
// 详情查看时通过 FindByRequestId 单独获取完整信息。
query := fmt.Sprintf(
"SELECT timestamp, request_id, node_id, cluster_id, domain_id, record_id, remote_addr, question_name, question_type, record_name, record_type, record_value, networking, is_recursive, error, ns_route_codes FROM %s WHERE %s ORDER BY timestamp %s, request_id %s LIMIT %d",
table,
strings.Join(conditions, " AND "),
orderDir,
orderDir,
limit+1,
)
var rawRows []map[string]interface{}
if err = s.client.Query(ctx, query, &rawRows); err != nil {
return nil, "", false, err
}
rows = make([]*NSLogsIngestRow, 0, len(rawRows))
for _, rawRow := range rawRows {
row := mapToNSLogsIngestRow(rawRow)
if row != nil {
rows = append(rows, row)
}
}
hasMore = len(rows) > int(limit)
if hasMore {
nextCursor = rows[limit].RequestId
rows = rows[:limit]
} else if len(rows) > 0 {
nextCursor = rows[len(rows)-1].RequestId
}
if f.Reverse {
for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
rows[left], rows[right] = rows[right], rows[left]
}
if len(rows) > 0 {
nextCursor = rows[0].RequestId
}
}
return rows, nextCursor, hasMore, nil
}
// FindByRequestId 按 request_id 查单条 DNS 日志.
func (s *NSLogsIngestStore) FindByRequestId(ctx context.Context, requestId string) (*NSLogsIngestRow, error) {
if !s.client.IsConfigured() {
return nil, fmt.Errorf("clickhouse: not configured")
}
if requestId == "" {
return nil, nil
}
table := quoteIdent("dns_logs_ingest")
if s.client.cfg.Database != "" && s.client.cfg.Database != "default" {
table = quoteIdent(s.client.cfg.Database) + "." + quoteIdent("dns_logs_ingest")
}
query := fmt.Sprintf(
"SELECT timestamp, request_id, node_id, cluster_id, domain_id, record_id, remote_addr, question_name, question_type, record_name, record_type, record_value, networking, is_recursive, error, ns_route_codes, content_json FROM %s WHERE request_id = '%s' LIMIT 1",
table,
escapeString(requestId),
)
var rawRows []map[string]interface{}
if err := s.client.Query(ctx, query, &rawRows); err != nil {
return nil, err
}
if len(rawRows) == 0 {
return nil, nil
}
return mapToNSLogsIngestRow(rawRows[0]), nil
}
func mapToNSLogsIngestRow(row map[string]interface{}) *NSLogsIngestRow {
result := &NSLogsIngestRow{}
u64 := func(key string) uint64 {
value, ok := row[key]
if !ok || value == nil {
return 0
}
switch typed := value.(type) {
case float64:
return uint64(typed)
case string:
number, _ := strconv.ParseUint(typed, 10, 64)
return number
case json.Number:
number, _ := typed.Int64()
return uint64(number)
case int64:
return uint64(typed)
case uint64:
return typed
}
return 0
}
str := func(key string) string {
value, ok := row[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
default:
return fmt.Sprintf("%v", typed)
}
}
ts := func(key string) time.Time {
value, ok := row[key]
if !ok || value == nil {
return time.Time{}
}
switch typed := value.(type) {
case string:
if typed == "" {
return time.Time{}
}
layouts := []string{
"2006-01-02 15:04:05",
time.RFC3339,
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, typed); err == nil {
return parsed
}
}
case float64:
return time.Unix(int64(typed), 0)
case json.Number:
number, _ := typed.Int64()
return time.Unix(number, 0)
}
return time.Time{}
}
boolValue := func(key string) bool {
value, ok := row[key]
if !ok || value == nil {
return false
}
switch typed := value.(type) {
case bool:
return typed
case float64:
return typed > 0
case int64:
return typed > 0
case uint64:
return typed > 0
case string:
switch strings.ToLower(strings.TrimSpace(typed)) {
case "1", "true", "yes":
return true
}
}
return false
}
parseStringArray := func(key string) []string {
value, ok := row[key]
if !ok || value == nil {
return nil
}
switch typed := value.(type) {
case []string:
return typed
case []interface{}:
result := make([]string, 0, len(typed))
for _, one := range typed {
if one == nil {
continue
}
result = append(result, fmt.Sprintf("%v", one))
}
return result
case string:
if typed == "" {
return nil
}
var result []string
if json.Unmarshal([]byte(typed), &result) == nil {
return result
}
return []string{typed}
}
return nil
}
result.Timestamp = ts("timestamp")
result.RequestId = str("request_id")
result.NodeId = u64("node_id")
result.ClusterId = u64("cluster_id")
result.DomainId = u64("domain_id")
result.RecordId = u64("record_id")
result.RemoteAddr = str("remote_addr")
result.QuestionName = str("question_name")
result.QuestionType = str("question_type")
result.RecordName = str("record_name")
result.RecordType = str("record_type")
result.RecordValue = str("record_value")
result.Networking = str("networking")
result.IsRecursive = boolValue("is_recursive")
result.Error = str("error")
result.NSRouteCodes = parseStringArray("ns_route_codes")
result.ContentJSON = str("content_json")
return result
}
// NSRowToPB 将 ClickHouse 行转换为 pb.NSAccessLog.
func NSRowToPB(row *NSLogsIngestRow) *pb.NSAccessLog {
if row == nil {
return nil
}
log := &pb.NSAccessLog{
NsNodeId: int64(row.NodeId),
NsDomainId: int64(row.DomainId),
NsRecordId: int64(row.RecordId),
NsRouteCodes: row.NSRouteCodes,
RemoteAddr: row.RemoteAddr,
QuestionName: row.QuestionName,
QuestionType: row.QuestionType,
RecordName: row.RecordName,
RecordType: row.RecordType,
RecordValue: row.RecordValue,
Networking: row.Networking,
Timestamp: row.Timestamp.Unix(),
RequestId: row.RequestId,
Error: row.Error,
IsRecursive: row.IsRecursive,
}
if !row.Timestamp.IsZero() {
log.TimeLocal = row.Timestamp.Format("2/Jan/2006:15:04:05 -0700")
}
if row.ContentJSON != "" {
contentLog := &pb.NSAccessLog{}
if json.Unmarshal([]byte(row.ContentJSON), contentLog) == nil {
contentLog.RequestId = row.RequestId
return contentLog
}
}
return log
}

View File

@@ -0,0 +1,198 @@
package configs
import (
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/iwind/TeaGo/Tea"
"gopkg.in/yaml.v3"
"os"
"path/filepath"
)
var sharedAPIConfig *APIConfig = nil
// ClickHouseConfig 仅用于访问日志列表只读查询logs_ingest
type ClickHouseConfig struct {
Host string `yaml:"host" json:"host"`
Port int `yaml:"port" json:"port"`
User string `yaml:"user" json:"user"`
Password string `yaml:"password" json:"password"`
Database string `yaml:"database" json:"database"`
Scheme string `yaml:"scheme" json:"scheme"`
TLSSkipVerify bool `yaml:"tlsSkipVerify" json:"tlsSkipVerify"`
TLSServerName string `yaml:"tlsServerName" json:"tlsServerName"`
}
// APIConfig API节点配置
type APIConfig struct {
NodeId string `yaml:"nodeId" json:"nodeId"`
Secret string `yaml:"secret" json:"secret"`
ClickHouse *ClickHouseConfig `yaml:"clickhouse,omitempty" json:"clickhouse,omitempty"`
numberId int64 // 数字ID
}
// SharedAPIConfig 获取共享配置
func SharedAPIConfig() (*APIConfig, error) {
sharedLocker.Lock()
defer sharedLocker.Unlock()
if sharedAPIConfig != nil {
return sharedAPIConfig, nil
}
// 候选文件
var config = &APIConfig{}
{
var localFile = Tea.ConfigFile("api.yaml")
var isFromLocal = false
var paths = []string{localFile}
homeDir, homeErr := os.UserHomeDir()
if homeErr == nil {
paths = append(paths, homeDir+"/."+teaconst.ProcessName+"/api.yaml")
}
paths = append(paths, "/etc/"+teaconst.ProcessName+"/api.yaml")
// 依次检查文件
var data []byte
var err error
var firstErr error
for _, path := range paths {
data, err = os.ReadFile(path)
if err != nil {
if firstErr == nil {
firstErr = err
}
} else {
if path == localFile {
isFromLocal = true
}
break
}
}
if firstErr != nil {
return nil, firstErr
}
// 解析内容
err = yaml.Unmarshal(data, config)
if err != nil {
return nil, err
}
if !isFromLocal {
// 恢复文件
_ = os.WriteFile(localFile, data, 0666)
}
}
// 恢复数据库文件
{
var dbConfigFile = Tea.ConfigFile("db.yaml")
_, err := os.Stat(dbConfigFile)
if err != nil {
var paths = []string{}
homeDir, homeErr := os.UserHomeDir()
if homeErr == nil {
paths = append(paths, homeDir+"/."+teaconst.ProcessName+"/db.yaml")
}
paths = append(paths, "/etc/"+teaconst.ProcessName+"/db.yaml")
for _, path := range paths {
_, err = os.Stat(path)
if err == nil {
data, err := os.ReadFile(path)
if err == nil {
_ = os.WriteFile(dbConfigFile, data, 0666)
break
}
}
}
}
}
sharedAPIConfig = config
return config, nil
}
// SetNumberId 设置数字ID
func (this *APIConfig) SetNumberId(numberId int64) {
this.numberId = numberId
teaconst.NodeId = numberId
}
// NumberId 获取数字ID
func (this *APIConfig) NumberId() int64 {
return this.numberId
}
// WriteFile 保存到文件
func (this *APIConfig) WriteFile(path string) error {
data, err := yaml.Marshal(this)
if err != nil {
return err
}
// 生成备份文件
var filename = filepath.Base(path)
homeDir, _ := os.UserHomeDir()
var backupDirs = []string{"/etc/edge-api"}
if len(homeDir) > 0 {
backupDirs = append(backupDirs, homeDir+"/.edge-api")
}
for _, backupDir := range backupDirs {
stat, err := os.Stat(backupDir)
if err == nil && stat.IsDir() {
_ = os.WriteFile(backupDir+"/"+filename, data, 0666)
} else if err != nil && os.IsNotExist(err) {
err = os.Mkdir(backupDir, 0777)
if err == nil {
_ = os.WriteFile(backupDir+"/"+filename, data, 0666)
}
}
}
return os.WriteFile(path, data, 0666)
}
// ResetAPIConfig 重置配置
func ResetAPIConfig() error {
for _, filename := range []string{"api.yaml", "db.yaml", ".db.yaml"} {
// 重置 configs/api.yaml
{
var configFile = Tea.ConfigFile(filename)
stat, err := os.Stat(configFile)
if err == nil && !stat.IsDir() {
err = os.Remove(configFile)
if err != nil {
return err
}
}
}
// 重置 ~/.edge-api/api.yaml
homeDir, homeErr := os.UserHomeDir()
if homeErr == nil {
var configFile = homeDir + "/." + teaconst.ProcessName + "/" + filename
stat, err := os.Stat(configFile)
if err == nil && !stat.IsDir() {
err = os.Remove(configFile)
if err != nil {
return err
}
}
}
// 重置 /etc/edge-api/api.yaml
{
var configFile = "/etc/" + teaconst.ProcessName + "/" + filename
stat, err := os.Stat(configFile)
if err == nil && !stat.IsDir() {
err = os.Remove(configFile)
if err != nil {
return err
}
}
}
}
return nil
}

View File

@@ -0,0 +1,14 @@
package configs
import (
_ "github.com/iwind/TeaGo/bootstrap"
"testing"
)
func TestSharedAPIConfig(t *testing.T) {
config, err := SharedAPIConfig()
if err != nil {
t.Fatal(err)
}
t.Log(config)
}

View File

@@ -0,0 +1,26 @@
// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn .
package configs
import (
"errors"
"os"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"gopkg.in/yaml.v3"
)
func LoadDBConfig() (*dbs.Config, error) {
var config = &dbs.Config{}
for _, filename := range []string{".db.yaml", "db.yaml"} {
configData, err := os.ReadFile(Tea.ConfigFile(filename))
if err != nil {
continue
}
err = yaml.Unmarshal(configData, config)
return config, err
}
return nil, errors.New("could not find database config file '.db.yaml' or 'db.yaml'")
}

View File

@@ -0,0 +1,5 @@
package configs
import "sync"
var sharedLocker = &sync.RWMutex{}

View File

@@ -0,0 +1,60 @@
// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn .
package configs
import (
"fmt"
"net/url"
"os"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"gopkg.in/yaml.v3"
)
type SimpleDBConfig struct {
User string `yaml:"user"`
Password string `yaml:"password"`
Database string `yaml:"database"`
Host string `yaml:"host"`
BoolFields []string `yaml:"boolFields,omitempty"`
}
func ParseSimpleDBConfig(data []byte) (*SimpleDBConfig, error) {
var config = &SimpleDBConfig{}
err := yaml.Unmarshal(data, config)
return config, err
}
func (this *SimpleDBConfig) GenerateOldConfig() error {
var dbConfig = &dbs.DBConfig{
Driver: "mysql",
Dsn: url.QueryEscape(this.User) + ":" + url.QueryEscape(this.Password) + "@tcp(" + this.Host + ")/" + url.PathEscape(this.Database) + "?charset=utf8mb4&timeout=30s&multiStatements=true",
Prefix: "edge",
}
dbConfig.Models.Package = "internal/db/models"
var config = &dbs.Config{
DBs: map[string]*dbs.DBConfig{
"dev": dbConfig,
"prod": dbConfig,
},
}
config.Default.DB = "prod"
config.Fields = map[string][]string{
"bool": this.BoolFields,
}
oldConfigYAML, encodeErr := yaml.Marshal(config)
if encodeErr != nil {
return encodeErr
}
var targetFile = Tea.ConfigFile(".db.yaml")
err := os.WriteFile(targetFile, oldConfigYAML, 0666)
if err != nil {
return fmt.Errorf("create database config file failed: %w", err)
}
return nil
}

View File

@@ -0,0 +1,9 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
//go:build !plus
// +build !plus
package teaconst
const BuildCommunity = true
const BuildPlus = false
const Tag = "community"

View File

@@ -0,0 +1,8 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
//go:build plus
package teaconst
const BuildCommunity = false
const BuildPlus = true
const Tag = "plus"

View File

@@ -0,0 +1,22 @@
package teaconst
const (
Version = "1.4.7" //1.3.9
ProductName = "Edge API"
ProcessName = "edge-api"
ProductNameZH = "Edge"
GlobalProductName = "GoEdge"
Role = "api"
EncryptMethod = "aes-256-cfb"
SystemdServiceName = "edge-api"
// 其他节点版本号,用来检测是否有需要升级的节点
NodeVersion = "1.4.7" //1.3.8.2
)

View File

@@ -0,0 +1,9 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build !plus
package teaconst
const (
// DefaultMaxNodes 节点数限制
DefaultMaxNodes int32 = 50
)

View File

@@ -0,0 +1,13 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package teaconst
const (
DNSNodeVersion = "1.4.7" //1.3.8.2
UserNodeVersion = "1.4.7" //1.3.8.2
ReportNodeVersion = "0.1.5"
DefaultMaxNodes int32 = 50
)

View File

@@ -0,0 +1,35 @@
// Copyright 2021 Liuxiangchao iwind.liu@gmail.com. All rights reserved.
package teaconst
import (
"crypto/sha1"
"fmt"
"github.com/iwind/TeaGo/rands"
"github.com/iwind/TeaGo/types"
"os"
"strings"
"time"
)
var (
IsPlus = false
Edition = ""
MaxNodes int32 = 0
NodeId int64 = 0
Debug = false
InstanceCode = fmt.Sprintf("%x", sha1.Sum([]byte("INSTANCE"+types.String(time.Now().UnixNano())+"@"+types.String(rands.Int64()))))
IsMain = checkMain()
)
// 检查是否为主程序
func checkMain() bool {
if len(os.Args) == 1 ||
(len(os.Args) >= 2 && os.Args[1] == "pprof") {
return true
}
exe, _ := os.Executable()
return strings.HasSuffix(exe, ".test") ||
strings.HasSuffix(exe, ".test.exe") ||
strings.Contains(exe, "___")
}

View File

@@ -0,0 +1,18 @@
// Copyright 2023 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package teaconst
import (
"github.com/iwind/TeaGo/Tea"
)
var AuthorityValidatorHOSTS = []string{}
func init() {
if Tea.IsTesting() {
AuthorityValidatorHOSTS = []string{"http://127.0.0.1:8084"} // local test
} else {
AuthorityValidatorHOSTS = []string{"https://authority.goedge.cn"}
}
}

View File

@@ -0,0 +1,55 @@
package db
import (
"database/sql"
"database/sql/driver"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
_ "github.com/iwind/TeaGo/bootstrap"
"github.com/iwind/TeaGo/dbs"
"testing"
"time"
)
func TestDB_Env(t *testing.T) {
Tea.Env = "prod"
t.Log(dbs.Default())
}
func TestDB_Instance(t *testing.T) {
for i := 0; i < 10; i++ {
db, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3306)/db_edge?charset=utf8mb4&timeout=30s")
if err != nil {
t.Fatal(i, "open:", err)
}
db.SetConnMaxIdleTime(time.Minute * 3)
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxIdleConns(0)
db.SetMaxOpenConns(1)
go func(db *sql.DB, i int) {
for j := 0; j < 100; j++ {
err := db.Ping()
if err != nil {
if err == driver.ErrBadConn {
return
}
t.Error(i, "exec:", err)
}
time.Sleep(1 * time.Second)
}
t.Log(i, "ok", db)
}(db, i)
}
time.Sleep(100 * time.Second)
}
func TestDB_Reuse(t *testing.T) {
var dao = models.NewVersionDAO()
for i := 0; i < 20_000; i++ {
_, _, err := dao.Query(nil).Attr("version", i).Reuse(true).FindOne()
if err != nil {
t.Fatal(err)
}
}
}

View File

@@ -0,0 +1,33 @@
package accounts
import (
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
const (
OrderMethodStateEnabled = 1 // 已启用
OrderMethodStateDisabled = 0 // 已禁用
)
type OrderMethodDAO dbs.DAO
func NewOrderMethodDAO() *OrderMethodDAO {
return dbs.NewDAO(&OrderMethodDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeOrderMethods",
Model: new(OrderMethod),
PkName: "id",
},
}).(*OrderMethodDAO)
}
var SharedOrderMethodDAO *OrderMethodDAO
func init() {
dbs.OnReady(func() {
SharedOrderMethodDAO = NewOrderMethodDAO()
})
}

View File

@@ -0,0 +1,180 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package accounts
import (
"encoding/json"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/TeaOSLab/EdgeAPI/internal/utils"
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
"github.com/iwind/TeaGo/dbs"
)
// EnableOrderMethod 启用条目
func (this *OrderMethodDAO) EnableOrderMethod(tx *dbs.Tx, id uint32) error {
_, err := this.Query(tx).
Pk(id).
Set("state", OrderMethodStateEnabled).
Update()
return err
}
// DisableOrderMethod 禁用条目
func (this *OrderMethodDAO) DisableOrderMethod(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", OrderMethodStateDisabled).
Update()
return err
}
// FindEnabledOrderMethod 查找支付方式
func (this *OrderMethodDAO) FindEnabledOrderMethod(tx *dbs.Tx, id int64) (*OrderMethod, error) {
result, err := this.Query(tx).
Pk(id).
Attr("state", OrderMethodStateEnabled).
Find()
if result == nil {
return nil, err
}
return result.(*OrderMethod), err
}
// FindEnabledBasicOrderMethod 查找支付方式基本信息
func (this *OrderMethodDAO) FindEnabledBasicOrderMethod(tx *dbs.Tx, id int64) (*OrderMethod, error) {
result, err := this.Query(tx).
Pk(id).
Result("id", "code", "url", "isOn", "secret", "parentCode", "clientType").
Attr("state", OrderMethodStateEnabled).
Find()
if err != nil || result == nil {
return nil, err
}
return result.(*OrderMethod), err
}
// FindEnabledOrderMethodWithCode 根据代号查找支付方式
func (this *OrderMethodDAO) FindEnabledOrderMethodWithCode(tx *dbs.Tx, code string) (*OrderMethod, error) {
result, err := this.Query(tx).
Attr("code", code).
Attr("state", OrderMethodStateEnabled).
Find()
if result == nil {
return nil, err
}
return result.(*OrderMethod), err
}
// ExistOrderMethodWithCode 根据代号检查支付方式是否存在
func (this *OrderMethodDAO) ExistOrderMethodWithCode(tx *dbs.Tx, code string, excludingMethodId int64) (bool, error) {
var query = this.Query(tx)
if excludingMethodId > 0 {
query.Neq("id", excludingMethodId)
}
return query.
Attr("code", code).
Attr("state", OrderMethodStateEnabled).
Exist()
}
// FindOrderMethodName 根据主键查找名称
func (this *OrderMethodDAO) FindOrderMethodName(tx *dbs.Tx, id int64) (string, error) {
return this.Query(tx).
Pk(id).
Result("name").
FindStringCol("")
}
// CreateMethod 创建支付方式
func (this *OrderMethodDAO) CreateMethod(tx *dbs.Tx, name string, code string, url string, description string, parentCode string, params any, clientType userconfigs.PayClientType, qrcodeTitle string) (int64, error) {
var op = NewOrderMethodOperator()
op.Name = name
op.Code = code
op.Url = url
op.ParentCode = parentCode
if params != nil {
paramsJSON, err := json.Marshal(params)
if err != nil {
return 0, err
}
op.Params = paramsJSON
}
op.Description = description
op.Secret = utils.Sha1RandomString()
op.ClientType = clientType
op.QrcodeTitle = qrcodeTitle
op.IsOn = true
op.State = OrderMethodStateEnabled
return this.SaveInt64(tx, op)
}
// UpdateMethod 修改支付方式
// 切记不允许修改parentCode
func (this *OrderMethodDAO) UpdateMethod(tx *dbs.Tx, methodId int64, name string, code string, url string, description string, params any, clientType userconfigs.PayClientType, qrcodeTitle string, isOn bool) error {
if methodId <= 0 {
return errors.New("invalid methodId")
}
var op = NewOrderMethodOperator()
op.Id = methodId
op.Name = name
op.Code = code
op.Url = url
op.Description = description
if params != nil {
paramsJSON, err := json.Marshal(params)
if err != nil {
return err
}
op.Params = paramsJSON
}
op.ClientType = clientType
op.QrcodeTitle = qrcodeTitle
op.IsOn = isOn
return this.Save(tx, op)
}
// UpdateMethodOrders 修改排序
func (this *OrderMethodDAO) UpdateMethodOrders(tx *dbs.Tx, methodIds []int64) error {
var maxOrder = len(methodIds)
for index, methodId := range methodIds {
err := this.Query(tx).
Pk(methodId).
Set("order", maxOrder-index).
UpdateQuickly()
if err != nil {
return err
}
}
return nil
}
// FindAllEnabledMethodOrders 查找所有支付方式
func (this *OrderMethodDAO) FindAllEnabledMethodOrders(tx *dbs.Tx) (result []*OrderMethod, err error) {
_, err = this.Query(tx).
State(OrderMethodStateEnabled).
Desc("order").
AscPk().
Slice(&result).
FindAll()
return
}
// FindAllEnabledAndOnMethodOrders 查找所有已启用的支付方式
func (this *OrderMethodDAO) FindAllEnabledAndOnMethodOrders(tx *dbs.Tx) (result []*OrderMethod, err error) {
_, err = this.Query(tx).
State(OrderMethodStateEnabled).
Attr("isOn", true).
Desc("order").
AscPk().
Slice(&result).
FindAll()
return
}

View File

@@ -0,0 +1,6 @@
package accounts_test
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

View File

@@ -0,0 +1,40 @@
package accounts
import "github.com/iwind/TeaGo/dbs"
// OrderMethod 订单支付方式
type OrderMethod struct {
Id uint32 `field:"id"` // ID
Name string `field:"name"` // 名称
IsOn bool `field:"isOn"` // 是否启用
Description string `field:"description"` // 描述
ParentCode string `field:"parentCode"` // 内置的父级代号
Code string `field:"code"` // 代号
Url string `field:"url"` // URL
Secret string `field:"secret"` // 密钥
Params dbs.JSON `field:"params"` // 参数
ClientType string `field:"clientType"` // 客户端类型
QrcodeTitle string `field:"qrcodeTitle"` // 二维码标题
Order uint32 `field:"order"` // 排序
State uint8 `field:"state"` // 状态
}
type OrderMethodOperator struct {
Id any // ID
Name any // 名称
IsOn any // 是否启用
Description any // 描述
ParentCode any // 内置的父级代号
Code any // 代号
Url any // URL
Secret any // 密钥
Params any // 参数
ClientType any // 客户端类型
QrcodeTitle any // 二维码标题
Order any // 排序
State any // 状态
}
func NewOrderMethodOperator() *OrderMethodOperator {
return &OrderMethodOperator{}
}

View File

@@ -0,0 +1 @@
package accounts

View File

@@ -0,0 +1,82 @@
//go:build plus
package accounts
import (
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/maps"
timeutil "github.com/iwind/TeaGo/utils/time"
)
type UserAccountDailyStatDAO dbs.DAO
func NewUserAccountDailyStatDAO() *UserAccountDailyStatDAO {
return dbs.NewDAO(&UserAccountDailyStatDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeUserAccountDailyStats",
Model: new(UserAccountDailyStat),
PkName: "id",
},
}).(*UserAccountDailyStatDAO)
}
var SharedUserAccountDailyStatDAO *UserAccountDailyStatDAO
func init() {
dbs.OnReady(func() {
SharedUserAccountDailyStatDAO = NewUserAccountDailyStatDAO()
})
}
// UpdateDailyStat 更新当天统计数据
func (this *UserAccountDailyStatDAO) UpdateDailyStat(tx *dbs.Tx) error {
var day = timeutil.Format("Ymd")
var month = timeutil.Format("Ym")
income, err := SharedUserAccountLogDAO.SumDailyEventTypes(tx, day, userconfigs.AccountIncomeEventTypes)
if err != nil {
return err
}
expense, err := SharedUserAccountLogDAO.SumDailyEventTypes(tx, day, userconfigs.AccountExpenseEventTypes)
if err != nil {
return err
}
if expense < 0 {
expense = -expense
}
return this.Query(tx).
InsertOrUpdateQuickly(maps.Map{
"day": day,
"month": month,
"income": income,
"expense": expense,
}, maps.Map{
"income": income,
"expense": expense,
})
}
// FindDailyStats 查看按天统计
func (this *UserAccountDailyStatDAO) FindDailyStats(tx *dbs.Tx, dayFrom string, dayTo string) (result []*UserAccountDailyStat, err error) {
_, err = this.Query(tx).
Between("day", dayFrom, dayTo).
Slice(&result).
FindAll()
return
}
// FindMonthlyStats 查看某月统计
func (this *UserAccountDailyStatDAO) FindMonthlyStats(tx *dbs.Tx, dayFrom string, dayTo string) (result []*UserAccountDailyStat, err error) {
_, err = this.Query(tx).
Result("SUM(income) AS income", "SUM(expense) AS expense", "month").
Between("day", dayFrom, dayTo).
Group("month").
Slice(&result).
FindAll()
return
}

View File

@@ -0,0 +1,6 @@
package accounts
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

View File

@@ -0,0 +1,22 @@
package accounts
// UserAccountDailyStat 账户每日统计
type UserAccountDailyStat struct {
Id uint32 `field:"id"` // ID
Day string `field:"day"` // YYYYMMDD
Month string `field:"month"` // YYYYMM
Income float64 `field:"income"` // 收入
Expense float64 `field:"expense"` // 支出
}
type UserAccountDailyStatOperator struct {
Id interface{} // ID
Day interface{} // YYYYMMDD
Month interface{} // YYYYMM
Income interface{} // 收入
Expense interface{} // 支出
}
func NewUserAccountDailyStatOperator() *UserAccountDailyStatOperator {
return &UserAccountDailyStatOperator{}
}

View File

@@ -0,0 +1 @@
package accounts

View File

@@ -0,0 +1,264 @@
//go:build plus
package accounts
import (
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/TeaOSLab/EdgeAPI/internal/goman"
"github.com/TeaOSLab/EdgeAPI/internal/remotelogs"
"github.com/TeaOSLab/EdgeAPI/internal/utils/numberutils"
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/lists"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/types"
"time"
)
func init() {
dbs.OnReadyDone(func() {
goman.New(func() {
// 自动支付账单任务
var ticker = time.NewTicker(6 * time.Hour)
for range ticker.C {
if SharedUserAccountDAO.Instance != nil {
err := SharedUserAccountDAO.Instance.RunTx(func(tx *dbs.Tx) error {
return SharedUserAccountDAO.PayBillsAutomatically(tx)
})
if err != nil {
remotelogs.Error("USER_ACCOUNT_DAO", "pay bills task failed: "+err.Error())
}
}
}
})
})
}
type UserAccountDAO dbs.DAO
func NewUserAccountDAO() *UserAccountDAO {
return dbs.NewDAO(&UserAccountDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeUserAccounts",
Model: new(UserAccount),
PkName: "id",
},
}).(*UserAccountDAO)
}
var SharedUserAccountDAO *UserAccountDAO
func init() {
dbs.OnReady(func() {
SharedUserAccountDAO = NewUserAccountDAO()
})
}
// FindUserAccountWithUserId 根据用户ID查找用户账户
func (this *UserAccountDAO) FindUserAccountWithUserId(tx *dbs.Tx, userId int64) (*UserAccount, error) {
if userId <= 0 {
return nil, errors.New("invalid userId '" + types.String(userId) + "'")
}
// 用户是否存在
user, err := models.SharedUserDAO.FindEnabledUser(tx, userId, nil)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
account, err := this.Query(tx).
Attr("userId", userId).
Find()
if err != nil {
return nil, err
}
if account != nil {
return account.(*UserAccount), nil
}
var op = NewUserAccountOperator()
op.UserId = userId
_, err = this.SaveInt64(tx, op)
if err != nil {
return nil, err
}
return this.FindUserAccountWithUserId(tx, userId)
}
// FindUserAccountWithAccountId 根据ID查找用户账户
func (this *UserAccountDAO) FindUserAccountWithAccountId(tx *dbs.Tx, accountId int64) (*UserAccount, error) {
one, err := this.Query(tx).
Pk(accountId).
Find()
if one != nil {
return one.(*UserAccount), nil
}
return nil, err
}
// UpdateUserAccount 操作用户账户
func (this *UserAccountDAO) UpdateUserAccount(tx *dbs.Tx, accountId int64, delta float64, eventType userconfigs.AccountEventType, description string, params maps.Map) error {
// 截取两位两位小数
var realDelta = delta
if realDelta < 0 {
realDelta = numberutils.FloorFloat64(realDelta, 2)
}
account, err := this.FindUserAccountWithAccountId(tx, accountId)
if err != nil {
return err
}
if account == nil {
return errors.New("invalid account id '" + types.String(accountId) + "'")
}
var userId = int64(account.UserId)
if delta < 0 && account.Total < -delta {
return errors.New("not enough account quota to decrease")
}
// 操作账户
err = this.Query(tx).
Pk(account.Id).
Set("total", dbs.SQL("total+:delta")).
Param("delta", realDelta).
UpdateQuickly()
if err != nil {
return err
}
// 生成日志
err = SharedUserAccountLogDAO.CreateAccountLog(tx, userId, accountId, realDelta, 0, eventType, description, params)
if err != nil {
return err
}
return nil
}
// UpdateUserAccountFrozen 操作用户账户冻结余额
func (this *UserAccountDAO) UpdateUserAccountFrozen(tx *dbs.Tx, userId int64, delta float64, eventType userconfigs.AccountEventType, description string, params maps.Map) error {
account, err := this.FindUserAccountWithUserId(tx, userId)
if err != nil {
return err
}
if account == nil {
return errors.New("invalid user account")
}
var deltaFloat64 = delta
if deltaFloat64 < 0 && account.TotalFrozen < -deltaFloat64 {
return errors.New("not enough account frozen quota to decrease")
}
// 操作账户
err = this.Query(tx).
Pk(account.Id).
Set("totalFrozen", dbs.SQL("total+:delta")).
Param("delta", delta).
UpdateQuickly()
if err != nil {
return err
}
// 生成日志
err = SharedUserAccountLogDAO.CreateAccountLog(tx, userId, int64(account.Id), 0, delta, eventType, description, params)
if err != nil {
return err
}
return nil
}
// CountAllAccounts 计算所有账户数量
func (this *UserAccountDAO) CountAllAccounts(tx *dbs.Tx, keyword string) (int64, error) {
var query = this.Query(tx)
if len(keyword) > 0 {
query.Where("userId IN (SELECT id FROM " + models.SharedUserDAO.Table + " WHERE state=1 AND (username LIKE :keyword OR fullname LIKE :keyword))")
query.Param("keyword", keyword)
} else {
query.Where("userId IN (SELECT id FROM " + models.SharedUserDAO.Table + " WHERE state=1)")
}
return query.Count()
}
// ListAccounts 列出单页账户
func (this *UserAccountDAO) ListAccounts(tx *dbs.Tx, keyword string, offset int64, size int64) (result []*UserAccount, err error) {
var query = this.Query(tx)
if len(keyword) > 0 {
query.Where("userId IN (SELECT id FROM " + models.SharedUserDAO.Table + " WHERE state=1 AND (username LIKE :keyword OR fullname LIKE :keyword))")
query.Param("keyword", keyword)
} else {
query.Where("userId IN (SELECT id FROM " + models.SharedUserDAO.Table + " WHERE state=1)")
}
_, err = query.
DescPk().
Offset(offset).
Limit(size).
Slice(&result).
FindAll()
return
}
// PayBills 尝试自动支付账单
func (this *UserAccountDAO) PayBillsAutomatically(tx *dbs.Tx) error {
bills, err := models.SharedUserBillDAO.FindUnpaidBills(tx, 10000)
if err != nil {
return err
}
// 先支付久远的
lists.Reverse(bills)
for _, bill := range bills {
if bill.Amount <= 0 {
err = models.SharedUserBillDAO.UpdateUserBillIsPaid(tx, int64(bill.Id), true)
if err != nil {
return err
}
continue
}
account, err := SharedUserAccountDAO.FindUserAccountWithUserId(tx, int64(bill.UserId))
if err != nil {
return err
}
if account == nil || account.Total < bill.Amount {
continue
}
// 扣款
err = SharedUserAccountDAO.UpdateUserAccount(tx, int64(account.Id), -bill.Amount, userconfigs.AccountEventTypePayBill, "支付账单"+bill.Code, maps.Map{"billId": bill.Id})
if err != nil {
return err
}
// 改为已支付
err = models.SharedUserBillDAO.UpdateUserBillIsPaid(tx, int64(bill.Id), true)
if err != nil {
return err
}
}
return nil
}
// CheckUserAccount 检查用户账户
func (this *UserAccountDAO) CheckUserAccount(tx *dbs.Tx, userId int64, accountId int64) error {
exists, err := this.Query(tx).
Pk(accountId).
Attr("userId", userId).
Exist()
if err != nil {
return err
}
if !exists {
return models.ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,47 @@
//go:build plus
package accounts_test
import (
"github.com/TeaOSLab/EdgeAPI/internal/db/models/accounts"
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
"github.com/iwind/TeaGo/dbs"
"testing"
)
func TestUserAccountDAO_PayBills(t *testing.T) {
dbs.NotifyReady()
err := accounts.NewUserAccountDAO().PayBillsAutomatically(nil)
if err != nil {
t.Fatal(err)
}
t.Log("ok")
}
func TestUserAccountDAO_UpdateUserAccount(t *testing.T) {
dbs.NotifyReady()
var tx *dbs.Tx
var dao = accounts.NewUserAccountDAO()
err := dao.UpdateUserAccount(tx, 3, -17.88, userconfigs.AccountEventTypePayBill, "test", nil)
if err != nil {
t.Fatal(err)
}
t.Log("ok")
}
// 221745.12
func TestUserAccountDAO_UpdateUserAccount2(t *testing.T) {
dbs.NotifyReady()
var tx *dbs.Tx
var dao = accounts.NewUserAccountDAO()
err := dao.UpdateUserAccount(tx, 3, -221745.12, userconfigs.AccountEventTypePayBill, "test", nil)
if err != nil {
t.Fatal(err)
}
t.Log("ok")
}

View File

@@ -0,0 +1,131 @@
//go:build plus
package accounts
import (
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
dbutils "github.com/TeaOSLab/EdgeAPI/internal/db/utils"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/types"
timeutil "github.com/iwind/TeaGo/utils/time"
)
type UserAccountLogDAO dbs.DAO
func NewUserAccountLogDAO() *UserAccountLogDAO {
return dbs.NewDAO(&UserAccountLogDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeUserAccountLogs",
Model: new(UserAccountLog),
PkName: "id",
},
}).(*UserAccountLogDAO)
}
var SharedUserAccountLogDAO *UserAccountLogDAO
func init() {
dbs.OnReady(func() {
SharedUserAccountLogDAO = NewUserAccountLogDAO()
})
}
// CreateAccountLog 生成用户账户日志
func (this *UserAccountLogDAO) CreateAccountLog(tx *dbs.Tx, userId int64, accountId int64, delta float64, deltaFrozen float64, eventType userconfigs.AccountEventType, description string, params maps.Map) error {
var op = NewUserAccountLogOperator()
op.UserId = userId
op.AccountId = accountId
op.Delta = delta
op.DeltaFrozen = deltaFrozen
account, err := SharedUserAccountDAO.FindUserAccountWithAccountId(tx, accountId)
if err != nil {
return err
}
if account == nil {
return errors.New("invalid account id '" + types.String(accountId) + "'")
}
op.Total = account.Total
op.TotalFrozen = account.TotalFrozen
op.EventType = eventType
op.Description = description
if params == nil {
params = maps.Map{}
}
op.Params = params.AsJSON()
op.Day = timeutil.Format("Ymd")
err = this.Save(tx, op)
if err != nil {
return err
}
return SharedUserAccountDailyStatDAO.UpdateDailyStat(tx)
}
// CountAccountLogs 计算日志数量
func (this *UserAccountLogDAO) CountAccountLogs(tx *dbs.Tx, userId int64, accountId int64, keyword string, eventType string) (int64, error) {
var query = this.Query(tx)
if userId > 0 {
query.Attr("userId", userId)
}
if accountId > 0 {
query.Attr("accountId", accountId)
}
if len(keyword) > 0 {
query.Where("(userId IN (SELECT id FROM " + models.SharedUserDAO.Table + " WHERE state=1 AND (username LIKE :keyword OR fullname LIKE :keyword)) OR description LIKE :keyword)")
query.Param("keyword", dbutils.QuoteLike(keyword))
}
if len(eventType) > 0 {
query.Attr("eventType", eventType)
}
return query.Count()
}
// ListAccountLogs 列出单页日志
func (this *UserAccountLogDAO) ListAccountLogs(tx *dbs.Tx, userId int64, accountId int64, keyword string, eventType string, offset int64, size int64) (result []*UserAccountLog, err error) {
var query = this.Query(tx)
if userId > 0 {
query.Attr("userId", userId)
}
if accountId > 0 {
query.Attr("accountId", accountId)
}
if len(keyword) > 0 {
query.Where("(userId IN (SELECT id FROM " + models.SharedUserDAO.Table + " WHERE state=1 AND (username LIKE :keyword OR fullname LIKE :keyword)) OR description LIKE :keyword)")
query.Param("keyword", dbutils.QuoteLike(keyword))
}
if len(eventType) > 0 {
query.Attr("eventType", eventType)
}
_, err = query.
DescPk().
Offset(offset).
Limit(size).
Slice(&result).
FindAll()
return
}
// SumDailyEventTypes 统计某天数据总和
func (this *UserAccountLogDAO) SumDailyEventTypes(tx *dbs.Tx, day string, eventTypes []userconfigs.AccountEventType) (float32, error) {
if len(eventTypes) == 0 {
return 0, nil
}
result, err := this.Query(tx).
Attr("day", day).
Attr("eventType", eventTypes).
Sum("delta", 0)
if err != nil {
return 0, err
}
return types.Float32(result), nil
}

View File

@@ -0,0 +1,6 @@
package accounts
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

View File

@@ -0,0 +1,38 @@
package accounts
import "github.com/iwind/TeaGo/dbs"
// UserAccountLog 用户账户日志
type UserAccountLog struct {
Id uint64 `field:"id"` // ID
UserId uint64 `field:"userId"` // 用户ID
AccountId uint64 `field:"accountId"` // 账户ID
Delta float64 `field:"delta"` // 操作余额的数量(可为负)
DeltaFrozen float64 `field:"deltaFrozen"` // 操作冻结的数量(可为负)
Total float64 `field:"total"` // 操作后余额
TotalFrozen float64 `field:"totalFrozen"` // 操作后冻结余额
EventType string `field:"eventType"` // 类型
Description string `field:"description"` // 描述文字
Day string `field:"day"` // YYYYMMDD
CreatedAt uint64 `field:"createdAt"` // 时间
Params dbs.JSON `field:"params"` // 参数
}
type UserAccountLogOperator struct {
Id interface{} // ID
UserId interface{} // 用户ID
AccountId interface{} // 账户ID
Delta interface{} // 操作余额的数量(可为负)
DeltaFrozen interface{} // 操作冻结的数量(可为负)
Total interface{} // 操作后余额
TotalFrozen interface{} // 操作后冻结余额
EventType interface{} // 类型
Description interface{} // 描述文字
Day interface{} // YYYYMMDD
CreatedAt interface{} // 时间
Params interface{} // 参数
}
func NewUserAccountLogOperator() *UserAccountLogOperator {
return &UserAccountLogOperator{}
}

View File

@@ -0,0 +1 @@
package accounts

View File

@@ -0,0 +1,20 @@
package accounts
// UserAccount 用户账号
type UserAccount struct {
Id uint64 `field:"id"` // ID
UserId uint64 `field:"userId"` // 用户ID
Total float64 `field:"total"` // 可用总余额
TotalFrozen float64 `field:"totalFrozen"` // 冻结余额
}
type UserAccountOperator struct {
Id interface{} // ID
UserId interface{} // 用户ID
Total interface{} // 可用总余额
TotalFrozen interface{} // 冻结余额
}
func NewUserAccountOperator() *UserAccountOperator {
return &UserAccountOperator{}
}

View File

@@ -0,0 +1 @@
package accounts

View File

@@ -0,0 +1,33 @@
package accounts
import (
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
const (
UserOrderStateEnabled = 1 // 已启用
UserOrderStateDisabled = 0 // 已禁用
)
type UserOrderDAO dbs.DAO
func NewUserOrderDAO() *UserOrderDAO {
return dbs.NewDAO(&UserOrderDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeUserOrders",
Model: new(UserOrder),
PkName: "id",
},
}).(*UserOrderDAO)
}
var SharedUserOrderDAO *UserOrderDAO
func init() {
dbs.OnReady(func() {
SharedUserOrderDAO = NewUserOrderDAO()
})
}

View File

@@ -0,0 +1,292 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package accounts
import (
"encoding/json"
"errors"
"fmt"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeAPI/internal/db/models/nameservers"
dbutils "github.com/TeaOSLab/EdgeAPI/internal/db/utils"
"github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/types"
timeutil "github.com/iwind/TeaGo/utils/time"
"time"
)
// CreateOrder 创建订单
func (this *UserOrderDAO) CreateOrder(tx *dbs.Tx, adminId int64, userId int64, orderType userconfigs.OrderType, methodId int64, amount float64, paramsJSON []byte) (orderId int64, code string, err error) {
// 查询过期时间
configJSON, err := models.SharedSysSettingDAO.ReadSetting(tx, systemconfigs.SettingCodeUserOrderConfig)
if err != nil {
return 0, "", err
}
var config = userconfigs.DefaultUserOrderConfig()
if len(configJSON) == 0 {
err = json.Unmarshal(configJSON, config)
if err != nil {
return 0, "", fmt.Errorf("decode order config failed: %w", err)
}
}
// 保存订单
var op = NewUserOrderOperator()
op.UserId = userId
op.Type = orderType
op.MethodId = methodId
op.Amount = amount
if len(paramsJSON) > 0 {
op.Params = paramsJSON
}
op.Status = userconfigs.OrderStatusNone
if config.OrderLife != nil && config.OrderLife.Count > 0 {
op.ExpiredAt = time.Now().Unix() + int64(config.OrderLife.Duration().Seconds())
} else {
op.ExpiredAt = time.Now().Unix() + 3600 /** 默认一个小时 **/
}
op.State = UserOrderStateEnabled
orderId, err = this.SaveInt64(tx, op)
if err != nil {
return 0, "", err
}
var orderCode = timeutil.Format("Ymd") + fmt.Sprintf("%08d", orderId) // 16 bytes
err = this.Query(tx).
Pk(orderId).
Set("code", orderCode).
UpdateQuickly()
if err != nil {
return 0, "", err
}
// 生成订单日志
err = SharedUserOrderLogDAO.CreateOrderLog(tx, adminId, userId, orderId, userconfigs.OrderStatusNone)
if err != nil {
return 0, "", err
}
return orderId, orderCode, nil
}
// FindEnabledOrder 查找某个订单
func (this *UserOrderDAO) FindEnabledOrder(tx *dbs.Tx, orderId int64) (*UserOrder, error) {
one, err := this.Query(tx).
Pk(orderId).
Find()
if err != nil || one == nil {
return nil, err
}
return one.(*UserOrder), nil
}
// CancelOrder 取消订单
func (this *UserOrderDAO) CancelOrder(tx *dbs.Tx, adminId int64, userId int64, orderId int64) error {
status, err := this.Query(tx).
Pk(orderId).
Result("status").
FindStringCol("")
if err != nil {
return err
}
if status != userconfigs.OrderStatusNone {
return errors.New("can not cancel the order with status '" + status + "'")
}
err = this.Query(tx).
Pk(orderId).
Set("status", userconfigs.OrderStatusCancelled).
Set("cancelledAt", time.Now().Unix()).
UpdateQuickly()
if err != nil {
return err
}
return SharedUserOrderLogDAO.CreateOrderLog(tx, adminId, userId, orderId, userconfigs.OrderStatusCancelled)
}
// FinishOrder 完成订单
// 不需要检查过期时间,因为用户可能在支付页面停留非常久后才完成支付
func (this *UserOrderDAO) FinishOrder(tx *dbs.Tx, adminId int64, userId int64, orderId int64) error {
// 检查订单状态
order, err := SharedUserOrderDAO.FindEnabledOrder(tx, orderId)
if err != nil {
return err
}
if order == nil {
return errors.New("can not find order")
}
if order.Status != userconfigs.OrderStatusNone {
return errors.New("you can not finish the order, cause order status is '" + order.Status + "'")
}
// 用户账户
account, err := SharedUserAccountDAO.FindUserAccountWithUserId(tx, int64(order.UserId))
if err != nil {
return err
}
if account == nil {
return errors.New("user account not found")
}
switch order.Type {
case userconfigs.OrderTypeCharge: // 充值
err = SharedUserAccountDAO.UpdateUserAccount(tx, int64(account.Id), order.Amount, userconfigs.AccountEventTypeCharge, "充值,订单号:"+order.Code, maps.Map{
"orderCode": order.Code,
})
if err != nil {
return err
}
case userconfigs.OrderTypeBuyNSPlan: // 购买DNS套餐
var params = &userconfigs.OrderTypeBuyNSPlanParams{}
err = json.Unmarshal(order.Params, params)
if err != nil {
return err
}
err = nameservers.SharedNSUserPlanDAO.RenewUserPlan(tx, int64(order.UserId), params.PlanId, params.DayFrom, params.DayTo, params.Period)
if err != nil {
return err
}
case userconfigs.OrderTypeBuyTrafficPackage: // 购买流量包
var params = &userconfigs.OrderTypeBuyTrafficPackageParams{}
err = json.Unmarshal(order.Params, params)
if err != nil {
return err
}
if params.Count > 0 {
for i := 0; i < int(params.Count); i++ {
_, err = models.SharedUserTrafficPackageDAO.CreateUserPackage(tx, int64(order.UserId), 0, params.PackageId, params.RegionId, params.PeriodId)
if err != nil {
return err
}
}
}
case userconfigs.OrderTypeBuyAntiDDoSInstance: // 购买高防实例
var params = &userconfigs.OrderTypeBuyAntiDDoSInstanceParams{}
err = json.Unmarshal(order.Params, params)
if err != nil {
return err
}
err = models.SharedUserADInstanceDAO.CreateUserADInstances(tx, int64(order.UserId), params.PackageId, params.PeriodId, params.Count)
if err != nil {
return err
}
case userconfigs.OrderTypeRenewAntiDDoSInstance: // 续费高防实例
var params = &userconfigs.OrderTypeRenewAntiDDoSInstanceParams{}
err = json.Unmarshal(order.Params, params)
if err != nil {
return err
}
userInstance, err := models.SharedUserADInstanceDAO.FindEnabledUserADInstance(tx, params.UserInstanceId)
if err != nil {
return err
}
if userInstance == nil {
return errors.New("could not find user instance '" + types.String(params.UserInstanceId) + "'")
}
period, err := models.SharedADPackagePeriodDAO.FindEnabledADPackagePeriod(tx, params.PeriodId)
if err != nil {
return err
}
if period == nil {
return errors.New("could not find period '" + types.String(params.PeriodId) + "'")
}
_, err = models.SharedUserADInstanceDAO.RenewUserInstance(tx, userInstance, period)
if err != nil {
return err
}
}
err = this.Query(tx).
Pk(orderId).
Set("status", userconfigs.OrderStatusFinished).
Set("finishedAt", time.Now().Unix()).
UpdateQuickly()
if err != nil {
return err
}
return SharedUserOrderLogDAO.CreateOrderLog(tx, adminId, userId, orderId, userconfigs.OrderStatusFinished)
}
// CountEnabledUserOrders 计算订单数量
func (this *UserOrderDAO) CountEnabledUserOrders(tx *dbs.Tx, userId int64, status userconfigs.OrderStatus, keyword string) (int64, error) {
var query = this.Query(tx).
State(UserOrderStateEnabled)
if userId > 0 {
query.Attr("userId", userId)
}
if len(status) > 0 {
query.Attr("status", status)
}
if len(keyword) > 0 {
query.Where("(code LIKE :keyword)")
query.Param("keyword", dbutils.QuoteLike(keyword))
}
return query.Count()
}
// ListEnabledUserOrders 列出单页订单
func (this *UserOrderDAO) ListEnabledUserOrders(tx *dbs.Tx, userId int64, status userconfigs.OrderStatus, keyword string, offset int64, size int64) (result []*UserOrder, err error) {
var query = this.Query(tx).
State(UserOrderStateEnabled)
if userId > 0 {
query.Attr("userId", userId)
}
if len(status) > 0 {
query.Attr("status", status)
}
if len(keyword) > 0 {
query.Where("(code LIKE :keyword)")
query.Param("keyword", dbutils.QuoteLike(keyword))
}
_, err = query.
DescPk().
Offset(offset).
Limit(size).
Slice(&result).
FindAll()
return
}
// FindUserOrderIdWithCode 根据订单号查找订单ID
func (this *UserOrderDAO) FindUserOrderIdWithCode(tx *dbs.Tx, code string) (int64, error) {
if len(code) == 0 {
return 0, nil
}
return this.Query(tx).
ResultPk().
State(UserOrderStateEnabled).
Attr("code", code).
FindInt64Col(0)
}
// FindUserOrderWithCode 根据订单号查找订单
func (this *UserOrderDAO) FindUserOrderWithCode(tx *dbs.Tx, code string) (*UserOrder, error) {
if len(code) == 0 {
return nil, nil
}
one, err := this.Query(tx).
State(UserOrderStateEnabled).
Attr("code", code).
Find()
if err != nil || one == nil {
return nil, err
}
return one.(*UserOrder), nil
}

View File

@@ -0,0 +1,6 @@
package accounts_test
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

View File

@@ -0,0 +1,28 @@
package accounts
import (
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
type UserOrderLogDAO dbs.DAO
func NewUserOrderLogDAO() *UserOrderLogDAO {
return dbs.NewDAO(&UserOrderLogDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeUserOrderLogs",
Model: new(UserOrderLog),
PkName: "id",
},
}).(*UserOrderLogDAO)
}
var SharedUserOrderLogDAO *UserOrderLogDAO
func init() {
dbs.OnReady(func() {
SharedUserOrderLogDAO = NewUserOrderLogDAO()
})
}

View File

@@ -0,0 +1,21 @@
// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package accounts
import (
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
"github.com/iwind/TeaGo/dbs"
"time"
)
// CreateOrderLog 创建订单日志
func (this *UserOrderLogDAO) CreateOrderLog(tx *dbs.Tx, adminId int64, userId int64, orderId int64, status userconfigs.OrderStatus) error {
var op = NewUserOrderLogOperator()
op.AdminId = adminId
op.UserId = userId
op.OrderId = orderId
op.Status = status
op.CreatedAt = time.Now().Unix()
return this.Save(tx, op)
}

View File

@@ -0,0 +1,6 @@
package accounts_test
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

View File

@@ -0,0 +1,28 @@
package accounts
import "github.com/iwind/TeaGo/dbs"
// UserOrderLog 订单日志
type UserOrderLog struct {
Id uint64 `field:"id"` // ID
AdminId uint64 `field:"adminId"` // 管理员ID
UserId uint64 `field:"userId"` // 用户ID
OrderId uint64 `field:"orderId"` // 订单ID
Status string `field:"status"` // 状态
Snapshot dbs.JSON `field:"snapshot"` // 状态快照
CreatedAt uint64 `field:"createdAt"` // 创建时间
}
type UserOrderLogOperator struct {
Id interface{} // ID
AdminId interface{} // 管理员ID
UserId interface{} // 用户ID
OrderId interface{} // 订单ID
Status interface{} // 状态
Snapshot interface{} // 状态快照
CreatedAt interface{} // 创建时间
}
func NewUserOrderLogOperator() *UserOrderLogOperator {
return &UserOrderLogOperator{}
}

View File

@@ -0,0 +1 @@
package accounts

View File

@@ -0,0 +1,40 @@
package accounts
import "github.com/iwind/TeaGo/dbs"
// UserOrder 用户订单
type UserOrder struct {
Id uint64 `field:"id"` // 用户订单
UserId uint64 `field:"userId"` // 用户ID
Code string `field:"code"` // 订单号
Type string `field:"type"` // 订单类型
MethodId uint32 `field:"methodId"` // 支付方式
Status string `field:"status"` // 订单状态
Amount float64 `field:"amount"` // 金额
Params dbs.JSON `field:"params"` // 附加参数
ExpiredAt uint64 `field:"expiredAt"` // 过期时间
CreatedAt uint64 `field:"createdAt"` // 创建时间
CancelledAt uint64 `field:"cancelledAt"` // 取消时间
FinishedAt uint64 `field:"finishedAt"` // 结束时间
State uint8 `field:"state"` // 状态
}
type UserOrderOperator struct {
Id interface{} // 用户订单
UserId interface{} // 用户ID
Code interface{} // 订单号
Type interface{} // 订单类型
MethodId interface{} // 支付方式
Status interface{} // 订单状态
Amount interface{} // 金额
Params interface{} // 附加参数
ExpiredAt interface{} // 过期时间
CreatedAt interface{} // 创建时间
CancelledAt interface{} // 取消时间
FinishedAt interface{} // 结束时间
State interface{} // 状态
}
func NewUserOrderOperator() *UserOrderOperator {
return &UserOrderOperator{}
}

View File

@@ -0,0 +1 @@
package accounts

View File

@@ -0,0 +1,14 @@
//go:build plus
package accounts
import (
"github.com/TeaOSLab/EdgeCommon/pkg/userconfigs"
"time"
)
// IsExpired 检查当前订单是否已经过期
func (this *UserOrder) IsExpired() bool {
return this.Status == userconfigs.OrderStatusNone &&
time.Now().Unix() > int64(this.ExpiredAt)
}

View File

@@ -0,0 +1,54 @@
package acme
import (
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
type ACMEAuthenticationDAO dbs.DAO
func NewACMEAuthenticationDAO() *ACMEAuthenticationDAO {
return dbs.NewDAO(&ACMEAuthenticationDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeACMEAuthentications",
Model: new(ACMEAuthentication),
PkName: "id",
},
}).(*ACMEAuthenticationDAO)
}
var SharedACMEAuthenticationDAO *ACMEAuthenticationDAO
func init() {
dbs.OnReady(func() {
SharedACMEAuthenticationDAO = NewACMEAuthenticationDAO()
})
}
// 创建认证信息
func (this *ACMEAuthenticationDAO) CreateAuth(tx *dbs.Tx, taskId int64, domain string, token string, key string) error {
var op = NewACMEAuthenticationOperator()
op.TaskId = taskId
op.Domain = domain
op.Token = token
op.Key = key
err := this.Save(tx, op)
return err
}
// 根据令牌查找认证信息
func (this *ACMEAuthenticationDAO) FindAuthWithToken(tx *dbs.Tx, token string) (*ACMEAuthentication, error) {
one, err := this.Query(tx).
Attr("token", token).
DescPk().
Find()
if err != nil {
return nil, err
}
if one == nil {
return nil, nil
}
return one.(*ACMEAuthentication), nil
}

View File

@@ -0,0 +1,5 @@
package acme
import (
_ "github.com/go-sql-driver/mysql"
)

View File

@@ -0,0 +1,24 @@
package acme
// ACME认证
type ACMEAuthentication struct {
Id uint64 `field:"id"` // ID
TaskId uint64 `field:"taskId"` // 任务ID
Domain string `field:"domain"` // 域名
Token string `field:"token"` // 令牌
Key string `field:"key"` // 密钥
CreatedAt uint64 `field:"createdAt"` // 创建时间
}
type ACMEAuthenticationOperator struct {
Id interface{} // ID
TaskId interface{} // 任务ID
Domain interface{} // 域名
Token interface{} // 令牌
Key interface{} // 密钥
CreatedAt interface{} // 创建时间
}
func NewACMEAuthenticationOperator() *ACMEAuthenticationOperator {
return &ACMEAuthenticationOperator{}
}

View File

@@ -0,0 +1 @@
package acme

View File

@@ -0,0 +1,154 @@
package acme
import (
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
const (
ACMEProviderAccountStateEnabled = 1 // 已启用
ACMEProviderAccountStateDisabled = 0 // 已禁用
)
type ACMEProviderAccountDAO dbs.DAO
func NewACMEProviderAccountDAO() *ACMEProviderAccountDAO {
return dbs.NewDAO(&ACMEProviderAccountDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeACMEProviderAccounts",
Model: new(ACMEProviderAccount),
PkName: "id",
},
}).(*ACMEProviderAccountDAO)
}
var SharedACMEProviderAccountDAO *ACMEProviderAccountDAO
func init() {
dbs.OnReady(func() {
SharedACMEProviderAccountDAO = NewACMEProviderAccountDAO()
})
}
// EnableACMEProviderAccount 启用条目
func (this *ACMEProviderAccountDAO) EnableACMEProviderAccount(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ACMEProviderAccountStateEnabled).
Update()
return err
}
// DisableACMEProviderAccount 禁用条目
func (this *ACMEProviderAccountDAO) DisableACMEProviderAccount(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ACMEProviderAccountStateDisabled).
Update()
return err
}
// FindEnabledACMEProviderAccount 查找启用中的条目
func (this *ACMEProviderAccountDAO) FindEnabledACMEProviderAccount(tx *dbs.Tx, id int64) (*ACMEProviderAccount, error) {
result, err := this.Query(tx).
Pk(id).
Attr("state", ACMEProviderAccountStateEnabled).
Find()
if result == nil {
return nil, err
}
return result.(*ACMEProviderAccount), err
}
// FindACMEProviderAccountName 根据主键查找名称
func (this *ACMEProviderAccountDAO) FindACMEProviderAccountName(tx *dbs.Tx, id int64) (string, error) {
return this.Query(tx).
Pk(id).
Result("name").
FindStringCol("")
}
// CreateAccount 创建账号
func (this *ACMEProviderAccountDAO) CreateAccount(tx *dbs.Tx, userId int64, name string, providerCode string, eabKid string, eabKey string) (int64, error) {
var op = NewACMEProviderAccountOperator()
op.UserId = userId
op.Name = name
op.ProviderCode = providerCode
op.EabKid = eabKid
op.EabKey = eabKey
op.IsOn = true
op.State = ACMEProviderAccountStateEnabled
return this.SaveInt64(tx, op)
}
// UpdateAccount 修改账号
func (this *ACMEProviderAccountDAO) UpdateAccount(tx *dbs.Tx, accountId int64, name string, eabKid string, eabKey string) error {
if accountId <= 0 {
return errors.New("invalid accountId")
}
var op = NewACMEProviderAccountOperator()
op.Id = accountId
op.Name = name
op.EabKid = eabKid
op.EabKey = eabKey
return this.Save(tx, op)
}
// CountAllEnabledAccounts 计算账号数量
func (this *ACMEProviderAccountDAO) CountAllEnabledAccounts(tx *dbs.Tx, userId int64) (int64, error) {
return this.Query(tx).
State(ACMEProviderAccountStateEnabled).
Attr("userId", userId).
Count()
}
// ListEnabledAccounts 查找单页账号
func (this *ACMEProviderAccountDAO) ListEnabledAccounts(tx *dbs.Tx, userId int64, offset int64, size int64) (result []*ACMEProviderAccount, err error) {
_, err = this.Query(tx).
State(ACMEProviderAccountStateEnabled).
Attr("userId", userId).
Offset(offset).
Limit(size).
DescPk().
Slice(&result).
FindAll()
return
}
// FindAllEnabledAccountsWithProviderCode 根据服务商代号查找账号
func (this *ACMEProviderAccountDAO) FindAllEnabledAccountsWithProviderCode(tx *dbs.Tx, userId int64, providerCode string) (result []*ACMEProviderAccount, err error) {
_, err = this.Query(tx).
State(ACMEProviderAccountStateEnabled).
Attr("providerCode", providerCode).
Attr("userId", userId).
DescPk().
Slice(&result).
FindAll()
return
}
// CheckUserAccount 检查是否为用户的服务商账号
func (this *ACMEProviderAccountDAO) CheckUserAccount(tx *dbs.Tx, userId int64, accountId int64) error {
if userId <= 0 || accountId <= 0 {
return models.ErrNotFound
}
b, err := this.Query(tx).
Pk(accountId).
State(ACMEProviderAccountStateEnabled).
Attr("userId", userId).
Exist()
if err != nil {
return err
}
if !b {
return models.ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,6 @@
package acme
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

View File

@@ -0,0 +1,30 @@
package acme
// ACMEProviderAccount ACME提供商
type ACMEProviderAccount struct {
Id uint64 `field:"id"` // ID
UserId uint64 `field:"userId"` // 用户ID
IsOn bool `field:"isOn"` // 是否启用
Name string `field:"name"` // 名称
ProviderCode string `field:"providerCode"` // 代号
EabKid string `field:"eabKid"` // KID
EabKey string `field:"eabKey"` // Key
Error string `field:"error"` // 最后一条错误信息
State uint8 `field:"state"` // 状态
}
type ACMEProviderAccountOperator struct {
Id any // ID
UserId any // 用户ID
IsOn any // 是否启用
Name any // 名称
ProviderCode any // 代号
EabKid any // KID
EabKey any // Key
Error any // 最后一条错误信息
State any // 状态
}
func NewACMEProviderAccountOperator() *ACMEProviderAccountOperator {
return &ACMEProviderAccountOperator{}
}

View File

@@ -0,0 +1 @@
package acme

View File

@@ -0,0 +1,517 @@
package acme
import (
"bytes"
"context"
"encoding/json"
acmeutils "github.com/TeaOSLab/EdgeAPI/internal/acme"
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeAPI/internal/db/models/dns"
dbutils "github.com/TeaOSLab/EdgeAPI/internal/db/utils"
"github.com/TeaOSLab/EdgeAPI/internal/dnsclients"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/TeaOSLab/EdgeAPI/internal/remotelogs"
"github.com/TeaOSLab/EdgeAPI/internal/utils"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs"
"github.com/go-acme/lego/v4/registration"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/logs"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/types"
"net/http"
"time"
)
const (
ACMETaskStateEnabled = 1 // 已启用
ACMETaskStateDisabled = 0 // 已禁用
)
type ACMETaskDAO dbs.DAO
func NewACMETaskDAO() *ACMETaskDAO {
return dbs.NewDAO(&ACMETaskDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeACMETasks",
Model: new(ACMETask),
PkName: "id",
},
}).(*ACMETaskDAO)
}
var SharedACMETaskDAO *ACMETaskDAO
func init() {
dbs.OnReady(func() {
SharedACMETaskDAO = NewACMETaskDAO()
})
}
// EnableACMETask 启用条目
func (this *ACMETaskDAO) EnableACMETask(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ACMETaskStateEnabled).
Update()
return err
}
// DisableACMETask 禁用条目
func (this *ACMETaskDAO) DisableACMETask(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ACMETaskStateDisabled).
Update()
return err
}
// FindEnabledACMETask 查找启用中的条目
func (this *ACMETaskDAO) FindEnabledACMETask(tx *dbs.Tx, id int64) (*ACMETask, error) {
result, err := this.Query(tx).
Pk(id).
Attr("state", ACMETaskStateEnabled).
Find()
if result == nil {
return nil, err
}
return result.(*ACMETask), err
}
// CountACMETasksWithACMEUserId 计算某个ACME用户相关的任务数量
func (this *ACMETaskDAO) CountACMETasksWithACMEUserId(tx *dbs.Tx, acmeUserId int64) (int64, error) {
return this.Query(tx).
State(ACMETaskStateEnabled).
Attr("acmeUserId", acmeUserId).
Count()
}
// CountACMETasksWithDNSProviderId 计算某个DNS服务商相关的任务数量
func (this *ACMETaskDAO) CountACMETasksWithDNSProviderId(tx *dbs.Tx, dnsProviderId int64) (int64, error) {
return this.Query(tx).
State(ACMETaskStateEnabled).
Attr("dnsProviderId", dnsProviderId).
Count()
}
// DisableAllTasksWithCertId 停止某个证书相关任务
func (this *ACMETaskDAO) DisableAllTasksWithCertId(tx *dbs.Tx, certId int64) error {
_, err := this.Query(tx).
Attr("certId", certId).
Set("state", ACMETaskStateDisabled).
Update()
return err
}
// CountAllEnabledACMETasks 计算所有任务数量
func (this *ACMETaskDAO) CountAllEnabledACMETasks(tx *dbs.Tx, userId int64, isAvailable bool, isExpired bool, expiringDays int64, keyword string, userOnly bool) (int64, error) {
var query = this.Query(tx)
if userId > 0 {
query.Attr("userId", userId)
} else {
if userOnly {
query.Gt("userId", 0)
} else {
query.Attr("userId", 0)
}
}
if isAvailable || isExpired || expiringDays > 0 {
query.Gt("certId", 0)
if isAvailable {
query.Where("certId IN (SELECT id FROM " + models.SharedSSLCertDAO.Table + " WHERE timeBeginAt<=UNIX_TIMESTAMP() AND timeEndAt>=UNIX_TIMESTAMP())")
}
if isExpired {
query.Where("certId IN (SELECT id FROM " + models.SharedSSLCertDAO.Table + " WHERE timeEndAt<UNIX_TIMESTAMP())")
}
if expiringDays > 0 {
query.Where("certId IN (SELECT id FROM "+models.SharedSSLCertDAO.Table+" WHERE timeEndAt>UNIX_TIMESTAMP() AND timeEndAt<:expiredAt)").
Param("expiredAt", time.Now().Unix()+expiringDays*86400)
}
}
if len(keyword) > 0 {
query.Where("(domains LIKE :keyword)").
Param("keyword", dbutils.QuoteLike(keyword))
}
if len(keyword) > 0 {
query.Where("domains LIKE :keyword").
Param("keyword", dbutils.QuoteLike(keyword))
}
return query.State(ACMETaskStateEnabled).
Count()
}
// ListEnabledACMETasks 列出单页任务
func (this *ACMETaskDAO) ListEnabledACMETasks(tx *dbs.Tx, userId int64, isAvailable bool, isExpired bool, expiringDays int64, keyword string, userOnly bool, offset int64, size int64) (result []*ACMETask, err error) {
var query = this.Query(tx)
if userId > 0 {
query.Attr("userId", userId)
} else {
if userOnly {
query.Gt("userId", 0)
} else {
query.Attr("userId", 0)
}
}
if isAvailable || isExpired || expiringDays > 0 {
query.Gt("certId", 0)
if isAvailable {
query.Where("certId IN (SELECT id FROM " + models.SharedSSLCertDAO.Table + " WHERE timeBeginAt<=UNIX_TIMESTAMP() AND timeEndAt>=UNIX_TIMESTAMP())")
}
if isExpired {
query.Where("certId IN (SELECT id FROM " + models.SharedSSLCertDAO.Table + " WHERE timeEndAt<UNIX_TIMESTAMP())")
}
if expiringDays > 0 {
query.Where("certId IN (SELECT id FROM "+models.SharedSSLCertDAO.Table+" WHERE timeEndAt>UNIX_TIMESTAMP() AND timeEndAt<:expiredAt)").
Param("expiredAt", time.Now().Unix()+expiringDays*86400)
}
}
if len(keyword) > 0 {
query.Where("(domains LIKE :keyword)").
Param("keyword", dbutils.QuoteLike(keyword))
}
_, err = query.
State(ACMETaskStateEnabled).
DescPk().
Offset(offset).
Limit(size).
Slice(&result).
FindAll()
return
}
// CreateACMETask 创建任务
func (this *ACMETaskDAO) CreateACMETask(tx *dbs.Tx, adminId int64, userId int64, authType acmeutils.AuthType, acmeUserId int64, dnsProviderId int64, dnsDomain string, domains []string, autoRenew bool, authURL string) (int64, error) {
var op = NewACMETaskOperator()
op.AdminId = adminId
op.UserId = userId
op.AuthType = authType
op.AcmeUserId = acmeUserId
op.DnsProviderId = dnsProviderId
op.DnsDomain = dnsDomain
if len(domains) > 0 {
domainsJSON, err := json.Marshal(domains)
if err != nil {
return 0, err
}
op.Domains = domainsJSON
} else {
op.Domains = "[]"
}
op.AutoRenew = autoRenew
op.AuthURL = authURL
op.IsOn = true
op.State = ACMETaskStateEnabled
err := this.Save(tx, op)
if err != nil {
return 0, err
}
return types.Int64(op.Id), nil
}
// UpdateACMETask 修改任务
func (this *ACMETaskDAO) UpdateACMETask(tx *dbs.Tx, acmeTaskId int64, acmeUserId int64, dnsProviderId int64, dnsDomain string, domains []string, autoRenew bool, authURL string) error {
if acmeTaskId <= 0 {
return errors.New("invalid acmeTaskId")
}
var op = NewACMETaskOperator()
op.Id = acmeTaskId
op.AcmeUserId = acmeUserId
op.DnsProviderId = dnsProviderId
op.DnsDomain = dnsDomain
if len(domains) > 0 {
domainsJSON, err := json.Marshal(domains)
if err != nil {
return err
}
op.Domains = domainsJSON
} else {
op.Domains = "[]"
}
op.AutoRenew = autoRenew
op.AuthURL = authURL
err := this.Save(tx, op)
return err
}
// CheckUserACMETask 检查用户权限
func (this *ACMETaskDAO) CheckUserACMETask(tx *dbs.Tx, userId int64, acmeTaskId int64) (bool, error) {
var query = this.Query(tx)
if userId > 0 {
query.Attr("userId", userId)
}
return query.
State(ACMETaskStateEnabled).
Pk(acmeTaskId).
Exist()
}
// FindACMETaskUserId 查找任务所属用户ID
func (this *ACMETaskDAO) FindACMETaskUserId(tx *dbs.Tx, taskId int64) (userId int64, err error) {
return this.Query(tx).
Pk(taskId).
Result("userId").
FindInt64Col(0)
}
// UpdateACMETaskCert 设置任务关联的证书
func (this *ACMETaskDAO) UpdateACMETaskCert(tx *dbs.Tx, taskId int64, certId int64) error {
if taskId <= 0 {
return errors.New("invalid taskId")
}
var op = NewACMETaskOperator()
op.Id = taskId
op.CertId = certId
err := this.Save(tx, op)
return err
}
// RunTask 执行任务并记录日志
func (this *ACMETaskDAO) RunTask(tx *dbs.Tx, taskId int64) (isOk bool, errMsg string, resultCertId int64) {
isOk, errMsg, resultCertId = this.runTaskWithoutLog(tx, taskId)
// 记录日志
err := SharedACMETaskLogDAO.CreateACMETaskLog(tx, taskId, isOk, errMsg)
if err != nil {
logs.Error(err)
}
return
}
// 执行任务但并不记录日志
func (this *ACMETaskDAO) runTaskWithoutLog(tx *dbs.Tx, taskId int64) (isOk bool, errMsg string, resultCertId int64) {
task, err := this.FindEnabledACMETask(tx, taskId)
if err != nil {
errMsg = "查询任务信息时出错:" + err.Error()
return
}
if task == nil {
errMsg = "找不到要执行的任务"
return
}
if !task.IsOn {
errMsg = "任务没有启用"
return
}
// ACME用户
user, err := SharedACMEUserDAO.FindEnabledACMEUser(tx, int64(task.AcmeUserId))
if err != nil {
errMsg = "查询ACME用户时出错" + err.Error()
return
}
if user == nil {
errMsg = "找不到ACME用户"
return
}
// 服务商
if len(user.ProviderCode) == 0 {
user.ProviderCode = acmeutils.DefaultProviderCode
}
var acmeProvider = acmeutils.FindProviderWithCode(user.ProviderCode)
if acmeProvider == nil {
errMsg = "服务商已不可用"
return
}
// 账号
var acmeAccount *acmeutils.Account
if user.AccountId > 0 {
account, err := SharedACMEProviderAccountDAO.FindEnabledACMEProviderAccount(tx, int64(user.AccountId))
if err != nil {
errMsg = "查询ACME账号时出错" + err.Error()
return
}
if account != nil {
acmeAccount = &acmeutils.Account{
EABKid: account.EabKid,
EABKey: account.EabKey,
}
}
}
privateKey, err := acmeutils.ParsePrivateKeyFromBase64(user.PrivateKey)
if err != nil {
errMsg = "解析私钥时出错:" + err.Error()
return
}
var remoteUser = acmeutils.NewUser(user.Email, privateKey, func(resource *registration.Resource) error {
resourceJSON, err := json.Marshal(resource)
if err != nil {
return err
}
err = SharedACMEUserDAO.UpdateACMEUserRegistration(tx, int64(user.Id), resourceJSON)
return err
})
if len(user.Registration) > 0 {
err = remoteUser.SetRegistration(user.Registration)
if err != nil {
errMsg = "设置注册信息时出错:" + err.Error()
return
}
}
var acmeTask *acmeutils.Task = nil
if task.AuthType == acmeutils.AuthTypeDNS {
// DNS服务商
dnsProvider, err := dns.SharedDNSProviderDAO.FindEnabledDNSProvider(tx, int64(task.DnsProviderId))
if err != nil {
errMsg = "查找DNS服务商账号信息时出错" + err.Error()
return
}
if dnsProvider == nil {
errMsg = "找不到DNS服务商账号"
return
}
providerInterface := dnsclients.FindProvider(dnsProvider.Type, int64(dnsProvider.Id))
if providerInterface == nil {
errMsg = "暂不支持此类型的DNS服务商 '" + dnsProvider.Type + "'"
return
}
providerInterface.SetMinTTL(int32(dnsProvider.MinTTL))
apiParams, err := dnsProvider.DecodeAPIParams()
if err != nil {
errMsg = "解析DNS服务商API参数时出错" + err.Error()
return
}
err = providerInterface.Auth(apiParams)
if err != nil {
errMsg = "校验DNS服务商API参数时出错" + err.Error()
return
}
acmeTask = &acmeutils.Task{
User: remoteUser,
AuthType: acmeutils.AuthTypeDNS,
DNSProvider: providerInterface,
DNSDomain: task.DnsDomain,
Domains: task.DecodeDomains(),
}
} else if task.AuthType == acmeutils.AuthTypeHTTP {
acmeTask = &acmeutils.Task{
User: remoteUser,
AuthType: acmeutils.AuthTypeHTTP,
Domains: task.DecodeDomains(),
}
}
acmeTask.Provider = acmeProvider
acmeTask.Account = acmeAccount
var acmeRequest = acmeutils.NewRequest(acmeTask)
acmeRequest.OnAuth(func(domain, token, keyAuth string) {
err := SharedACMEAuthenticationDAO.CreateAuth(tx, taskId, domain, token, keyAuth)
if err != nil {
remotelogs.Error("ACME", "write authentication to database error: "+err.Error())
} else {
// 调用校验URL
if len(task.AuthURL) > 0 {
authJSON, err := json.Marshal(maps.Map{
"domain": domain,
"token": token,
"key": keyAuth,
})
if err != nil {
remotelogs.Error("ACME", "encode auth data failed: '"+task.AuthURL+"'")
} else {
var client = utils.SharedHttpClient(10 * time.Second)
req, err := http.NewRequest(http.MethodPost, task.AuthURL, bytes.NewReader(authJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", teaconst.ProductName+"/"+teaconst.Version)
if err != nil {
remotelogs.Error("ACME", "parse auth url failed '"+task.AuthURL+"': "+err.Error())
} else {
resp, err := client.Do(req)
if err != nil {
remotelogs.Error("ACME", "call auth url failed '"+task.AuthURL+"': "+err.Error())
} else {
_ = resp.Body.Close()
}
}
}
}
}
})
certData, keyData, err := acmeRequest.Run()
if err != nil {
errMsg = "证书生成失败:" + err.Error()
return
}
// 分析证书
var sslConfig = &sslconfigs.SSLCertConfig{
CertData: certData,
KeyData: keyData,
}
err = sslConfig.Init(context.Background())
if err != nil {
errMsg = "证书生成成功,但是分析证书信息时发生错误:" + err.Error()
return
}
// 保存证书
resultCertId = int64(task.CertId)
if resultCertId > 0 {
cert, err := models.SharedSSLCertDAO.FindEnabledSSLCert(tx, resultCertId)
if err != nil {
errMsg = "证书生成成功,但查询已绑定的证书时出错:" + err.Error()
return
}
if cert == nil {
errMsg = "证书已被管理员或用户删除"
// 禁用
err = SharedACMETaskDAO.DisableACMETask(tx, taskId)
if err != nil {
errMsg = "禁用失效的ACME任务出错" + err.Error()
}
return
}
err = models.SharedSSLCertDAO.UpdateCert(tx, resultCertId, cert.IsOn, cert.Name, cert.Description, cert.ServerName, cert.IsCA, certData, keyData, sslConfig.TimeBeginAt, sslConfig.TimeEndAt, sslConfig.DNSNames, sslConfig.CommonNames)
if err != nil {
errMsg = "证书生成成功,但是修改数据库中的证书信息时出错:" + err.Error()
return
}
} else {
resultCertId, err = models.SharedSSLCertDAO.CreateCert(tx, int64(task.AdminId), int64(task.UserId), true, task.DnsDomain+"免费证书", "免费申请的证书", "", false, certData, keyData, sslConfig.TimeBeginAt, sslConfig.TimeEndAt, sslConfig.DNSNames, sslConfig.CommonNames)
if err != nil {
errMsg = "证书生成成功,但是保存到数据库失败:" + err.Error()
return
}
err = models.SharedSSLCertDAO.UpdateCertACME(tx, resultCertId, int64(task.Id))
if err != nil {
errMsg = "证书生成成功修改证书ACME信息时出错" + err.Error()
return
}
// 设置成功
err = SharedACMETaskDAO.UpdateACMETaskCert(tx, taskId, resultCertId)
if err != nil {
errMsg = "证书生成成功,设置任务关联的证书时出错:" + err.Error()
return
}
}
isOk = true
return
}

View File

@@ -0,0 +1,5 @@
package acme
import (
_ "github.com/go-sql-driver/mysql"
)

View File

@@ -0,0 +1,51 @@
package acme
import (
"github.com/TeaOSLab/EdgeAPI/internal/utils"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
type ACMETaskLogDAO dbs.DAO
func NewACMETaskLogDAO() *ACMETaskLogDAO {
return dbs.NewDAO(&ACMETaskLogDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeACMETaskLogs",
Model: new(ACMETaskLog),
PkName: "id",
},
}).(*ACMETaskLogDAO)
}
var SharedACMETaskLogDAO *ACMETaskLogDAO
func init() {
dbs.OnReady(func() {
SharedACMETaskLogDAO = NewACMETaskLogDAO()
})
}
// CreateACMETaskLog 生成日志
func (this *ACMETaskLogDAO) CreateACMETaskLog(tx *dbs.Tx, taskId int64, isOk bool, errMsg string) error {
var op = NewACMETaskLogOperator()
op.TaskId = taskId
op.Error = utils.LimitString(errMsg, 1024)
op.IsOk = isOk
err := this.Save(tx, op)
return err
}
// FindLatestACMETasKLog 取得任务的最后一条执行日志
func (this *ACMETaskLogDAO) FindLatestACMETasKLog(tx *dbs.Tx, taskId int64) (*ACMETaskLog, error) {
one, err := this.Query(tx).
Attr("taskId", taskId).
DescPk().
Find()
if err != nil || one == nil {
return nil, err
}
return one.(*ACMETaskLog), nil
}

View File

@@ -0,0 +1,5 @@
package acme
import (
_ "github.com/go-sql-driver/mysql"
)

View File

@@ -0,0 +1,22 @@
package acme
// ACMETaskLog ACME任务运行日志
type ACMETaskLog struct {
Id uint64 `field:"id"` // ID
TaskId uint64 `field:"taskId"` // 任务ID
IsOk bool `field:"isOk"` // 是否成功
Error string `field:"error"` // 错误信息
CreatedAt uint64 `field:"createdAt"` // 运行时间
}
type ACMETaskLogOperator struct {
Id interface{} // ID
TaskId interface{} // 任务ID
IsOk interface{} // 是否成功
Error interface{} // 错误信息
CreatedAt interface{} // 运行时间
}
func NewACMETaskLogOperator() *ACMETaskLogOperator {
return &ACMETaskLogOperator{}
}

View File

@@ -0,0 +1 @@
package acme

View File

@@ -0,0 +1,42 @@
package acme
import "github.com/iwind/TeaGo/dbs"
// ACMETask ACME任务
type ACMETask struct {
Id uint64 `field:"id"` // ID
AdminId uint32 `field:"adminId"` // 管理员ID
UserId uint32 `field:"userId"` // 用户ID
IsOn bool `field:"isOn"` // 是否启用
AcmeUserId uint32 `field:"acmeUserId"` // ACME用户ID
DnsDomain string `field:"dnsDomain"` // DNS主域名
DnsProviderId uint64 `field:"dnsProviderId"` // DNS服务商
Domains dbs.JSON `field:"domains"` // 证书域名
CreatedAt uint64 `field:"createdAt"` // 创建时间
State uint8 `field:"state"` // 状态
CertId uint64 `field:"certId"` // 生成的证书ID
AutoRenew uint8 `field:"autoRenew"` // 是否自动更新
AuthType string `field:"authType"` // 认证类型
AuthURL string `field:"authURL"` // 认证URL
}
type ACMETaskOperator struct {
Id interface{} // ID
AdminId interface{} // 管理员ID
UserId interface{} // 用户ID
IsOn interface{} // 是否启用
AcmeUserId interface{} // ACME用户ID
DnsDomain interface{} // DNS主域名
DnsProviderId interface{} // DNS服务商
Domains interface{} // 证书域名
CreatedAt interface{} // 创建时间
State interface{} // 状态
CertId interface{} // 生成的证书ID
AutoRenew interface{} // 是否自动更新
AuthType interface{} // 认证类型
AuthURL interface{} // 认证URL
}
func NewACMETaskOperator() *ACMETaskOperator {
return &ACMETaskOperator{}
}

View File

@@ -0,0 +1,20 @@
package acme
import (
"encoding/json"
"github.com/iwind/TeaGo/logs"
)
// DecodeDomains 将域名解析成字符串数组
func (this *ACMETask) DecodeDomains() []string {
if len(this.Domains) == 0 {
return nil
}
result := []string{}
err := json.Unmarshal(this.Domains, &result)
if err != nil {
logs.Error(err)
return nil
}
return result
}

View File

@@ -0,0 +1,211 @@
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/types"
)
const (
ACMEUserStateEnabled = 1 // 已启用
ACMEUserStateDisabled = 0 // 已禁用
)
type ACMEUserDAO dbs.DAO
func NewACMEUserDAO() *ACMEUserDAO {
return dbs.NewDAO(&ACMEUserDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeACMEUsers",
Model: new(ACMEUser),
PkName: "id",
},
}).(*ACMEUserDAO)
}
var SharedACMEUserDAO *ACMEUserDAO
func init() {
dbs.OnReady(func() {
SharedACMEUserDAO = NewACMEUserDAO()
})
}
// EnableACMEUser 启用条目
func (this *ACMEUserDAO) EnableACMEUser(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ACMEUserStateEnabled).
Update()
return err
}
// DisableACMEUser 禁用条目
func (this *ACMEUserDAO) DisableACMEUser(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ACMEUserStateDisabled).
Update()
return err
}
// 查找启用中的条目
func (this *ACMEUserDAO) FindEnabledACMEUser(tx *dbs.Tx, id int64) (*ACMEUser, error) {
result, err := this.Query(tx).
Pk(id).
Attr("state", ACMEUserStateEnabled).
Find()
if result == nil {
return nil, err
}
return result.(*ACMEUser), err
}
// CreateACMEUser 创建用户
func (this *ACMEUserDAO) CreateACMEUser(tx *dbs.Tx, adminId int64, userId int64, providerCode string, accountId int64, email string, description string) (int64, error) {
// 生成私钥
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return 0, err
}
privateKeyData, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return 0, err
}
privateKeyText := base64.StdEncoding.EncodeToString(privateKeyData)
var op = NewACMEUserOperator()
op.AdminId = adminId
op.UserId = userId
op.ProviderCode = providerCode
op.AccountId = accountId
op.Email = email
op.Description = description
op.PrivateKey = privateKeyText
op.State = ACMEUserStateEnabled
err = this.Save(tx, op)
if err != nil {
return 0, err
}
return types.Int64(op.Id), nil
}
// UpdateACMEUser 修改用户信息
func (this *ACMEUserDAO) UpdateACMEUser(tx *dbs.Tx, acmeUserId int64, description string) error {
if acmeUserId <= 0 {
return errors.New("invalid acmeUserId")
}
var op = NewACMEUserOperator()
op.Id = acmeUserId
op.Description = description
err := this.Save(tx, op)
return err
}
// UpdateACMEUserRegistration 修改用户ACME注册信息
func (this *ACMEUserDAO) UpdateACMEUserRegistration(tx *dbs.Tx, acmeUserId int64, registrationJSON []byte) error {
if acmeUserId <= 0 {
return errors.New("invalid acmeUserId")
}
var op = NewACMEUserOperator()
op.Id = acmeUserId
op.Registration = registrationJSON
err := this.Save(tx, op)
return err
}
// CountACMEUsersWithAdminId 计算用户数量
func (this *ACMEUserDAO) CountACMEUsersWithAdminId(tx *dbs.Tx, adminId int64, userId int64, accountId int64) (int64, error) {
query := this.Query(tx)
if adminId > 0 {
query.Attr("adminId", adminId)
}
if userId > 0 {
query.Attr("userId", userId)
} else {
query.Attr("userId", 0)
}
if accountId > 0 {
query.Attr("accountId", accountId)
}
return query.
State(ACMEUserStateEnabled).
Count()
}
// ListACMEUsers 列出当前管理员的用户
func (this *ACMEUserDAO) ListACMEUsers(tx *dbs.Tx, adminId int64, userId int64, offset int64, size int64) (result []*ACMEUser, err error) {
query := this.Query(tx)
if adminId > 0 {
query.Attr("adminId", adminId)
}
if userId > 0 {
query.Attr("userId", userId)
} else {
query.Attr("userId", 0)
}
_, err = query.
State(ACMEUserStateEnabled).
Offset(offset).
Limit(size).
Slice(&result).
DescPk().
FindAll()
return
}
// FindAllACMEUsers 查找所有用户
func (this *ACMEUserDAO) FindAllACMEUsers(tx *dbs.Tx, adminId int64, userId int64, providerCode string) (result []*ACMEUser, err error) {
// 防止没有传入条件导致返回的数据过多
if adminId <= 0 && userId <= 0 {
return nil, errors.New("'adminId' or 'userId' should not be empty")
}
query := this.Query(tx)
if adminId > 0 {
query.Attr("adminId", adminId)
}
if userId > 0 {
query.Attr("userId", userId)
}
if len(providerCode) > 0 {
query.Attr("providerCode", providerCode)
}
_, err = query.
State(ACMEUserStateEnabled).
Slice(&result).
DescPk().
FindAll()
return
}
// CheckACMEUser 检查用户权限
func (this *ACMEUserDAO) CheckACMEUser(tx *dbs.Tx, acmeUserId int64, adminId int64, userId int64) (bool, error) {
if acmeUserId <= 0 {
return false, nil
}
query := this.Query(tx)
if adminId > 0 {
query.Attr("adminId", adminId)
} else if userId > 0 {
query.Attr("userId", userId)
} else {
return false, nil
}
return query.
State(ACMEUserStateEnabled).
Exist()
}

View File

@@ -0,0 +1,5 @@
package acme
import (
_ "github.com/go-sql-driver/mysql"
)

View File

@@ -0,0 +1,36 @@
package acme
import "github.com/iwind/TeaGo/dbs"
// ACMEUser ACME用户
type ACMEUser struct {
Id uint64 `field:"id"` // ID
AdminId uint32 `field:"adminId"` // 管理员ID
UserId uint32 `field:"userId"` // 用户ID
PrivateKey string `field:"privateKey"` // 私钥
Email string `field:"email"` // E-mail
CreatedAt uint64 `field:"createdAt"` // 创建时间
State uint8 `field:"state"` // 状态
Description string `field:"description"` // 备注介绍
Registration dbs.JSON `field:"registration"` // 注册信息
ProviderCode string `field:"providerCode"` // 服务商代号
AccountId uint64 `field:"accountId"` // 提供商ID
}
type ACMEUserOperator struct {
Id interface{} // ID
AdminId interface{} // 管理员ID
UserId interface{} // 用户ID
PrivateKey interface{} // 私钥
Email interface{} // E-mail
CreatedAt interface{} // 创建时间
State interface{} // 状态
Description interface{} // 备注介绍
Registration interface{} // 注册信息
ProviderCode interface{} // 服务商代号
AccountId interface{} // 提供商ID
}
func NewACMEUserOperator() *ACMEUserOperator {
return &ACMEUserOperator{}
}

View File

@@ -0,0 +1 @@
package acme

View File

@@ -0,0 +1,71 @@
package models
import (
_ "github.com/go-sql-driver/mysql"
"github.com/iwind/TeaGo/Tea"
"github.com/iwind/TeaGo/dbs"
)
const (
ADNetworkStateEnabled = 1 // 已启用
ADNetworkStateDisabled = 0 // 已禁用
)
type ADNetworkDAO dbs.DAO
func NewADNetworkDAO() *ADNetworkDAO {
return dbs.NewDAO(&ADNetworkDAO{
DAOObject: dbs.DAOObject{
DB: Tea.Env,
Table: "edgeADNetworks",
Model: new(ADNetwork),
PkName: "id",
},
}).(*ADNetworkDAO)
}
var SharedADNetworkDAO *ADNetworkDAO
func init() {
dbs.OnReady(func() {
SharedADNetworkDAO = NewADNetworkDAO()
})
}
// EnableADNetwork 启用条目
func (this *ADNetworkDAO) EnableADNetwork(tx *dbs.Tx, id uint32) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ADNetworkStateEnabled).
Update()
return err
}
// DisableADNetwork 禁用条目
func (this *ADNetworkDAO) DisableADNetwork(tx *dbs.Tx, id int64) error {
_, err := this.Query(tx).
Pk(id).
Set("state", ADNetworkStateDisabled).
Update()
return err
}
// FindEnabledADNetwork 查找启用中的条目
func (this *ADNetworkDAO) FindEnabledADNetwork(tx *dbs.Tx, id int64) (*ADNetwork, error) {
result, err := this.Query(tx).
Pk(id).
State(ADNetworkStateEnabled).
Find()
if result == nil {
return nil, err
}
return result.(*ADNetwork), err
}
// FindADNetworkName 根据主键查找名称
func (this *ADNetworkDAO) FindADNetworkName(tx *dbs.Tx, id uint32) (string, error) {
return this.Query(tx).
Pk(id).
Result("name").
FindStringCol("")
}

View File

@@ -0,0 +1,56 @@
// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn .
//go:build plus
package models
import (
"errors"
"github.com/iwind/TeaGo/dbs"
)
// CreateNetwork 创建线路
func (this *ADNetworkDAO) CreateNetwork(tx *dbs.Tx, name string, description string) (int64, error) {
var op = NewADNetworkOperator()
op.Name = name
op.Description = description
op.IsOn = true
op.State = ADNetworkStateEnabled
return this.SaveInt64(tx, op)
}
// UpdateNetwork 修改线路
func (this *ADNetworkDAO) UpdateNetwork(tx *dbs.Tx, networkId int64, isOn bool, name string, description string) error {
if networkId <= 0 {
return errors.New("invalid networkId")
}
var op = NewADNetworkOperator()
op.Id = networkId
op.Name = name
op.Description = description
op.IsOn = isOn
return this.Save(tx, op)
}
// FindAllNetworks 列出所有线路
func (this *ADNetworkDAO) FindAllNetworks(tx *dbs.Tx) (result []*ADNetwork, err error) {
_, err = this.Query(tx).
State(ADNetworkStateEnabled).
Desc("order").
AscPk().
Slice(&result).
FindAll()
return
}
// FindAllAvailableNetworks 列出所有可用的线路
func (this *ADNetworkDAO) FindAllAvailableNetworks(tx *dbs.Tx) (result []*ADNetwork, err error) {
_, err = this.Query(tx).
State(ADNetworkStateEnabled).
Attr("isOn", true).
Desc("order").
AscPk().
Slice(&result).
FindAll()
return
}

View File

@@ -0,0 +1,22 @@
//go:build plus
package models_test
import (
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
"github.com/iwind/TeaGo/dbs"
"github.com/iwind/TeaGo/logs"
"testing"
)
func TestADNetworkDAO_FindAllNetworks(t *testing.T) {
var dao = models.NewADNetworkDAO()
var tx *dbs.Tx
networks, err := dao.FindAllNetworks(tx)
if err != nil {
t.Fatal(err)
}
logs.PrintAsJSON(networks, t)
}

View File

@@ -0,0 +1,6 @@
package models_test
import (
_ "github.com/go-sql-driver/mysql"
_ "github.com/iwind/TeaGo/bootstrap"
)

Some files were not shown because too many files have changed in this diff Show More