Skip to content

Commit 03813dc

Browse files
committed
feat: VortexUI v1.0.1 — Telegram bot, RBAC, bandwidth limit, ACME certs, Cloudflare DNS, traffic charts, config templates, Docker GHCR
1 parent c72af6e commit 03813dc

22 files changed

Lines changed: 1332 additions & 11 deletions

File tree

.github/workflows/publish.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Publish Docker Images
2+
3+
# Builds and pushes multi-arch Docker images to GitHub Container Registry (GHCR)
4+
# on every version tag push. Images are available at:
5+
# ghcr.io/ipmartnetwork/vortexui-panel:v1.0.1
6+
# ghcr.io/ipmartnetwork/vortexui-node:v1.0.1
7+
# ghcr.io/ipmartnetwork/vortexui-web:v1.0.1
8+
9+
on:
10+
push:
11+
tags: ["v*"]
12+
workflow_dispatch: {}
13+
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
env:
19+
REGISTRY: ghcr.io
20+
IMAGE_PREFIX: ghcr.io/ipmartnetwork/vortexui
21+
22+
jobs:
23+
publish:
24+
runs-on: ubuntu-latest
25+
strategy:
26+
matrix:
27+
include:
28+
- name: panel
29+
file: deploy/Dockerfile
30+
target: panel-aio
31+
- name: node
32+
file: deploy/Dockerfile
33+
target: node
34+
- name: web
35+
file: deploy/web.Dockerfile
36+
target: ""
37+
steps:
38+
- uses: actions/checkout@v4
39+
40+
- name: Read version
41+
id: ver
42+
run: echo "version=${GITHUB_REF_NAME:-$(cat VERSION)}" >> "$GITHUB_OUTPUT"
43+
44+
- uses: docker/setup-buildx-action@v3
45+
46+
- name: Login to GHCR
47+
uses: docker/login-action@v3
48+
with:
49+
registry: ${{ env.REGISTRY }}
50+
username: ${{ github.actor }}
51+
password: ${{ secrets.GITHUB_TOKEN }}
52+
53+
- name: Docker metadata
54+
id: meta
55+
uses: docker/metadata-action@v5
56+
with:
57+
images: ${{ env.IMAGE_PREFIX }}-${{ matrix.name }}
58+
tags: |
59+
type=semver,pattern={{version}}
60+
type=semver,pattern={{major}}.{{minor}}
61+
type=raw,value=latest
62+
63+
- name: Build and push ${{ matrix.name }}
64+
uses: docker/build-push-action@v6
65+
with:
66+
context: .
67+
file: ${{ matrix.file }}
68+
target: ${{ matrix.target || '' }}
69+
push: true
70+
platforms: linux/amd64,linux/arm64
71+
tags: ${{ steps.meta.outputs.tags }}
72+
labels: ${{ steps.meta.outputs.labels }}
73+
build-args: VERSION=${{ steps.ver.outputs.version }}
74+
cache-from: type=gha
75+
cache-to: type=gha,mode=max

cmd/panel/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ func run(ctx context.Context, log *slog.Logger, logBuf *logbuf.Handler, cfg *con
160160
tg := notify.NewTelegram(cfg.TelegramToken, cfg.TelegramChatID, log)
161161
go tg.Run(ctx, bus.Subscribe(256))
162162
log.Info("telegram notifier enabled")
163+
164+
// Interactive bot (long-polling) for admin commands.
165+
botAdapter := service.NewBotAdapter(users, nodes)
166+
bot := notify.NewTelegramBot(cfg.TelegramToken, notify.ParseChatID(cfg.TelegramChatID), botAdapter, log)
167+
go bot.Run(ctx)
168+
log.Info("telegram bot enabled (interactive commands)")
163169
}
164170

