Initial commit: OpenVPN Manager v1.0
OpenVPN Web management console with multi-instance support, client cert issuance, traffic/connection auditing, certificate expiry reminders, auto backup/restore. Stack: - Backend: Go 1.21+ (Gin + JWT) - Frontend: Vue 3 + Element Plus + ECharts + Vite - Storage: JSON file (db.json) + filesystem (pki/, instances/, clients/, backups/) Features: - Multi-instance OpenVPN management (independent port/proto/subnet/PKI) - One-click client certificate issuance with .ovpn (embedded certs) - Certificate expiry reminders (30-day threshold) - Connection log parsing (status-version 3) - Auto backup/restore (tar.gz) - Audit log for all write operations - JWT auth (12h TTL) - One-line install.sh for Ubuntu/Debian/RHEL/Fedora
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"openvpn-manager/internal/api"
|
||||
"openvpn-manager/internal/config"
|
||||
"openvpn-manager/internal/service"
|
||||
"openvpn-manager/internal/store"
|
||||
"openvpn-manager/pkg/openvpn"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data", "", "data directory (default $OVPNMGR_DATA or ./data)")
|
||||
distDir := flag.String("dist", "", "frontend dist directory (default $OVPNMGR_DIST or ./dist)")
|
||||
flag.Parse()
|
||||
|
||||
if *dataDir == "" {
|
||||
*dataDir = os.Getenv("OVPNMGR_DATA")
|
||||
}
|
||||
if *dataDir == "" {
|
||||
*dataDir = "./data"
|
||||
}
|
||||
if *distDir == "" {
|
||||
*distDir = os.Getenv("OVPNMGR_DIST")
|
||||
}
|
||||
if *distDir == "" {
|
||||
*distDir = "./dist"
|
||||
}
|
||||
abs, _ := filepath.Abs(*dataDir)
|
||||
cfg := config.Load(abs)
|
||||
|
||||
if err := os.MkdirAll(cfg.PKIDir(), 0o700); err != nil {
|
||||
log.Fatalf("mkdir pki: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.InstancesDir(), 0o755); err != nil {
|
||||
log.Fatalf("mkdir instances: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.ClientsDir(), 0o755); err != nil {
|
||||
log.Fatalf("mkdir clients: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
||||
log.Fatalf("mkdir backups: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.DBFile())
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
ovm := openvpn.NewManager(cfg.OpenVPNBin, cfg.DataDir)
|
||||
if err := ovm.EnsureCA(); err != nil {
|
||||
log.Printf("warn: ensure CA: %v", err)
|
||||
}
|
||||
svc := service.New(cfg, st, ovm)
|
||||
|
||||
addr := cfg.Host + ":" + itoa(cfg.Port)
|
||||
log.Printf("openvpn-manager listening on %s, data=%s, dist=%s", addr, cfg.DataDir, *distDir)
|
||||
srv := api.NewServer(cfg, svc)
|
||||
if err := srv.Router(*distDir).Run(addr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if n < 0 {
|
||||
neg = true
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
module openvpn-manager
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/cors v1.7.7 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,402 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"openvpn-manager/internal/config"
|
||||
"openvpn-manager/internal/middleware"
|
||||
"openvpn-manager/internal/model"
|
||||
"openvpn-manager/internal/service"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Cfg *config.Config
|
||||
Svc *service.Service
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, svc *service.Service) *Server {
|
||||
return &Server{Cfg: cfg, Svc: svc}
|
||||
}
|
||||
|
||||
func (s *Server) Router(distDir string) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
// 公共
|
||||
r.POST("/api/login", s.login)
|
||||
r.GET("/api/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
auth := r.Group("/api", middleware.JWTAuth(s.Cfg.JWTSecret))
|
||||
{
|
||||
auth.GET("/me", s.me)
|
||||
auth.POST("/logout", s.logout)
|
||||
auth.GET("/dashboard", s.dashboard)
|
||||
|
||||
auth.GET("/instances", s.listInstances)
|
||||
auth.POST("/instances", s.createInstance)
|
||||
auth.GET("/instances/:id", s.getInstance)
|
||||
auth.PUT("/instances/:id", s.updateInstance)
|
||||
auth.DELETE("/instances/:id", s.deleteInstance)
|
||||
auth.POST("/instances/:id/start", s.startInstance)
|
||||
auth.POST("/instances/:id/stop", s.stopInstance)
|
||||
auth.GET("/instances/:id/online", s.onlineClients)
|
||||
|
||||
auth.GET("/instances/:id/users", s.listUsers)
|
||||
auth.POST("/instances/:id/users", s.createUser)
|
||||
auth.POST("/instances/:id/users/:uid/revoke", s.revokeUser)
|
||||
auth.DELETE("/instances/:id/users/:uid", s.deleteUser)
|
||||
auth.GET("/instances/:id/users/:uid/ovpn", s.downloadOVPN)
|
||||
|
||||
auth.GET("/certs", s.listCerts)
|
||||
|
||||
auth.GET("/connlogs", s.listConnLogs)
|
||||
|
||||
auth.GET("/backups", s.listBackups)
|
||||
auth.POST("/backups", s.createBackup)
|
||||
auth.POST("/backups/:id/restore", s.restoreBackup)
|
||||
auth.DELETE("/backups/:id", s.deleteBackup)
|
||||
|
||||
auth.GET("/audits", s.listAudits)
|
||||
}
|
||||
|
||||
// 静态前端
|
||||
if distDir != "" {
|
||||
if _, err := os.Stat(distDir); err == nil {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := filepath.Join(distDir, c.Request.URL.Path)
|
||||
if !fileExists(path) || strings.HasSuffix(c.Request.URL.Path, "/") {
|
||||
c.File(filepath.Join(distDir, "index.html"))
|
||||
return
|
||||
}
|
||||
c.File(path)
|
||||
})
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && !fi.IsDir()
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
func (s *Server) login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if req.Username != s.Cfg.AdminUser || req.Password != s.Cfg.AdminPass {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
tok, err := middleware.IssueToken(s.Cfg.JWTSecret, req.Username, "admin", 12*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"token": tok, "username": req.Username})
|
||||
}
|
||||
|
||||
func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||
|
||||
func (s *Server) me(c *gin.Context) {
|
||||
u, _ := c.Get("user")
|
||||
c.JSON(200, gin.H{"username": u})
|
||||
}
|
||||
|
||||
// dashboard 汇总统计
|
||||
func (s *Server) dashboard(c *gin.Context) {
|
||||
instances := s.Svc.Store.ListInstances()
|
||||
users := s.Svc.Store.ListUsers("")
|
||||
certs, _ := s.Svc.CertInfos()
|
||||
expiring := 0
|
||||
for _, ct := range certs {
|
||||
if ct.DaysLeft <= 30 {
|
||||
expiring++
|
||||
}
|
||||
}
|
||||
online := 0
|
||||
for _, in := range instances {
|
||||
cl, _ := s.Svc.ListOnline(in.ID)
|
||||
online += len(cl)
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"instances": len(instances),
|
||||
"running": countByStatus(instances, "running"),
|
||||
"users": len(users),
|
||||
"active_users": countEnabled(users),
|
||||
"online": online,
|
||||
"expiring_certs": expiring,
|
||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- instances ----
|
||||
|
||||
func (s *Server) listInstances(c *gin.Context) {
|
||||
c.JSON(200, s.Svc.Store.ListInstances())
|
||||
}
|
||||
|
||||
func (s *Server) getInstance(c *gin.Context) {
|
||||
in, err := s.Svc.Store.GetInstance(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, in)
|
||||
}
|
||||
|
||||
func (s *Server) createInstance(c *gin.Context) {
|
||||
var in model.Instance
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(400, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
out, err := s.Svc.CreateInstance(in)
|
||||
if err != nil {
|
||||
s.Svc.AuditForAPI(c, "create_instance", in.Name, err.Error(), "failed")
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "create_instance", in.Name, "port="+itoa(out.Port), "ok")
|
||||
c.JSON(200, out)
|
||||
}
|
||||
|
||||
func (s *Server) updateInstance(c *gin.Context) {
|
||||
var in model.Instance
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(400, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
in.ID = c.Param("id")
|
||||
if err := s.Svc.UpdateInstance(in); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "update_instance", in.Name, "", "ok")
|
||||
c.JSON(200, in)
|
||||
}
|
||||
|
||||
func (s *Server) deleteInstance(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := s.Svc.DeleteInstance(id); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "delete_instance", id, "", "ok")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) startInstance(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := s.Svc.StartInstance(id); err != nil {
|
||||
s.Svc.AuditForAPI(c, "start_instance", id, err.Error(), "failed")
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "start_instance", id, "", "ok")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) stopInstance(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := s.Svc.StopInstance(id); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "stop_instance", id, "", "ok")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) onlineClients(c *gin.Context) {
|
||||
cl, err := s.Svc.ListOnline(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, cl)
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
func (s *Server) listUsers(c *gin.Context) {
|
||||
c.JSON(200, s.Svc.Store.ListUsers(c.Param("id")))
|
||||
}
|
||||
|
||||
func (s *Server) createUser(c *gin.Context) {
|
||||
var u model.VPNUser
|
||||
if err := c.ShouldBindJSON(&u); err != nil {
|
||||
c.JSON(400, gin.H{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
u.InstanceID = c.Param("id")
|
||||
out, err := s.Svc.CreateUser(u)
|
||||
if err != nil {
|
||||
s.Svc.AuditForAPI(c, "create_user", u.Username, err.Error(), "failed")
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "create_user", u.Username, "instance="+u.InstanceID, "ok")
|
||||
c.JSON(200, out)
|
||||
}
|
||||
|
||||
func (s *Server) revokeUser(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
if err := s.Svc.RevokeUser(uid); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "revoke_user", uid, "", "ok")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
if err := s.Svc.DeleteUser(uid); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "delete_user", uid, "", "ok")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) downloadOVPN(c *gin.Context) {
|
||||
host := c.Query("host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
p, err := s.Svc.GenerateOVPN(c.Param("uid"), host)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename="+filepath.Base(p))
|
||||
c.File(p)
|
||||
}
|
||||
|
||||
// ---- certs ----
|
||||
|
||||
func (s *Server) listCerts(c *gin.Context) {
|
||||
certs, err := s.Svc.CertInfos()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, certs)
|
||||
}
|
||||
|
||||
// ---- conn logs ----
|
||||
|
||||
func (s *Server) listConnLogs(c *gin.Context) {
|
||||
c.JSON(200, s.Svc.Store.ListConnLogs(c.Query("instance"), 200))
|
||||
}
|
||||
|
||||
// ---- backups ----
|
||||
|
||||
func (s *Server) listBackups(c *gin.Context) {
|
||||
c.JSON(200, s.Svc.Store.ListBackups())
|
||||
}
|
||||
|
||||
func (s *Server) createBackup(c *gin.Context) {
|
||||
var req struct{ Note string `json:"note"` }
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
b, err := s.Svc.Backup(req.Note)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "create_backup", b.ID, "", "ok")
|
||||
c.JSON(200, b)
|
||||
}
|
||||
|
||||
func (s *Server) restoreBackup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := s.Svc.Restore(id); err != nil {
|
||||
s.Svc.AuditForAPI(c, "restore_backup", id, err.Error(), "failed")
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.Svc.AuditForAPI(c, "restore_backup", id, "", "ok")
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) deleteBackup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := s.Svc.DeleteBackup(id); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ---- audits ----
|
||||
|
||||
func (s *Server) listAudits(c *gin.Context) {
|
||||
c.JSON(200, s.Svc.Store.ListAudits(500))
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func countByStatus(in []model.Instance, status string) int {
|
||||
n := 0
|
||||
for _, x := range in {
|
||||
if x.Status == status {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countEnabled(u []model.VPNUser) int {
|
||||
n := 0
|
||||
for _, x := range u {
|
||||
if x.Enabled {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if n < 0 {
|
||||
neg = true
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config 包含 Web 管理界面的运行时配置。
|
||||
// 数据目录结构:
|
||||
// <DataDir>/
|
||||
// config.yaml - 全局配置
|
||||
// db.json - 用户/实例元数据(简化存储)
|
||||
// pki/ - CA 与证书(PKI 基础设施)
|
||||
// ca.crt / ca.key / dh.pem / ta.key
|
||||
// instances/<name>/ - 每个 OpenVPN 实例的目录
|
||||
// server.conf
|
||||
// pki/{issued,private,csd}
|
||||
// ccd/ - 客户端配置目录
|
||||
// status.log - openvpn --status 周期输出
|
||||
// logs/ - openvpn 运行日志
|
||||
// clients/<inst>/<user>/ - 生成的 .ovpn 文件
|
||||
// backups/ - 备份归档
|
||||
|
||||
type Config struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
AdminUser string `yaml:"admin_user"`
|
||||
AdminPass string `yaml:"admin_pass"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
OpenVPNBin string `yaml:"openvpn_bin"`
|
||||
EasyrsaBin string `yaml:"easyrsa_bin"`
|
||||
}
|
||||
|
||||
func Load(dataDir string) *Config {
|
||||
c := &Config{
|
||||
Host: getenv("OVPNMGR_HOST", "0.0.0.0"),
|
||||
Port: getenvInt("OVPNMGR_PORT", 8089),
|
||||
DataDir: dataDir,
|
||||
JWTSecret: getenv("OVPNMGR_JWT_SECRET", "change-me-in-prod-please"),
|
||||
AdminUser: getenv("OVPNMGR_ADMIN_USER", "admin"),
|
||||
AdminPass: getenv("OVPNMGR_ADMIN_PASS", "admin123"),
|
||||
LogLevel: getenv("OVPNMGR_LOG_LEVEL", "info"),
|
||||
OpenVPNBin: getenv("OVPNMGR_OPENVPN_BIN", "openvpn"),
|
||||
EasyrsaBin: getenv("OVPNMGR_EASYRSA_BIN", "easyrsa"),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Config) PKIDir() string { return filepath.Join(c.DataDir, "pki") }
|
||||
func (c *Config) InstancesDir() string { return filepath.Join(c.DataDir, "instances") }
|
||||
func (c *Config) InstanceDir(name string) string {
|
||||
return filepath.Join(c.InstancesDir(), name)
|
||||
}
|
||||
func (c *Config) ClientsDir() string { return filepath.Join(c.DataDir, "clients") }
|
||||
func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") }
|
||||
func (c *Config) DBFile() string { return filepath.Join(c.DataDir, "db.json") }
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvInt(k string, def int) int {
|
||||
v := os.Getenv(k)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func IssueToken(secret, username, role string, ttl time.Duration) (string, error) {
|
||||
c := Claims{
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
||||
return t.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func JWTAuth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.GetHeader("Authorization")
|
||||
if h == "" {
|
||||
h = c.Query("token")
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
if strings.HasPrefix(h, prefix) {
|
||||
h = strings.TrimPrefix(h, prefix)
|
||||
}
|
||||
if h == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(h, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set("user", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Instance 一个 OpenVPN 服务端实例。
|
||||
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
|
||||
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // 唯一名,作为目录名
|
||||
Port int `json:"port"` // openvpn 监听端口
|
||||
Proto string `json:"proto"` // udp / tcp
|
||||
Dev string `json:"dev"` // tun / tap
|
||||
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
|
||||
Cipher string `json:"cipher"` // 加密算法
|
||||
AuthDigest string `json:"auth_digest"` // 摘要算法
|
||||
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
|
||||
PushRoutes string `json:"push_routes"` // 多行
|
||||
Extra string `json:"extra"` // 用户追加配置
|
||||
Status string `json:"status"` // running/stopped/error
|
||||
PID int `json:"pid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VPNUser 一个 OpenVPN 客户端用户。
|
||||
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
|
||||
type VPNUser struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Username string `json:"username"` // 证书 CN
|
||||
RealName string `json:"real_name"` // 备注
|
||||
Email string `json:"email"`
|
||||
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
|
||||
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuditLog 操作审计日志
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
User string `json:"user"` // 操作者(管理用户)
|
||||
Action string `json:"action"` // create_instance, revoke_user, ...
|
||||
Target string `json:"target"` // 目标对象
|
||||
Result string `json:"result"` // ok / failed
|
||||
Detail string `json:"detail"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
|
||||
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
|
||||
type ConnectionLog struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
CommonName string `json:"common_name"`
|
||||
RealIP string `json:"real_ip"` // 客户端公网 IP
|
||||
VPNIP string `json:"vpn_ip"` // 分配的 VPN 内网 IP
|
||||
BytesIn int64 `json:"bytes_in"`
|
||||
BytesOut int64 `json:"bytes_out"`
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
|
||||
}
|
||||
|
||||
// Backup 一份备份归档
|
||||
type Backup struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Size int64 `json:"size"`
|
||||
Note string `json:"note"`
|
||||
Filename string `json:"filename"`
|
||||
Includes []string `json:"includes"`
|
||||
}
|
||||
|
||||
// CertInfo 证书元数据(用于证书到期提醒)。
|
||||
type CertInfo struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Username string `json:"username"`
|
||||
Subject string `json:"subject"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"openvpn-manager/internal/config"
|
||||
"openvpn-manager/internal/model"
|
||||
"openvpn-manager/internal/store"
|
||||
"openvpn-manager/pkg/openvpn"
|
||||
)
|
||||
|
||||
// Service 业务逻辑聚合,供 API 层调用。
|
||||
// 任何对实例/用户/证书/备份的变更都应经过这里,从而写入审计日志。
|
||||
type Service struct {
|
||||
Cfg *config.Config
|
||||
Store *store.Store
|
||||
Ovm *openvpn.Manager
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, st *store.Store, ovm *openvpn.Manager) *Service {
|
||||
return &Service{Cfg: cfg, Store: st, Ovm: ovm}
|
||||
}
|
||||
|
||||
func (s *Service) audit(c context.Context, action, target, detail, result, ip string) {
|
||||
username, _ := c.Value("user").(string)
|
||||
if username == "" {
|
||||
username = "system"
|
||||
}
|
||||
_ = s.Store.AppendAudit(model.AuditLog{
|
||||
ID: uuid.NewString(),
|
||||
Time: time.Now(),
|
||||
User: username,
|
||||
Action: action,
|
||||
Target: target,
|
||||
Result: result,
|
||||
Detail: detail,
|
||||
IP: ip,
|
||||
})
|
||||
}
|
||||
|
||||
// AuditForAPI 在 API 层被调用时手动写入(因为 gin context 转为 context.Context)。
|
||||
func (s *Service) AuditForAPI(c *gin.Context, action, target, detail, result string) {
|
||||
username, _ := c.Get("user")
|
||||
un, _ := username.(string)
|
||||
if un == "" {
|
||||
un = "system"
|
||||
}
|
||||
_ = s.Store.AppendAudit(model.AuditLog{
|
||||
ID: uuid.NewString(),
|
||||
Time: time.Now(),
|
||||
User: un,
|
||||
Action: action,
|
||||
Target: target,
|
||||
Result: result,
|
||||
Detail: detail,
|
||||
IP: c.ClientIP(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateInstance 新建一个 OpenVPN 实例,并签发服务端证书、生成 server.conf。
|
||||
func (s *Service) CreateInstance(in model.Instance) (*model.Instance, error) {
|
||||
if in.Name == "" {
|
||||
return nil, fmt.Errorf("name required")
|
||||
}
|
||||
if in.Port == 0 {
|
||||
return nil, fmt.Errorf("port required")
|
||||
}
|
||||
if in.Proto == "" {
|
||||
in.Proto = "udp"
|
||||
}
|
||||
if in.Dev == "" {
|
||||
in.Dev = "tun"
|
||||
}
|
||||
if in.Subnet == "" {
|
||||
in.Subnet = "10.8.0.0/24"
|
||||
}
|
||||
// 名称查重
|
||||
if _, err := s.Store.GetInstanceByName(in.Name); err == nil {
|
||||
return nil, fmt.Errorf("instance %s already exists", in.Name)
|
||||
}
|
||||
// 确保 CA
|
||||
if err := s.Ovm.EnsureCA(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.ID = uuid.NewString()
|
||||
in.Status = "stopped"
|
||||
in.CreatedAt = time.Now()
|
||||
in.UpdatedAt = time.Now()
|
||||
// 创建实例目录
|
||||
if err := os.MkdirAll(s.Cfg.InstanceDir(in.Name), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs"), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 签发服务端证书
|
||||
if err := s.Ovm.IssueServerCert(in.Name); err != nil {
|
||||
return nil, fmt.Errorf("issue server cert: %w", err)
|
||||
}
|
||||
// 写 server.conf
|
||||
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||||
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||||
return nil, fmt.Errorf("write conf: %w", err)
|
||||
}
|
||||
if err := s.Store.UpsertInstance(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &in, nil
|
||||
}
|
||||
|
||||
// UpdateInstance 仅更新可热改字段(端口/子网需要重启生效)。
|
||||
func (s *Service) UpdateInstance(in model.Instance) error {
|
||||
old, err := s.Store.GetInstance(in.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.CreatedAt = old.CreatedAt
|
||||
in.UpdatedAt = time.Now()
|
||||
in.Status = old.Status
|
||||
in.PID = old.PID
|
||||
ccdDir := filepath.Join(s.Cfg.InstanceDir(in.Name), "ccd")
|
||||
if err := s.Ovm.WriteServerConf(&in, ccdDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.UpsertInstance(in)
|
||||
}
|
||||
|
||||
// DeleteInstance 移除实例及其 PKI/配置。
|
||||
func (s *Service) DeleteInstance(id string) error {
|
||||
in, err := s.Store.GetInstance(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Status == "running" {
|
||||
_ = s.StopInstance(id)
|
||||
}
|
||||
// 清理用户记录与目录
|
||||
for _, u := range s.Store.ListUsers(id) {
|
||||
_ = s.Store.DeleteUser(u.ID)
|
||||
}
|
||||
_ = os.RemoveAll(s.Cfg.InstanceDir(in.Name))
|
||||
_ = os.RemoveAll(filepath.Join(s.Cfg.ClientsDir(), in.Name))
|
||||
return s.Store.DeleteInstance(id)
|
||||
}
|
||||
|
||||
// StartInstance 在前台启动 openvpn。
|
||||
// 注意:本服务应以 root 运行;非 root 场景下应通过 systemd 单元托管。
|
||||
func (s *Service) StartInstance(id string) error {
|
||||
in, err := s.Store.GetInstance(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Status == "running" {
|
||||
return fmt.Errorf("already running")
|
||||
}
|
||||
conf := s.Ovm.InstanceConf(in.Name)
|
||||
if _, err := os.Stat(conf); err != nil {
|
||||
return fmt.Errorf("conf missing: %w", err)
|
||||
}
|
||||
cmd := exec.Command(s.Cfg.OpenVPNBin,
|
||||
"--cd", s.Cfg.InstanceDir(in.Name),
|
||||
"--config", conf)
|
||||
logf, _ := os.OpenFile(filepath.Join(s.Cfg.InstanceDir(in.Name), "logs", "openvpn-stdout.log"),
|
||||
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if logf != nil {
|
||||
cmd.Stdout = logf
|
||||
cmd.Stderr = logf
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
in.Status = "running"
|
||||
in.PID = cmd.Process.Pid
|
||||
in.UpdatedAt = time.Now()
|
||||
_ = s.Store.UpsertInstance(*in)
|
||||
// 后台释放
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
// 进程退出时回写状态(简单模型)
|
||||
cur, err := s.Store.GetInstance(id)
|
||||
if err == nil && cur.PID == cmd.Process.Pid {
|
||||
cur.Status = "stopped"
|
||||
cur.PID = 0
|
||||
cur.UpdatedAt = time.Now()
|
||||
_ = s.Store.UpsertInstance(*cur)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopInstance 通过 SIGTERM 停止实例。
|
||||
func (s *Service) StopInstance(id string) error {
|
||||
in, err := s.Store.GetInstance(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.PID == 0 {
|
||||
in.Status = "stopped"
|
||||
return s.Store.UpsertInstance(*in)
|
||||
}
|
||||
proc, err := os.FindProcess(in.PID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil {
|
||||
// 兜底:直接 Kill
|
||||
_ = proc.Signal(os.Kill)
|
||||
}
|
||||
in.Status = "stopped"
|
||||
in.PID = 0
|
||||
in.UpdatedAt = time.Now()
|
||||
return s.Store.UpsertInstance(*in)
|
||||
}
|
||||
|
||||
// CreateUser 新建客户端用户并签发证书。
|
||||
func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
||||
if u.Username == "" {
|
||||
return nil, fmt.Errorf("username required")
|
||||
}
|
||||
in, err := s.Store.GetInstance(u.InstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.Store.GetUserByCN(u.InstanceID, u.Username); err == nil {
|
||||
return nil, fmt.Errorf("user %s already exists", u.Username)
|
||||
}
|
||||
u.ID = uuid.NewString()
|
||||
u.Enabled = true
|
||||
u.CreatedAt = time.Now()
|
||||
// 签发证书(Manager 按实例名索引 PKI)
|
||||
if _, _, err := s.Ovm.IssueCert(in.Name, u.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CCD
|
||||
if u.StaticIP != "" {
|
||||
ccd := "ifconfig-push " + u.StaticIP + " 255.255.255.0\n"
|
||||
if err := s.Ovm.WriteCCD(in.Name, u.Username, ccd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 预生成 ovpn(以空 host 生成占位,用户在 UI 上下载)
|
||||
if _, err := s.Ovm.GenerateClientOVPNFor(&u, in, "vpn.example.com"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Store.UpsertUser(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GenerateOVPN 下载/重新生成 .ovpn,remoteHost 由前端传入。
|
||||
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
||||
u, err := s.Store.GetUser(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
in, err := s.Store.GetInstance(u.InstanceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost)
|
||||
}
|
||||
|
||||
// RevokeUser 吊销用户:禁用 + 标记。
|
||||
func (s *Service) RevokeUser(userID string) error {
|
||||
u, err := s.Store.GetUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Enabled = false
|
||||
now := time.Now()
|
||||
u.RevokedAt = &now
|
||||
if err := s.Store.UpsertUser(*u); err != nil {
|
||||
return err
|
||||
}
|
||||
// 在 ccd 写入禁用标记
|
||||
in, err2 := s.Store.GetInstance(u.InstanceID)
|
||||
if err2 == nil {
|
||||
body := "# revoked by manager\n"
|
||||
_ = s.Ovm.WriteCCD(in.Name, u.Username, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser 删除用户及证书。
|
||||
func (s *Service) DeleteUser(userID string) error {
|
||||
u, err := s.Store.GetUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in, _ := s.Store.GetInstance(u.InstanceID)
|
||||
if in != nil {
|
||||
_ = s.Ovm.DeleteCCD(in.Name, u.Username)
|
||||
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "issued", u.Username+".crt"))
|
||||
_ = os.Remove(filepath.Join(s.Ovm.PKIPath(in.Name), "private", u.Username+".key"))
|
||||
_ = os.Remove(filepath.Join(s.Cfg.ClientsDir(), in.Name, u.Username+".ovpn"))
|
||||
}
|
||||
return s.Store.DeleteUser(userID)
|
||||
}
|
||||
|
||||
// CertInfos 汇总所有用户证书的过期时间。
|
||||
func (s *Service) CertInfos() ([]model.CertInfo, error) {
|
||||
var out []model.CertInfo
|
||||
now := time.Now()
|
||||
for _, in := range s.Store.ListInstances() {
|
||||
pki := s.Ovm.PKIPath(in.Name)
|
||||
issuedDir := filepath.Join(pki, "issued")
|
||||
entries, err := os.ReadDir(issuedDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".crt") {
|
||||
continue
|
||||
}
|
||||
cn := strings.TrimSuffix(e.Name(), ".crt")
|
||||
if cn == "server" {
|
||||
continue
|
||||
}
|
||||
notAfter, err := s.Ovm.CertNotAfter(filepath.Join(issuedDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, model.CertInfo{
|
||||
InstanceID: in.ID,
|
||||
Username: cn,
|
||||
NotAfter: notAfter,
|
||||
DaysLeft: int(notAfter.Sub(now).Hours() / 24),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListOnline 解析 status 文件获取在线客户端。
|
||||
func (s *Service) ListOnline(instanceID string) ([]openvpn.StatusEntry, error) {
|
||||
in, err := s.Store.GetInstance(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
|
||||
return s.Ovm.ParseStatus(statusPath)
|
||||
}
|
||||
|
||||
// Backup 创建 tar.gz 备份。
|
||||
func (s *Service) Backup(note string) (*model.Backup, error) {
|
||||
if err := os.MkdirAll(s.Cfg.BackupsDir(), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ts := time.Now().Format("20060102-150405")
|
||||
fp := filepath.Join(s.Cfg.BackupsDir(), "backup-"+ts+"-"+id[:8]+".tar.gz")
|
||||
f, err := os.Create(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
add := func(rel string) error {
|
||||
abs := filepath.Join(s.Cfg.DataDir, rel)
|
||||
return filepath.Walk(abs, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
hdr, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
hdr.Name = filepath.ToSlash(filepath.Join(rel, strings.TrimPrefix(path, abs)))
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_, _ = tw.Write(data)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
for _, sub := range []string{"pki", "instances", "clients"} {
|
||||
_ = add(sub)
|
||||
}
|
||||
b := &model.Backup{
|
||||
ID: id,
|
||||
CreatedAt: time.Now(),
|
||||
Filename: filepath.Base(fp),
|
||||
Note: note,
|
||||
Includes: []string{"pki", "instances", "clients"},
|
||||
}
|
||||
fi, _ := os.Stat(fp)
|
||||
if fi != nil {
|
||||
b.Size = fi.Size()
|
||||
}
|
||||
_ = s.Store.AddBackup(*b)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Restore 从备份恢复。会覆盖现有数据。
|
||||
func (s *Service) Restore(backupID string) error {
|
||||
var bk *model.Backup
|
||||
for _, b := range s.Store.ListBackups() {
|
||||
if b.ID == backupID {
|
||||
b := b
|
||||
bk = &b
|
||||
break
|
||||
}
|
||||
}
|
||||
if bk == nil {
|
||||
return fmt.Errorf("backup not found")
|
||||
}
|
||||
src := filepath.Join(s.Cfg.BackupsDir(), bk.Filename)
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var r io.Reader = f
|
||||
if strings.HasSuffix(src, ".gz") {
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
r = gz
|
||||
}
|
||||
tr := tar.NewReader(r)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(s.Cfg.DataDir, hdr.Name)
|
||||
if hdr.FileInfo().IsDir() {
|
||||
_ = os.MkdirAll(target, 0o755)
|
||||
continue
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(target), 0o755)
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(out, tr)
|
||||
_ = out.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBackup 删除备份文件与索引。
|
||||
func (s *Service) DeleteBackup(id string) error {
|
||||
for _, b := range s.Store.ListBackups() {
|
||||
if b.ID == id {
|
||||
_ = os.Remove(filepath.Join(s.Cfg.BackupsDir(), b.Filename))
|
||||
return s.Store.DeleteBackup(id)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
// RandomToken 生成短随机串。
|
||||
func RandomToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"openvpn-manager/internal/model"
|
||||
)
|
||||
|
||||
// Store 简易 JSON 文件存储:
|
||||
// 适合中小规模运维工具,无需引入数据库。所有变更通过 RWMutex 保护。
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
data Data
|
||||
writeCh chan struct{}
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Instances []model.Instance `json:"instances"`
|
||||
Users []model.VPNUser `json:"users"`
|
||||
Audits []model.AuditLog `json:"audits"`
|
||||
ConnLogs []model.ConnectionLog `json:"conn_logs"`
|
||||
Backups []model.Backup `json:"backups"`
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
s.data = Data{}
|
||||
if err := s.flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) == 0 {
|
||||
s.data = Data{}
|
||||
return s, nil
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.data); err != nil {
|
||||
return nil, fmt.Errorf("parse db: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) flush() error {
|
||||
b, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// ---- Instances ----
|
||||
|
||||
func (s *Store) ListInstances() []model.Instance {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.Instance, len(s.data.Instances))
|
||||
copy(out, s.data.Instances)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) GetInstance(id string) (*model.Instance, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.data.Instances {
|
||||
if s.data.Instances[i].ID == id {
|
||||
in := s.data.Instances[i]
|
||||
return &in, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", id)
|
||||
}
|
||||
|
||||
func (s *Store) GetInstanceByName(name string) (*model.Instance, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.data.Instances {
|
||||
if s.data.Instances[i].Name == name {
|
||||
in := s.data.Instances[i]
|
||||
return &in, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", name)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertInstance(in model.Instance) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.data.Instances {
|
||||
if s.data.Instances[i].ID == in.ID {
|
||||
s.data.Instances[i] = in
|
||||
return s.flush()
|
||||
}
|
||||
}
|
||||
s.data.Instances = append(s.data.Instances, in)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteInstance(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.data.Instances {
|
||||
if s.data.Instances[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("instance %s not found", id)
|
||||
}
|
||||
s.data.Instances = append(s.data.Instances[:idx], s.data.Instances[idx+1:]...)
|
||||
// 同步删除其用户
|
||||
users := s.data.Users[:0]
|
||||
for _, u := range s.data.Users {
|
||||
if u.InstanceID != id {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
s.data.Users = users
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
|
||||
func (s *Store) ListUsers(instanceID string) []model.VPNUser {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := []model.VPNUser{}
|
||||
for _, u := range s.data.Users {
|
||||
if instanceID == "" || u.InstanceID == instanceID {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(id string) (*model.VPNUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.data.Users {
|
||||
if s.data.Users[i].ID == id {
|
||||
u := s.data.Users[i]
|
||||
return &u, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("user %s not found", id)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByCN(instanceID, cn string) (*model.VPNUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.data.Users {
|
||||
if s.data.Users[i].InstanceID == instanceID && s.data.Users[i].Username == cn {
|
||||
u := s.data.Users[i]
|
||||
return &u, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("user %s/%s not found", instanceID, cn)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertUser(u model.VPNUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.data.Users {
|
||||
if s.data.Users[i].ID == u.ID {
|
||||
s.data.Users[i] = u
|
||||
return s.flush()
|
||||
}
|
||||
}
|
||||
s.data.Users = append(s.data.Users, u)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.data.Users {
|
||||
if s.data.Users[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("user %s not found", id)
|
||||
}
|
||||
s.data.Users = append(s.data.Users[:idx], s.data.Users[idx+1:]...)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
// ---- Audit ----
|
||||
|
||||
func (s *Store) AppendAudit(a model.AuditLog) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.Audits = append(s.data.Audits, a)
|
||||
// 仅保留最近 5000 条
|
||||
if len(s.data.Audits) > 5000 {
|
||||
s.data.Audits = s.data.Audits[len(s.data.Audits)-5000:]
|
||||
}
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) ListAudits(limit int) []model.AuditLog {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if limit <= 0 || limit > len(s.data.Audits) {
|
||||
limit = len(s.data.Audits)
|
||||
}
|
||||
out := make([]model.AuditLog, limit)
|
||||
copy(out, s.data.Audits[len(s.data.Audits)-limit:])
|
||||
// 倒序
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- Connection Logs ----
|
||||
|
||||
func (s *Store) AppendConnLog(c model.ConnectionLog) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.ConnLogs = append(s.data.ConnLogs, c)
|
||||
if len(s.data.ConnLogs) > 20000 {
|
||||
s.data.ConnLogs = s.data.ConnLogs[len(s.data.ConnLogs)-20000:]
|
||||
}
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) ListConnLogs(instanceID string, limit int) []model.ConnectionLog {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := []model.ConnectionLog{}
|
||||
for i := len(s.data.ConnLogs) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
c := s.data.ConnLogs[i]
|
||||
if instanceID == "" || c.InstanceID == instanceID {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) FindActiveConn(instanceID, commonName string) *model.ConnectionLog {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
||||
c := s.data.ConnLogs[i]
|
||||
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
||||
cc := c
|
||||
return &cc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CloseActiveConn(instanceID, commonName string, at time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
||||
c := &s.data.ConnLogs[i]
|
||||
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
||||
c.DisconnectedAt = &at
|
||||
return s.flush()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Backups ----
|
||||
|
||||
func (s *Store) ListBackups() []model.Backup {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.Backup, len(s.data.Backups))
|
||||
copy(out, s.data.Backups)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) AddBackup(b model.Backup) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.Backups = append(s.data.Backups, b)
|
||||
return s.flush()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBackup(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.data.Backups {
|
||||
if s.data.Backups[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("backup %s not found", id)
|
||||
}
|
||||
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
|
||||
return s.flush()
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openvpn-manager/internal/model"
|
||||
)
|
||||
|
||||
// Manager 封装与本地 OpenVPN 的交互:生成 server.conf、生成证书、解析 status。
|
||||
// 本服务不直接以 root 启动 OpenVPN 进程(避免安全/权限问题),而是在用户机器上
|
||||
// 由 systemd / launchctl / 自定义脚本拉起。Manager 负责:
|
||||
// 1. 生成易用的 server.conf / client.ovpn / ccd
|
||||
// 2. 解析 openvpn --status 输出
|
||||
// 3. 调用 openssl/easyrsa 完成证书签发与吊销
|
||||
// 4. 提供 ping 命令检查进程可达性
|
||||
type Manager struct {
|
||||
binary string
|
||||
dataDir string
|
||||
pkiDir string
|
||||
}
|
||||
|
||||
func NewManager(binary, dataDir string) *Manager {
|
||||
return &Manager{
|
||||
binary: binary,
|
||||
dataDir: dataDir,
|
||||
pkiDir: filepath.Join(dataDir, "pki"),
|
||||
}
|
||||
}
|
||||
|
||||
// PKIPath 返回实例的 PKI 目录(用于存放 issued/private 等子目录)。
|
||||
func (m *Manager) PKIPath(instanceName string) string {
|
||||
return filepath.Join(m.dataDir, "instances", instanceName, "pki")
|
||||
}
|
||||
|
||||
// InstanceConf 返回实例 server.conf 路径。
|
||||
func (m *Manager) InstanceConf(instanceName string) string {
|
||||
return filepath.Join(m.dataDir, "instances", instanceName, "server.conf")
|
||||
}
|
||||
|
||||
// EnsureCA 初始化全局 CA。幂等。
|
||||
func (m *Manager) EnsureCA() error {
|
||||
if _, err := os.Stat(filepath.Join(m.pkiDir, "ca.crt")); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(m.pkiDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
// 使用 openssl 直接生成自签 CA,避免依赖 easyrsa
|
||||
if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "`+m.pkiDir+`/ca.key" \
|
||||
-out "`+m.pkiDir+`/ca.crt" \
|
||||
-days 3650 -subj "/CN=OpenVPN-Manager-CA" 2>/dev/null`); err != nil {
|
||||
return fmt.Errorf("generate CA: %w", err)
|
||||
}
|
||||
// DH 参数(轻量: 1024,生产可改为 2048/4096)
|
||||
if _, err := os.Stat(filepath.Join(m.pkiDir, "dh.pem")); os.IsNotExist(err) {
|
||||
if err := runShell(`openssl dhparam -out "` + m.pkiDir + `/dh.pem" 1024 2>/dev/null`); err != nil {
|
||||
return fmt.Errorf("generate DH: %w", err)
|
||||
}
|
||||
}
|
||||
// TLS-Auth key
|
||||
if _, err := os.Stat(filepath.Join(m.pkiDir, "ta.key")); os.IsNotExist(err) {
|
||||
if err := runShell(`openvpn --genkey secret "` + m.pkiDir + `/ta.key"`); err != nil {
|
||||
return fmt.Errorf("generate ta.key: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteServerConf 生成 server.conf。
|
||||
func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
|
||||
dir := filepath.Dir(m.InstanceConf(in.Name))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
conf := strings.Builder{}
|
||||
conf.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
|
||||
conf.WriteString("port " + strconv.Itoa(in.Port) + "\n")
|
||||
conf.WriteString("proto " + in.Proto + "\n")
|
||||
conf.WriteString("dev " + in.Dev + "\n")
|
||||
conf.WriteString("ca " + filepath.Join(m.pkiDir, "ca.crt") + "\n")
|
||||
conf.WriteString("cert " + filepath.Join(m.PKIPath(in.Name), "issued", "server.crt") + "\n")
|
||||
conf.WriteString("key " + filepath.Join(m.PKIPath(in.Name), "private", "server.key") + "\n")
|
||||
conf.WriteString("dh " + filepath.Join(m.pkiDir, "dh.pem") + "\n")
|
||||
conf.WriteString("tls-auth " + filepath.Join(m.pkiDir, "ta.key") + " 0\n")
|
||||
conf.WriteString("topology subnet\n")
|
||||
conf.WriteString("server " + in.Subnet + "\n")
|
||||
conf.WriteString("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\n")
|
||||
conf.WriteString("keepalive 10 120\n")
|
||||
conf.WriteString("persist-key\npersist-tun\n")
|
||||
conf.WriteString("status " + filepath.Join(dir, "status.log") + " 10\n")
|
||||
conf.WriteString("status-version 3\n")
|
||||
conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n")
|
||||
conf.WriteString("verb 3\n")
|
||||
conf.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n")
|
||||
conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
|
||||
if in.PushDNS != "" {
|
||||
for _, line := range strings.Split(in.PushDNS, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
conf.WriteString("push \"" + line + "\"\n")
|
||||
}
|
||||
}
|
||||
if in.PushRoutes != "" {
|
||||
for _, line := range strings.Split(in.PushRoutes, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
conf.WriteString("push \"route " + line + "\"\n")
|
||||
}
|
||||
}
|
||||
if extraDir != "" {
|
||||
conf.WriteString("client-config-dir " + extraDir + "\n")
|
||||
}
|
||||
if in.Extra != "" {
|
||||
conf.WriteString("\n# --- custom ---\n")
|
||||
conf.WriteString(in.Extra + "\n")
|
||||
}
|
||||
return os.WriteFile(m.InstanceConf(in.Name), []byte(conf.String()), 0o644)
|
||||
}
|
||||
|
||||
// IssueCert 为客户端签发证书。clientName = CN。
|
||||
// 返回 (certPath, keyPath, error)。
|
||||
func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) {
|
||||
pki := m.PKIPath(instanceName)
|
||||
issuedDir := filepath.Join(pki, "issued")
|
||||
privDir := filepath.Join(pki, "private")
|
||||
certsDir := filepath.Join(pki, "certs")
|
||||
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := os.MkdirAll(privDir, 0o700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := os.MkdirAll(certsDir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
crt := filepath.Join(issuedDir, clientName+".crt")
|
||||
key := filepath.Join(privDir, clientName+".key")
|
||||
csr := filepath.Join(pki, clientName+".csr")
|
||||
// 已存在则跳过
|
||||
if _, err := os.Stat(crt); err == nil {
|
||||
return crt, key, nil
|
||||
}
|
||||
// 生成私钥
|
||||
if err := runShell(fmt.Sprintf(`openssl genrsa -out "%s" 2048 2>/dev/null`, key)); err != nil {
|
||||
return "", "", fmt.Errorf("gen key: %w", err)
|
||||
}
|
||||
// 生成 CSR
|
||||
if err := runShell(fmt.Sprintf(`openssl req -new -key "%s" -out "%s" -subj "/CN=%s" 2>/dev/null`,
|
||||
key, csr, clientName)); err != nil {
|
||||
return "", "", fmt.Errorf("gen csr: %w", err)
|
||||
}
|
||||
// 用 CA 签发
|
||||
caCrt := filepath.Join(m.pkiDir, "ca.crt")
|
||||
caKey := filepath.Join(m.pkiDir, "ca.key")
|
||||
if err := runShell(fmt.Sprintf(`openssl x509 -req -in "%s" -CA "%s" -CAkey "%s" -CAcreateserial \
|
||||
-out "%s" -days 3650 -sha256 2>/dev/null`, csr, caCrt, caKey, crt)); err != nil {
|
||||
return "", "", fmt.Errorf("sign cert: %w", err)
|
||||
}
|
||||
_ = os.Remove(csr)
|
||||
return crt, key, nil
|
||||
}
|
||||
|
||||
// IssueServerCert 为实例本身签发服务端证书。
|
||||
func (m *Manager) IssueServerCert(instanceName string) error {
|
||||
_, _, err := m.IssueCert(instanceName, "server")
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeCert 通过 CA 吊销证书(生成 CRL)。
|
||||
// 由于完整 CRL 链路较重,本管理器使用更简单的"禁用"模型:保留吊销标记,
|
||||
// 并通过 ccd 与 enabled=false 让 openvpn 拒绝连接。
|
||||
func (m *Manager) RevokeCert(instanceName, clientName string) error {
|
||||
_ = instanceName
|
||||
_ = clientName
|
||||
// 简化模型:不做 OpenSSL 吊销,仅通过 Store 标记 + 强制策略。
|
||||
// 真正的吊销可通过 `openssl ca -revoke` 扩展。
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateClientOVPN 生成 .ovpn 客户端配置,含内嵌证书便于分发。
|
||||
func (m *Manager) GenerateClientOVPN(in *model.Instance, username, remoteHost string) (string, error) {
|
||||
pki := m.PKIPath(in.Name)
|
||||
crt, err := os.ReadFile(filepath.Join(pki, "issued", username+".crt"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := os.ReadFile(filepath.Join(pki, "private", username+".key"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
caCrt, err := os.ReadFile(filepath.Join(m.pkiDir, "ca.crt"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
taKey, err := os.ReadFile(filepath.Join(m.pkiDir, "ta.key"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if remoteHost == "" {
|
||||
remoteHost = "vpn.example.com"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("client\n")
|
||||
b.WriteString("dev " + in.Dev + "\n")
|
||||
b.WriteString("proto " + in.Proto + "\n")
|
||||
b.WriteString("remote " + remoteHost + " " + strconv.Itoa(in.Port) + "\n")
|
||||
b.WriteString("resolv-retry infinite\n")
|
||||
b.WriteString("nobind\n")
|
||||
b.WriteString("persist-key\npersist-tun\n")
|
||||
b.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n")
|
||||
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
|
||||
b.WriteString("remote-cert-tls server\n")
|
||||
b.WriteString("verb 3\n")
|
||||
b.WriteString("\n<ca>\n")
|
||||
b.WriteString(string(caCrt))
|
||||
b.WriteString("\n</ca>\n")
|
||||
b.WriteString("\n<cert>\n")
|
||||
b.WriteString(string(crt))
|
||||
b.WriteString("\n</cert>\n")
|
||||
b.WriteString("\n<key>\n")
|
||||
b.WriteString(string(key))
|
||||
b.WriteString("\n</key>\n")
|
||||
b.WriteString("\n<tls-auth>\n")
|
||||
b.WriteString(string(taKey))
|
||||
b.WriteString("\n</tls-auth>\n")
|
||||
b.WriteString("key-direction 1\n")
|
||||
out := filepath.Join(m.dataDir, "clients", in.Name, username+".ovpn")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GenerateClientOVPNFor 是基于 VPNUser + Instance 的便捷封装。
|
||||
func (m *Manager) GenerateClientOVPNFor(u *model.VPNUser, in *model.Instance, remoteHost string) (string, error) {
|
||||
return m.GenerateClientOVPN(in, u.Username, remoteHost)
|
||||
}
|
||||
|
||||
// WriteCCD 写入客户端静态配置(固定 IP 等)。
|
||||
func (m *Manager) WriteCCD(instanceName, username, body string) error {
|
||||
dir := filepath.Join(m.dataDir, "instances", instanceName, "ccd")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, username), []byte(body+"\n"), 0o644)
|
||||
}
|
||||
|
||||
// DeleteCCD 移除 ccd 文件。
|
||||
func (m *Manager) DeleteCCD(instanceName, username string) error {
|
||||
p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username)
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseStatus 解析 openvpn --status 输出。
|
||||
// 输入是 status-version 3 的文本,格式详见 OpenVPN 文档。
|
||||
func (m *Manager) ParseStatus(path string) ([]StatusEntry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseStatusReader(f)
|
||||
}
|
||||
|
||||
// StatusEntry 是 status 文件中的一条 CLIENT 记录。
|
||||
type StatusEntry struct {
|
||||
CommonName string
|
||||
RealAddress string // IP:port
|
||||
VPNAddress string // 客户端 VPN 内网 IP
|
||||
BytesRecv int64
|
||||
BytesSent int64
|
||||
ConnectedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
||||
reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),(\d+),(\d+),`)
|
||||
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
|
||||
)
|
||||
|
||||
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
var out []StatusEntry
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "CLIENT_LIST,"):
|
||||
m := reCli.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2])
|
||||
out = append(out, StatusEntry{
|
||||
CommonName: m[1],
|
||||
RealAddress: m[3],
|
||||
VPNAddress: m[4],
|
||||
BytesRecv: atoi64(m[5]),
|
||||
BytesSent: atoi64(m[6]),
|
||||
ConnectedAt: connected,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
|
||||
func (m *Manager) IsRunning(host string, port int) bool {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// CertNotAfter 解析证书的过期时间。
|
||||
func (m *Manager) CertNotAfter(certPath string) (time.Time, error) {
|
||||
b, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
block, _ := pem.Decode(b)
|
||||
if block == nil {
|
||||
return time.Time{}, fmt.Errorf("not a pem file")
|
||||
}
|
||||
c, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return c.NotAfter, nil
|
||||
}
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
func runShell(s string) error {
|
||||
cmd := exec.Command("bash", "-c", s)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func atoi64(s string) int64 {
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func pemTrim(b []byte) string {
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
Reference in New Issue
Block a user