feat: add compatible Trojan manager image
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
ARG RUNTIME_BASE=jrohy/trojan@sha256:f559fac7e9960614032e520c59405cec0e8937eca009a8169cb1d188f2b5ccb9
|
||||||
|
FROM node:20.11.1-bookworm AS frontend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY trojan-web-master/package.json trojan-web-master/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY trojan-web-master/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.21.6-bookworm AS manager-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY trojan-master/go.mod trojan-master/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY trojan-master/ ./
|
||||||
|
COPY --from=frontend /src/dist ./web/templates
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/trojan .
|
||||||
|
|
||||||
|
# Production builds pass an immutable, verified runtime snapshot that contains
|
||||||
|
# the legacy Trojan core. This avoids a runtime download and preserves protocol
|
||||||
|
# compatibility during the first replacement release.
|
||||||
|
FROM ${RUNTIME_BASE}
|
||||||
|
COPY --from=manager-build /out/trojan /usr/local/bin/trojan
|
||||||
|
COPY trojan-master/asset/trojan.service /etc/systemd/system/trojan.service
|
||||||
|
COPY trojan-master/asset/trojan-web.service /etc/systemd/system/trojan-web.service
|
||||||
|
COPY trojan-master/asset/trojan-acme-challenge.service /etc/systemd/system/trojan-acme-challenge.service
|
||||||
|
COPY trojan-master/asset/trojan-acme-renew.service /etc/systemd/system/trojan-acme-renew.service
|
||||||
|
COPY trojan-master/asset/trojan-acme-renew.timer /etc/systemd/system/trojan-acme-renew.timer
|
||||||
|
COPY trojan-master/asset/renew-trojan-certificate /usr/local/sbin/renew-trojan-certificate
|
||||||
|
COPY trojan-master/asset/compat-init /usr/local/sbin/compat-init
|
||||||
|
RUN chmod 0755 /usr/local/sbin/renew-trojan-certificate /usr/local/sbin/compat-init
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD curl -fsS "http://127.0.0.1:${WEB_PORT:-8081}/healthz" || exit 1
|
||||||
|
CMD ["/usr/local/sbin/compat-init"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The legacy image's systemd replacement can supervise units without a real
|
||||||
|
# PID-1 systemd. Start them explicitly and keep the container foreground alive.
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl start trojan
|
||||||
|
systemctl start trojan-web
|
||||||
|
systemctl start trojan-acme-challenge
|
||||||
|
systemctl enable trojan trojan-web trojan-acme-challenge trojan-acme-renew.timer || true
|
||||||
|
exec tail -f /dev/null
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
domain="${ACME_DOMAIN:?ACME_DOMAIN is required}"
|
||||||
|
home=/root/.acme.sh
|
||||||
|
cert_dir=/etc/trojan/certs
|
||||||
|
source_cert="$home/${domain}_ecc/fullchain.cer"
|
||||||
|
source_key="$home/${domain}_ecc/${domain}.key"
|
||||||
|
before=""
|
||||||
|
[[ -f "$cert_dir/fullchain.pem" ]] && before=$(openssl x509 -in "$cert_dir/fullchain.pem" -noout -fingerprint -sha256)
|
||||||
|
"$home/acme.sh" --cron --home "$home"
|
||||||
|
test -r "$source_cert" -a -r "$source_key"
|
||||||
|
openssl x509 -in "$source_cert" -noout -checkend 0
|
||||||
|
mkdir -p "$cert_dir"
|
||||||
|
install -m 0644 "$source_cert" "$cert_dir/fullchain.pem"
|
||||||
|
install -m 0600 "$source_key" "$cert_dir/private.key"
|
||||||
|
openssl x509 -noout -modulus -in "$cert_dir/fullchain.pem" | openssl sha256 > /tmp/trojan-cert.modulus
|
||||||
|
openssl rsa -noout -modulus -in "$cert_dir/private.key" | openssl sha256 >> /tmp/trojan-cert.modulus
|
||||||
|
[[ $(cut -d' ' -f2 /tmp/trojan-cert.modulus | sort -u | wc -l) -eq 1 ]]
|
||||||
|
after=$(openssl x509 -in "$cert_dir/fullchain.pem" -noout -fingerprint -sha256)
|
||||||
|
if [[ "$before" != "$after" ]]; then systemctl restart trojan; fi
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Trojan ACME HTTP-01 challenge server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Environment=ACME_WEBROOT=/var/lib/trojan-acme-webroot
|
||||||
|
ExecStart=/bin/sh -c '/usr/local/bin/trojan acme-http --port "${ACME_HTTP_PORT:-8082}"'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Renew Trojan ACME certificate
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/renew-trojan-certificate
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Trojan ACME renewal check
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
RandomizedDelaySec=6h
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -6,7 +6,7 @@ After=network.target network-online.target nss-lookup.target mysql.service maria
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
ExecStart=/usr/local/bin/trojan web
|
ExecStart=/bin/sh -c '/usr/local/bin/trojan web --host "${WEB_HOST:-127.0.0.1}" --port "${WEB_PORT:-8081}"'
|
||||||
ExecReload=/bin/kill -HUP $MAINPID
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3s
|
RestartSec=3s
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=trojan
|
||||||
|
After=network.target network-online.target nss-lookup.target mysql.service mariadb.service mysqld.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
StandardError=journal
|
||||||
|
ExecStart=/usr/bin/trojan/trojan /usr/local/etc/trojan/config.json
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var acmeHTTPPort int
|
||||||
|
|
||||||
|
var acmeHTTPCmd = &cobra.Command{
|
||||||
|
Use: "acme-http",
|
||||||
|
Short: "serve only ACME HTTP-01 challenge files",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
root := os.Getenv("ACME_WEBROOT")
|
||||||
|
if root == "" { root = "/var/lib/trojan-acme-webroot" }
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, ".well-known", "acme-challenge"), 0750); err != nil { return err }
|
||||||
|
addr := fmt.Sprintf("127.0.0.1:%d", acmeHTTPPort)
|
||||||
|
return http.ListenAndServe(addr, http.FileServer(http.Dir(root)))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { acmeHTTPCmd.Flags().IntVar(&acmeHTTPPort, "port", 8082, "ACME HTTP listener port"); rootCmd.AddCommand(acmeHTTPCmd) }
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/tidwall/pretty"
|
"github.com/tidwall/pretty"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
var configPath = "/usr/local/etc/trojan/config.json"
|
var configPath = "/usr/local/etc/trojan/config.json"
|
||||||
@@ -53,10 +54,13 @@ func Save(data []byte, path string) bool {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
path = configPath
|
path = configPath
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, pretty.Pretty(data), 0644); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { fmt.Println(err); return false }
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, pretty.Pretty(data), 0644); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil { fmt.Println(err); return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Package managerconfig owns settings which are not part of Trojan's native JSON.
|
||||||
|
package managerconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPath = "/etc/trojan-manager/manager.yaml"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SchemaVersion int `yaml:"schema_version" json:"schemaVersion"`
|
||||||
|
Public struct {
|
||||||
|
Domain string `yaml:"domain" json:"domain"`
|
||||||
|
Port int `yaml:"port" json:"port"`
|
||||||
|
} `yaml:"public" json:"public"`
|
||||||
|
Runtime struct {
|
||||||
|
ListenAddress string `yaml:"listen_address" json:"listenAddress"`
|
||||||
|
ListenPort int `yaml:"listen_port" json:"listenPort"`
|
||||||
|
FallbackAddress string `yaml:"fallback_address" json:"fallbackAddress"`
|
||||||
|
FallbackPort int `yaml:"fallback_port" json:"fallbackPort"`
|
||||||
|
} `yaml:"runtime" json:"runtime"`
|
||||||
|
Web struct { ListenAddress string `yaml:"listen_address" json:"listenAddress"`; ListenPort int `yaml:"listen_port" json:"listenPort"` } `yaml:"web" json:"web"`
|
||||||
|
ACME struct { Enabled bool `yaml:"enabled" json:"enabled"`; Domain string `yaml:"domain" json:"domain"`; HTTPPort int `yaml:"http_port" json:"httpPort"` } `yaml:"acme" json:"acme"`
|
||||||
|
Firewall struct { Manage bool `yaml:"manage" json:"manage"` } `yaml:"firewall" json:"firewall"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Defaults() Config {
|
||||||
|
var c Config
|
||||||
|
c.SchemaVersion = 1; c.Public.Port = 443; c.Runtime.ListenAddress = "0.0.0.0"; c.Runtime.ListenPort = 8443
|
||||||
|
c.Runtime.FallbackAddress = "127.0.0.1"; c.Runtime.FallbackPort = 8081; c.Web.ListenAddress = "127.0.0.1"; c.Web.ListenPort = 8081; c.ACME.HTTPPort = 8082
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
func Path() string { if p := os.Getenv("TROJAN_MANAGER_CONFIG"); p != "" { return p }; return DefaultPath }
|
||||||
|
func Load() (Config, error) { c := Defaults(); b, err := os.ReadFile(Path()); if os.IsNotExist(err) { return c, nil }; if err != nil { return c, err }; err = yaml.Unmarshal(b, &c); return c, err }
|
||||||
|
func Validate(c Config) error {
|
||||||
|
if c.SchemaVersion != 1 { return errors.New("unsupported manager config schema") }
|
||||||
|
ports := []int{c.Public.Port, c.Runtime.ListenPort, c.Runtime.FallbackPort, c.Web.ListenPort, c.ACME.HTTPPort}
|
||||||
|
for _, p := range ports { if p < 1 || p > 65535 { return fmt.Errorf("invalid port %d", p) } }
|
||||||
|
if c.Runtime.ListenPort == c.Web.ListenPort || c.Runtime.ListenPort == c.ACME.HTTPPort || c.Web.ListenPort == c.ACME.HTTPPort { return errors.New("listen, web and ACME ports must be distinct") }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func Save(c Config) error { if err := Validate(c); err != nil { return err }; b, err := yaml.Marshal(c); if err != nil { return err }; p := Path(); if err := os.MkdirAll(filepath.Dir(p), 0750); err != nil { return err }; tmp := p + ".tmp"; if err := os.WriteFile(tmp, b, 0640); err != nil { return err }; return os.Rename(tmp, p) }
|
||||||
@@ -48,20 +48,20 @@ func ControlMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restart 重启trojan
|
// Restart 重启trojan
|
||||||
func Restart() {
|
func Restart() error {
|
||||||
util.OpenPort(core.GetConfig().LocalPort)
|
util.OpenPort(core.GetConfig().LocalPort)
|
||||||
util.SystemctlRestart("trojan")
|
return util.SystemctlRestart("trojan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start 启动trojan
|
// Start 启动trojan
|
||||||
func Start() {
|
func Start() error {
|
||||||
util.OpenPort(core.GetConfig().LocalPort)
|
util.OpenPort(core.GetConfig().LocalPort)
|
||||||
util.SystemctlStart("trojan")
|
return util.SystemctlStart("trojan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop 停止trojan
|
// Stop 停止trojan
|
||||||
func Stop() {
|
func Stop() error {
|
||||||
util.SystemctlStop("trojan")
|
return util.SystemctlStop("trojan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status 获取trojan状态
|
// Status 获取trojan状态
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"trojan/core"
|
"trojan/core"
|
||||||
|
"trojan/managerconfig"
|
||||||
"trojan/util"
|
"trojan/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,5 +53,8 @@ func SetDomain(domain string) {
|
|||||||
// GetDomainAndPort 获取域名和端口
|
// GetDomainAndPort 获取域名和端口
|
||||||
func GetDomainAndPort() (string, int) {
|
func GetDomainAndPort() (string, int) {
|
||||||
config := core.GetConfig()
|
config := core.GetConfig()
|
||||||
|
if managed, err := managerconfig.Load(); err == nil && managed.Public.Port != 0 {
|
||||||
|
return config.SSl.Sni, managed.Public.Port
|
||||||
|
}
|
||||||
return config.SSl.Sni, config.LocalPort
|
return config.SSl.Sni, config.LocalPort
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,30 +34,36 @@ func systemctlBase(name, operate string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SystemctlStart 服务启动
|
// SystemctlStart 服务启动
|
||||||
func SystemctlStart(name string) {
|
func SystemctlStart(name string) error {
|
||||||
if _, err := systemctlBase(name, "start"); err != nil {
|
if _, err := systemctlBase(name, "start"); err != nil {
|
||||||
fmt.Println(Red(fmt.Sprintf("启动%s失败!", name)))
|
fmt.Println(Red(fmt.Sprintf("启动%s失败!", name)))
|
||||||
|
return err
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(Green(fmt.Sprintf("启动%s成功!", name)))
|
fmt.Println(Green(fmt.Sprintf("启动%s成功!", name)))
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemctlStop 服务停止
|
// SystemctlStop 服务停止
|
||||||
func SystemctlStop(name string) {
|
func SystemctlStop(name string) error {
|
||||||
if _, err := systemctlBase(name, "stop"); err != nil {
|
if _, err := systemctlBase(name, "stop"); err != nil {
|
||||||
fmt.Println(Red(fmt.Sprintf("停止%s失败!", name)))
|
fmt.Println(Red(fmt.Sprintf("停止%s失败!", name)))
|
||||||
|
return err
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(Green(fmt.Sprintf("停止%s成功!", name)))
|
fmt.Println(Green(fmt.Sprintf("停止%s成功!", name)))
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemctlRestart 服务重启
|
// SystemctlRestart 服务重启
|
||||||
func SystemctlRestart(name string) {
|
func SystemctlRestart(name string) error {
|
||||||
if _, err := systemctlBase(name, "restart"); err != nil {
|
if _, err := systemctlBase(name, "restart"); err != nil {
|
||||||
fmt.Println(Red(fmt.Sprintf("重启%s失败!", name)))
|
fmt.Println(Red(fmt.Sprintf("重启%s失败!", name)))
|
||||||
|
return err
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(Green(fmt.Sprintf("重启%s成功!", name)))
|
fmt.Println(Green(fmt.Sprintf("重启%s成功!", name)))
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemctlEnable 服务设置开机自启
|
// SystemctlEnable 服务设置开机自启
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ func RequestUsername(c *gin.Context) string {
|
|||||||
return claims[identityKey].(string)
|
return claims[identityKey].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequireAdmin(c *gin.Context) bool {
|
||||||
|
if RequestUsername(c) == "admin" { return true }
|
||||||
|
c.JSON(403, gin.H{"code": 403, "message": "administrator access required"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Auth 权限router
|
// Auth 权限router
|
||||||
func Auth(r *gin.Engine, timeout int) *jwt.GinJWTMiddleware {
|
func Auth(r *gin.Engine, timeout int) *jwt.GinJWTMiddleware {
|
||||||
jwtInit(timeout)
|
jwtInit(timeout)
|
||||||
@@ -152,7 +158,10 @@ func Auth(r *gin.Engine, timeout int) *jwt.GinJWTMiddleware {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
r.POST("/auth/login", authMiddleware.LoginHandler)
|
r.POST("/auth/login", authMiddleware.LoginHandler)
|
||||||
r.POST("/auth/register", updateUser)
|
r.POST("/auth/register", func(c *gin.Context) {
|
||||||
|
if result, _ := core.GetValue("admin_pass"); result != "" { c.JSON(403, gin.H{"code":403,"message":"administrator already initialized"}); return }
|
||||||
|
updateUser(c)
|
||||||
|
})
|
||||||
authO := r.Group("/auth")
|
authO := r.Group("/auth")
|
||||||
authO.Use(authMiddleware.MiddlewareFunc())
|
authO.Use(authMiddleware.MiddlewareFunc())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
"trojan/managerconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SystemConfig() *ResponseBody { c, err := managerconfig.Load(); r := &ResponseBody{Msg:"success", Data:c}; if err != nil { r.Msg=err.Error() }; return r }
|
||||||
|
func ValidateSystemConfig(c managerconfig.Config) *ResponseBody { r:=&ResponseBody{Msg:"success"}; if err:=managerconfig.Validate(c); err != nil { r.Msg=err.Error() }; return r }
|
||||||
|
func ApplySystemConfig(c managerconfig.Config) *ResponseBody { r:=ValidateSystemConfig(c); if r.Msg!="success" { return r }; if err:=managerconfig.Save(c); err!=nil { r.Msg=err.Error(); return r }; r.Data=map[string]bool{"restartRequired":true,"nginxReloadRequired":false,"containerRecreateRequired":false}; return r }
|
||||||
|
func CertificateStatus() *ResponseBody {
|
||||||
|
r:=&ResponseBody{Msg:"success"}; b,err:=os.ReadFile("/etc/trojan/certs/fullchain.pem"); if err!=nil { r.Msg=err.Error(); return r }; block,_:=pem.Decode(b); if block==nil { r.Msg="invalid certificate PEM"; return r }; cert,err:=x509.ParseCertificate(block.Bytes); if err!=nil { r.Msg=err.Error(); return r }
|
||||||
|
r.Data=map[string]interface{}{"subject":cert.Subject.String(),"issuer":cert.Issuer.String(),"dnsNames":cert.DNSNames,"notBefore":cert.NotBefore,"notAfter":cert.NotAfter,"daysRemaining":int(time.Until(cert.NotAfter).Hours()/24),"serialNumber":fmt.Sprint(cert.SerialNumber)}; return r
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
func Start() *ResponseBody {
|
func Start() *ResponseBody {
|
||||||
responseBody := ResponseBody{Msg: "success"}
|
responseBody := ResponseBody{Msg: "success"}
|
||||||
defer TimeCost(time.Now(), &responseBody)
|
defer TimeCost(time.Now(), &responseBody)
|
||||||
trojan.Start()
|
if err := trojan.Start(); err != nil { responseBody.Msg = err.Error() }
|
||||||
return &responseBody
|
return &responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ func Start() *ResponseBody {
|
|||||||
func Stop() *ResponseBody {
|
func Stop() *ResponseBody {
|
||||||
responseBody := ResponseBody{Msg: "success"}
|
responseBody := ResponseBody{Msg: "success"}
|
||||||
defer TimeCost(time.Now(), &responseBody)
|
defer TimeCost(time.Now(), &responseBody)
|
||||||
trojan.Stop()
|
if err := trojan.Stop(); err != nil { responseBody.Msg = err.Error() }
|
||||||
return &responseBody
|
return &responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ func Stop() *ResponseBody {
|
|||||||
func Restart() *ResponseBody {
|
func Restart() *ResponseBody {
|
||||||
responseBody := ResponseBody{Msg: "success"}
|
responseBody := ResponseBody{Msg: "success"}
|
||||||
defer TimeCost(time.Now(), &responseBody)
|
defer TimeCost(time.Now(), &responseBody)
|
||||||
trojan.Restart()
|
if err := trojan.Restart(); err != nil { responseBody.Msg = err.Error() }
|
||||||
return &responseBody
|
return &responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func SetLogLevel(level int) *ResponseBody {
|
|||||||
responseBody := ResponseBody{Msg: "success"}
|
responseBody := ResponseBody{Msg: "success"}
|
||||||
defer TimeCost(time.Now(), &responseBody)
|
defer TimeCost(time.Now(), &responseBody)
|
||||||
core.WriteLogLevel(level)
|
core.WriteLogLevel(level)
|
||||||
trojan.Restart()
|
if err := trojan.Restart(); err != nil { responseBody.Msg = err.Error() }
|
||||||
return &responseBody
|
return &responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ func UserList(requestUser string) *ResponseBody {
|
|||||||
}
|
}
|
||||||
domain, port := trojan.GetDomainAndPort()
|
domain, port := trojan.GetDomainAndPort()
|
||||||
responseBody.Data = map[string]interface{}{
|
responseBody.Data = map[string]interface{}{
|
||||||
"domain": domain,
|
"domain": domain, "port": port, "publicPort": port,
|
||||||
"port": port,
|
"listenPort": core.GetConfig().LocalPort,
|
||||||
"userList": userList,
|
"userList": userList,
|
||||||
}
|
}
|
||||||
return &responseBody
|
return &responseBody
|
||||||
@@ -55,8 +55,8 @@ func PageUserList(curPage int, pageSize int) *ResponseBody {
|
|||||||
}
|
}
|
||||||
domain, port := trojan.GetDomainAndPort()
|
domain, port := trojan.GetDomainAndPort()
|
||||||
responseBody.Data = map[string]interface{}{
|
responseBody.Data = map[string]interface{}{
|
||||||
"domain": domain,
|
"domain": domain, "port": port, "publicPort": port,
|
||||||
"port": port,
|
"listenPort": core.GetConfig().LocalPort,
|
||||||
"pageData": pageData,
|
"pageData": pageData,
|
||||||
}
|
}
|
||||||
return &responseBody
|
return &responseBody
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"trojan/managerconfig"
|
||||||
"trojan/core"
|
"trojan/core"
|
||||||
"trojan/util"
|
"trojan/util"
|
||||||
"trojan/web/controller"
|
"trojan/web/controller"
|
||||||
@@ -171,6 +172,14 @@ func noTokenRouter(router *gin.Engine) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func systemRouter(router *gin.Engine) {
|
||||||
|
system := router.Group("/system")
|
||||||
|
system.GET("/config", func(c *gin.Context) { if !RequireAdmin(c) { return }; c.JSON(200, controller.SystemConfig()) })
|
||||||
|
system.POST("/config/validate", func(c *gin.Context) { if !RequireAdmin(c) { return }; var cfg managerconfig.Config; if err:=c.ShouldBindJSON(&cfg); err!=nil { c.JSON(400, gin.H{"message":err.Error()}); return }; c.JSON(200, controller.ValidateSystemConfig(cfg)) })
|
||||||
|
system.POST("/config/apply", func(c *gin.Context) { if !RequireAdmin(c) { return }; var cfg managerconfig.Config; if err:=c.ShouldBindJSON(&cfg); err!=nil { c.JSON(400, gin.H{"message":err.Error()}); return }; c.JSON(200, controller.ApplySystemConfig(cfg)) })
|
||||||
|
system.GET("/certificate", func(c *gin.Context) { if !RequireAdmin(c) { return }; c.JSON(200, controller.CertificateStatus()) })
|
||||||
|
}
|
||||||
|
|
||||||
// Start web启动入口
|
// Start web启动入口
|
||||||
func Start(host string, port, timeout int, isSSL bool) {
|
func Start(host string, port, timeout int, isSSL bool) {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
@@ -178,7 +187,10 @@ func Start(host string, port, timeout int, isSSL bool) {
|
|||||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
staticRouter(router)
|
staticRouter(router)
|
||||||
noTokenRouter(router)
|
noTokenRouter(router)
|
||||||
|
router.GET("/healthz", func(c *gin.Context) { c.JSON(200, gin.H{"status":"ok"}) })
|
||||||
|
router.GET("/readyz", func(c *gin.Context) { if core.GetConfig()==nil { c.JSON(503, gin.H{"status":"not ready"}); return }; c.JSON(200, gin.H{"status":"ready"}) })
|
||||||
router.Use(Auth(router, timeout).MiddlewareFunc())
|
router.Use(Auth(router, timeout).MiddlewareFunc())
|
||||||
|
systemRouter(router)
|
||||||
trojanRouter(router)
|
trojanRouter(router)
|
||||||
userRouter(router)
|
userRouter(router)
|
||||||
dataRouter(router)
|
dataRouter(router)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
trojan:
|
||||||
|
image: ${TROJAN_IMAGE:?set an immutable image digest}
|
||||||
|
container_name: trojan
|
||||||
|
network_mode: host
|
||||||
|
privileged: true
|
||||||
|
restart: always
|
||||||
|
command: init
|
||||||
|
env_file: /opt/trojan/runtime.env
|
||||||
|
volumes:
|
||||||
|
- /opt/trojan/config:/usr/local/etc/trojan
|
||||||
|
- /opt/trojan/manager:/var/lib/trojan-manager
|
||||||
|
- /opt/trojan/acme:/root/.acme.sh
|
||||||
|
- /opt/trojan/certs:/etc/trojan/certs
|
||||||
|
- /opt/trojan/manager-config:/etc/trojan-manager
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 无损替换运行手册
|
||||||
|
|
||||||
|
1. 以镜像 digest 设置 `TROJAN_IMAGE`;禁止使用 `latest`。
|
||||||
|
2. 先备份 MariaDB,记录用户数,并保存 `docker inspect trojan` 与 Nginx 配置。
|
||||||
|
3. 在旧容器仍运行时执行 `migrate-v1.sh`,然后用 18443/18081/18082 完成预演。
|
||||||
|
4. 使用 `verify.sh` 验证管理端、证书、Trojan 监听与公开 443 分享链接。
|
||||||
|
5. 正式切换时停止并重命名旧容器为 `trojan-legacy`,再以本 compose 启动新容器。
|
||||||
|
6. 任一核心检查失败,立即运行 `rollback.sh`。保留旧容器至少一个证书续期周期。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ACME_DOMAIN=www.hk.example.com
|
||||||
|
PUBLIC_DOMAIN=www.hk.example.com
|
||||||
|
PUBLIC_PORT=443
|
||||||
|
TROJAN_LISTEN_PORT=8443
|
||||||
|
WEB_HOST=127.0.0.1
|
||||||
|
WEB_PORT=8081
|
||||||
|
ACME_HTTP_PORT=8082
|
||||||
|
MANAGE_FIREWALL=false
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
legacy_container=${1:-trojan}
|
||||||
|
target=/opt/trojan
|
||||||
|
install -d -m 0750 "$target"/{config,manager,acme,certs,manager-config}
|
||||||
|
docker cp "$legacy_container":/usr/local/etc/trojan/config.json "$target/config/config.json"
|
||||||
|
docker cp "$legacy_container":/var/lib/trojan-manager/. "$target/manager/"
|
||||||
|
docker cp "$legacy_container":/root/.acme.sh/. "$target/acme/" || true
|
||||||
|
cat > "$target/manager-config/manager.yaml" <<EOF
|
||||||
|
schema_version: 1
|
||||||
|
public: { domain: ${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN}, port: ${PUBLIC_PORT:-443} }
|
||||||
|
runtime: { listen_address: 0.0.0.0, listen_port: ${TROJAN_LISTEN_PORT:-8443}, fallback_address: 127.0.0.1, fallback_port: ${WEB_PORT:-8081} }
|
||||||
|
web: { listen_address: ${WEB_HOST:-127.0.0.1}, listen_port: ${WEB_PORT:-8081} }
|
||||||
|
acme: { enabled: true, domain: ${ACME_DOMAIN:-$PUBLIC_DOMAIN}, http_port: ${ACME_HTTP_PORT:-8082} }
|
||||||
|
firewall: { manage: false }
|
||||||
|
EOF
|
||||||
|
chmod 700 "$target"/{acme,certs}; chmod 600 "$target/config/config.json"
|
||||||
|
echo "Migration staged in $target; do not stop the legacy container before rehearsal succeeds."
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
docker stop trojan || true
|
||||||
|
docker rename trojan trojan-failed-"$(date +%s)" || true
|
||||||
|
docker rename trojan-legacy trojan
|
||||||
|
docker start trojan
|
||||||
|
echo "Legacy container restored. Verify ports 8443 and 8081."
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
: "${PUBLIC_DOMAIN:?PUBLIC_DOMAIN is required}"
|
||||||
|
for port in "${TROJAN_LISTEN_PORT:-8443}" "${WEB_PORT:-8081}" "${ACME_HTTP_PORT:-8082}"; do
|
||||||
|
ss -ltn "sport = :${port}" | grep -q LISTEN || { echo "port ${port} is not listening" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
curl --fail --silent --show-error "http://${WEB_HOST:-127.0.0.1}:${WEB_PORT:-8081}/healthz" >/dev/null
|
||||||
|
openssl x509 -in /opt/trojan/certs/fullchain.pem -noout -checkend 0
|
||||||
|
openssl x509 -in /opt/trojan/certs/fullchain.pem -noout -text | grep -F "${PUBLIC_DOMAIN}" >/dev/null
|
||||||
|
echo "verification passed"
|
||||||
@@ -2,7 +2,6 @@ import path, { resolve } from 'path'
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import vue from "@vitejs/plugin-vue"
|
import vue from "@vitejs/plugin-vue"
|
||||||
import viteSvgIcons from 'vite-plugin-svg-icons'
|
import viteSvgIcons from 'vite-plugin-svg-icons'
|
||||||
import externalGlobals from 'rollup-plugin-external-globals'
|
|
||||||
|
|
||||||
export default defineConfig(({ command }) => {
|
export default defineConfig(({ command }) => {
|
||||||
|
|
||||||
@@ -65,24 +64,6 @@ export default defineConfig(({ command }) => {
|
|||||||
drop_debugger: true
|
drop_debugger: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rollupOptions:{
|
|
||||||
external: ['vue', 'vuex', 'vue-i18n', 'vue-router', 'element-plus',
|
|
||||||
'axios', 'crypto-js', 'dayjs', 'easyqrcodejs', 'nprogress'],
|
|
||||||
plugins: [
|
|
||||||
externalGlobals({
|
|
||||||
vue: 'Vue',
|
|
||||||
vuex: 'Vuex',
|
|
||||||
'vue-i18n': 'VueI18n',
|
|
||||||
'vue-router': 'VueRouter',
|
|
||||||
axios: 'axios',
|
|
||||||
'crypto-js': 'CryptoJS',
|
|
||||||
'dayjs': 'dayjs',
|
|
||||||
'easyqrcodejs': 'QRCode',
|
|
||||||
'nprogress': 'NProgress',
|
|
||||||
'element-plus': 'ElementPlus'
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user