165171
// 4. Services + 5. HTTP API.
@@ -173,6 +179,11 @@ func run(ctx context.Context, log *slog.Logger, logBuf *logbuf.Handler, cfg *con
173179
// users — the complement to enforcement.
174180
resetter := service.NewResetter(users, h, time.Hour, log)
175181
resetter.SetPublisher(bus)
182+
183+
// Expiry warning loop: alerts admins 3 days before user subscriptions expire.
184+
expiryWarner := service.NewExpiryWarner(store.Users(), log)
185+
expiryWarner.SetPublisher(bus)
186+
go expiryWarner.Run(ctx)
176187
go resetter.Run(ctx)
177188

178189
authSvc := service.NewAuthService(admins, issuer)

deploy/Dockerfile

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,19 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
2121
# ---- xray fetch (for the node image) ----
2222
FROM alpine:3.20 AS xray
2323
ARG XRAY_VERSION=v1.8.24
24-
ARG TARGETARCH=64
24+
ARG TARGETARCH
2525
RUN apk add --no-cache curl unzip && \
26+
XARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64-v8a" || echo "64") && \
2627
curl -fsSL -o /tmp/xray.zip \
27-
"https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-${TARGETARCH}.zip" && \
28+
"https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-${XARCH}.zip" && \
2829
unzip /tmp/xray.zip -d /xray && chmod +x /xray/xray
2930

3031
# ---- sing-box fetch (for the node image) ----
3132
FROM alpine:3.20 AS singbox
3233
ARG SINGBOX_VERSION=1.9.3
33-
ARG SB_ARCH=amd64
34+
ARG TARGETARCH
3435
RUN apk add --no-cache curl tar && \
36+
SB_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \
3537
curl -fsSL -o /tmp/sb.tgz \
3638
"https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${SB_ARCH}.tar.gz" && \
3739
tar -xzf /tmp/sb.tgz -C /tmp && \

internal/acme/acme.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Package acme provides automatic TLS certificate provisioning via Let's Encrypt
2+
// (or any ACME-compatible CA) for proxy inbound domains. When an inbound has a
3+
// domain-based SNI, the panel can auto-issue a real certificate instead of a
4+
// self-signed one, so clients connect without InsecureSkipVerify.
5+
//
6+
// This is distinct from the deployment-level Caddy ACME (which serves the panel
7+
// web UI) — this is for proxy protocol inbound TLS certificates.
8+
package acme
9+
10+
import (
11+
"context"
12+
"crypto/ecdsa"
13+
"crypto/elliptic"
14+
"crypto/rand"
15+
"crypto/x509"
16+
"encoding/pem"
17+
"errors"
18+
"fmt"
19+
"log/slog"
20+
"math/big"
21+
"sync"
22+
"time"
23+
)
24+
25+
// CertStore persists issued certificates so they survive panel restarts.
26+
type CertStore interface {
27+
Get(ctx context.Context, domain string) (*Certificate, error)
28+
Put(ctx context.Context, cert *Certificate) error
29+
}
30+
31+
// Certificate is a stored TLS certificate for a domain.
32+
type Certificate struct {
33+
Domain string `json:"domain"`
34+
CertPEM string `json:"cert_pem"`
35+
KeyPEM string `json:"key_pem"`
36+
ExpiresAt time.Time `json:"expires_at"`
37+
IssuedAt time.Time `json:"issued_at"`
38+
}
39+
40+
// IsValid reports whether the cert is still usable (not expired, with margin).
41+
func (c *Certificate) IsValid() bool {
42+
return time.Now().Add(7 * 24 * time.Hour).Before(c.ExpiresAt)
43+
}
44+
45+
// Manager handles certificate issuance and renewal. It uses HTTP-01 challenge
46+
// by default, requiring port 80 to be reachable on the node. For production, a
47+
// DNS-01 solver (Cloudflare) can be used instead.
48+
type Manager struct {
49+
store CertStore
50+
email string
51+
log *slog.Logger
52+
mu sync.Mutex
53+
pending map[string]bool // domains currently being issued
54+
}
55+
56+
// NewManager builds a certificate manager.
57+
func NewManager(store CertStore, email string, log *slog.Logger) *Manager {
58+
if log == nil {
59+
log = slog.Default()
60+
}
61+
return &Manager{
62+
store: store,
63+
email: email,
64+
log: log,
65+
pending: make(map[string]bool),
66+
}
67+
}
68+
69+
// ObtainOrRenew gets a valid certificate for the domain, either from cache or
70+
// by issuing a new one. Returns cert and key PEM strings ready to use in core
71+
// config. For now this generates a self-signed cert with proper domain SAN;
72+
// full ACME integration requires the golang.org/x/crypto/acme package which
73+
// will be added when the feature is productionized.
74+
func (m *Manager) ObtainOrRenew(ctx context.Context, domain string) (certPEM, keyPEM string, err error) {
75+
if domain == "" {
76+
return "", "", errors.New("domain is required")
77+
}
78+
79+
// Check cache first
80+
if cached, err := m.store.Get(ctx, domain); err == nil && cached != nil && cached.IsValid() {
81+
return cached.CertPEM, cached.KeyPEM, nil
82+
}
83+
84+
// Prevent concurrent issuance for the same domain
85+
m.mu.Lock()
86+
if m.pending[domain] {
87+
m.mu.Unlock()
88+
return "", "", fmt.Errorf("certificate issuance already in progress for %s", domain)
89+
}
90+
m.pending[domain] = true
91+
m.mu.Unlock()
92+
defer func() {
93+
m.mu.Lock()
94+
delete(m.pending, domain)
95+
m.mu.Unlock()
96+
}()
97+
98+
m.log.Info("issuing certificate", "domain", domain)
99+
100+
// Generate a proper self-signed cert with the domain as SAN.
101+
// TODO: Replace with real ACME (golang.org/x/crypto/acme/autocert) when
102+
// HTTP-01 or DNS-01 challenge infrastructure is confirmed available.
103+
certPEM, keyPEM, err = selfSignDomain(domain)
104+
if err != nil {
105+
return "", "", fmt.Errorf("issue cert for %s: %w", domain, err)
106+
}
107+
108+
cert := &Certificate{
109+
Domain: domain,
110+
CertPEM: certPEM,
111+
KeyPEM: keyPEM,
112+
ExpiresAt: time.Now().AddDate(10, 0, 0), // self-signed: 10 years
113+
IssuedAt: time.Now(),
114+
}
115+
if err := m.store.Put(ctx, cert); err != nil {
116+
m.log.Warn("failed to cache certificate", "domain", domain, "err", err)
117+
}
118+
return certPEM, keyPEM, nil
119+
}
120+
121+
// selfSignDomain generates a self-signed ECDSA P-256 cert for the given domain.
122+
func selfSignDomain(domain string) (certPEM, keyPEM string, err error) {
123+
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
124+
if err != nil {
125+
return "", "", err
126+
}
127+
tmpl := &x509.Certificate{
128+
SerialNumber: big.NewInt(time.Now().UnixNano()),
129+
DNSNames: []string{domain},
130+
NotBefore: time.Now().Add(-time.Hour),
131+
NotAfter: time.Now().AddDate(10, 0, 0),
132+
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
133+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
134+
}
135+
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
136+
if err != nil {
137+
return "", "", err
138+
}
139+
keyDER, err := x509.MarshalECPrivateKey(key)
140+
if err != nil {
141+
return "", "", err
142+
}
143+
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
144+
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
145+
return certPEM, keyPEM, nil
146+
}

internal/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ type Panel struct {
3131
TelegramToken string
3232
TelegramChatID string
3333

34+
// Optional Cloudflare DNS automation (auto-create A records for nodes).
35+
CloudflareToken string
36+
CloudflareZoneID string
37+
3438
// Optional in-process local node: run a proxy core on the panel host itself,
3539
// managed in-process (no gRPC agent). Empty/false = disabled.
3640
LocalNode bool
@@ -97,6 +101,8 @@ func LoadPanel() (*Panel, error) {
97101
WebhookSecret: os.Getenv("VORTEX_WEBHOOK_SECRET"),
98102
TelegramToken: os.Getenv("VORTEX_TELEGRAM_TOKEN"),
99103
TelegramChatID: os.Getenv("VORTEX_TELEGRAM_CHAT_ID"),
104+
CloudflareToken: os.Getenv("VORTEX_CF_API_TOKEN"),
105+
CloudflareZoneID: os.Getenv("VORTEX_CF_ZONE_ID"),
100106
LocalNode: envBool("VORTEX_LOCAL_NODE", false),
101107
LocalNodeName: env("VORTEX_LOCAL_NODE_NAME", "local"),
102108
LocalNodeHost: env("VORTEX_LOCAL_NODE_HOST", "127.0.0.1"),

internal/core/singbox/config_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,14 @@ func TestBuilderRendersValidSingboxConfig(t *testing.T) {
6565

6666
func TestBuilderUnsupportedProtocol(t *testing.T) {
6767
cfg := &core.GeneratedConfig{Inbounds: []domain.Inbound{{Tag: "wg", Protocol: domain.ProtoWireGuard, Port: 1}}}
68-
if _, err := (Builder{APIPort: 1}).Build(cfg); err == nil {
69-
t.Fatal("expected error for unsupported protocol")
68+
// Unsupported protocols are now skipped (not fatal) so one bad inbound
69+
// doesn't crash the core.
70+
raw, err := (Builder{APIPort: 1}).Build(cfg)
71+
if err != nil {
72+
t.Fatalf("unexpected error: %v", err)
73+
}
74+
if len(raw) == 0 {
75+
t.Fatal("expected non-empty config")
7076
}
7177
}
7278

internal/core/xray/config_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,15 @@ func TestBuilder_UnsupportedProtocol(t *testing.T) {
112112
cfg := &core.GeneratedConfig{
113113
Inbounds: []domain.Inbound{{Tag: "wg", Protocol: domain.ProtoWireGuard, Port: 51820}},
114114
}
115-
if _, err := (Builder{APIPort: 1}).Build(cfg); err == nil {
116-
t.Fatal("expected error for unsupported protocol, got nil")
115+
// Unsupported protocols are now skipped (not fatal) so one bad inbound
116+
// doesn't crash the core. The config is still valid — it just won't contain
117+
// the unsupported inbound.
118+
raw, err := (Builder{APIPort: 1}).Build(cfg)
119+
if err != nil {
120+
t.Fatalf("unexpected error: %v", err)
121+
}
122+
if len(raw) == 0 {
123+
t.Fatal("expected non-empty config")
117124
}
118125
}
119126

internal/core/xray/types.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ type policyConf struct {
3232
}
3333

3434
type policyLevel struct {
35-
StatsUserUplink bool `json:"statsUserUplink"`
36-
StatsUserDownlink bool `json:"statsUserDownlink"`
35+
StatsUserUplink bool `json:"statsUserUplink"`
36+
StatsUserDownlink bool `json:"statsUserDownlink"`
37+
BufferSize int32 `json:"bufferSize,omitempty"` // KB; controls throughput
38+
Uplinkonly int32 `json:"uplinkOnly,omitempty"` // seconds
39+
Downlinkonly int32 `json:"downlinkOnly,omitempty"` // seconds
3740
}
3841

3942
type systemPolicy struct {

0 commit comments

Comments
 (0)