Drop the TLS template for OpenVPN and OpenConnect endpoints (#1253)

This commit is contained in:
Alireza Ahmadi 2026-09-09 23:45:02 +02:00
parent 686f233ff6
commit d8625970e4
12 changed files with 1285 additions and 646 deletions

View file

@ -2,12 +2,19 @@ package core
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/alireza0/s-ui/database/model"
singboxtls "github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
)
@ -82,59 +89,98 @@ func TestEndpointDefaults(t *testing.T) {
}
}
// TestEndpointPanelTLS builds an endpoint the way the panel does: from a stored
// model.Endpoint carrying a tls_id, whose TLS row is projected into whatever
// shape that endpoint type accepts. It proves the projection produces something
// sing-box actually takes, which a unit test over the mapping alone cannot.
func TestEndpointPanelTLS(t *testing.T) {
// TestEndpointInlineTLS starts the TLS-bearing endpoints from the `tls` object
// the panel UI now writes, in sing-box's own field names. It covers the shapes
// the OpenVPN and OpenConnect forms produce, control_wrap among them, which is
// the setting a shared panel TLS config could never express (#1253).
//
// These are started rather than only built, because the checks that made #1253
// hard to pin down -- a server insisting on client certificates it has no CA
// for, a client with nothing to verify its peer against -- all run on start.
func TestEndpointInlineTLS(t *testing.T) {
certPath, keyPath := writeTestCert(t)
// A TLS row as the panel stores it, `enabled` and all.
tlsRow := &model.Tls{
Name: "panel-cert",
Server: json.RawMessage(`{
"enabled": true,
"server_name": "vpn.example.com",
"alpn": ["h2"],
"certificate_path": ` + strconv.Quote(certPath) + `,
"key_path": ` + strconv.Quote(keyPath) + `
}`),
Client: json.RawMessage(`{
"enabled": true,
"server_name": "vpn.example.com",
"utls": {"enabled": true, "fingerprint": "chrome"},
"certificate_path": ` + strconv.Quote(certPath) + `
}`),
}
quotedCert, quotedKey := strconv.Quote(certPath), strconv.Quote(keyPath)
// The form also takes the PEM itself instead of a path, which is what an
// operator pasting a .ovpn profile has. sing-box reads it as a list of
// lines, which is how the panel splits a textarea.
certLines, keyLines := pemLines(t, certPath), pemLines(t, keyPath)
generatedKey, generatedCert := generateSelfSigned(t)
staticKeyPath := writeStaticKey(t)
quotedStaticKey := strconv.Quote(staticKeyPath)
testCases := []struct {
name string
endpoint model.Endpoint
}{
// The server presents `certificate`/`key` and verifies clients against
// `client_certificate`, which is the opposite of what those two names
// mean on the client below.
{name: "openvpn-server", endpoint: model.Endpoint{
Type: "openvpn-server", Tag: "ovs-tls", TlsId: 1, Tls: tlsRow,
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44101,"mode":"tls","network":"udp","address":["10.8.0.1/24"]}`),
Type: "openvpn-server", Tag: "ovs-tls",
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44101,"mode":"tls","network":"udp","address":["10.8.0.1/24"],
"tls":{"certificate_path":` + quotedCert + `,"key_path":` + quotedKey + `,"verify_client_certificate":"none"}}`),
}},
{name: "openvpn-client", endpoint: model.Endpoint{
Type: "openvpn-client", Tag: "ovc-tls", TlsId: 1, Tls: tlsRow,
Options: json.RawMessage(`{"server":"vpn.example.com","server_port":1194,"mode":"tls","network":"udp"}`),
}},
{name: "openconnect", endpoint: model.Endpoint{
Type: "openconnect", Tag: "oc-tls", TlsId: 1, Tls: tlsRow,
Options: json.RawMessage(`{"server":"vpn.example.com","flavor":"anyconnect"}`),
}},
// mutual TLS: the panel stores client CA paths as a list and names
// verification differently, so both are converted on the way in.
{name: "openvpn-server-mtls", endpoint: model.Endpoint{
Type: "openvpn-server", Tag: "ovs-mtls", TlsId: 2,
Tls: &model.Tls{Name: "panel-mtls", Server: json.RawMessage(`{
"enabled": true,
"certificate_path": ` + strconv.Quote(certPath) + `,
"key_path": ` + strconv.Quote(keyPath) + `,
"client_authentication": "require-and-verify",
"client_certificate_path": [` + strconv.Quote(certPath) + `]
}`)},
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44102,"mode":"tls","network":"udp","address":["10.8.0.1/24"]}`),
Type: "openvpn-server", Tag: "ovs-mtls",
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44102,"mode":"tls","network":"udp","address":["10.8.0.1/24"],
"tls":{"certificate_path":` + quotedCert + `,"key_path":` + quotedKey + `,
"client_certificate_path":` + quotedCert + `,"verify_client_certificate":"require"}}`),
}},
// tls-crypt wraps the control channel in a pre-shared key. Nearly every
// real deployment uses one.
{name: "openvpn-server-control-wrap", endpoint: model.Endpoint{
Type: "openvpn-server", Tag: "ovs-wrap",
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44103,"mode":"tls","network":"udp","address":["10.8.0.1/24"],
"tls":{"certificate_path":` + quotedCert + `,"key_path":` + quotedKey + `,
"verify_client_certificate":"none",
"control_wrap":{"type":"tls_crypt","key_path":` + quotedStaticKey + `}}}`),
}},
// On the client `certificate` is the CA it checks the server against.
{name: "openvpn-client", endpoint: model.Endpoint{
Type: "openvpn-client", Tag: "ovc-tls",
Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp",
"tls":{"certificate_path":` + quotedCert + `,"version_min":"1.2"}}`),
}},
{name: "openvpn-client-mtls", endpoint: model.Endpoint{
Type: "openvpn-client", Tag: "ovc-mtls",
Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp",
"tls":{"certificate_path":` + quotedCert + `,"client_certificate_path":` + quotedCert + `,
"client_key_path":` + quotedKey + `,"remote_certificate_tls":"server"}}`),
}},
// A peer fingerprint stands in for a certificate authority.
{name: "openvpn-client-fingerprint", endpoint: model.Endpoint{
Type: "openvpn-client", Tag: "ovc-pin",
Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp",
"tls":{"peer_fingerprint":["030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dc"]}}`),
}},
{name: "openvpn-server-inline-pem", endpoint: model.Endpoint{
Type: "openvpn-server", Tag: "ovs-inline",
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44104,"mode":"tls","network":"udp","address":["10.8.0.1/24"],
"tls":{"certificate":` + certLines + `,"key":` + keyLines + `,"verify_client_certificate":"none"}}`),
}},
{name: "openvpn-client-inline-pem", endpoint: model.Endpoint{
Type: "openvpn-client", Tag: "ovc-inline",
Options: json.RawMessage(`{"server":"127.0.0.1","server_port":1194,"mode":"tls","network":"udp",
"tls":{"certificate":` + certLines + `,"control_wrap":{"type":"tls_crypt","key":` + staticKeyLines(t, staticKeyPath) + `}}}`),
}},
// What the "generate" button in the OpenVPN server form produces: the
// same self-signed pair the panel issues for inbounds, held as text
// because it exists nowhere on disk.
{name: "openvpn-server-generated-pem", endpoint: model.Endpoint{
Type: "openvpn-server", Tag: "ovs-gen",
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":44105,"mode":"tls","network":"udp","address":["10.8.0.1/24"],
"tls":{"certificate":` + generatedCert + `,"key":` + generatedKey + `,"verify_client_certificate":"none"}}`),
}},
// OpenConnect names the trust anchor after its role.
{name: "openconnect", endpoint: model.Endpoint{
Type: "openconnect", Tag: "oc-tls",
Options: json.RawMessage(`{"server":"vpn.example.com","flavor":"anyconnect",
"tls":{"certificate_authority_path":` + quotedCert + `,"server_name":"vpn.example.com"}}`),
}},
{name: "openconnect-inline-pem", endpoint: model.Endpoint{
Type: "openconnect", Tag: "oc-inline",
Options: json.RawMessage(`{"server":"vpn.example.com","flavor":"anyconnect",
"tls":{"certificate_authority":` + certLines + `}}`),
}},
}
@ -157,14 +203,154 @@ func TestEndpointPanelTLS(t *testing.T) {
EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry())
var options option.Options
if err = options.UnmarshalJSONContext(ctx, raw); err != nil {
t.Fatalf("parse (projected tls was %s): %v", endpointJSON, err)
t.Fatalf("parse (endpoint was %s): %v", endpointJSON, err)
}
instance, err := NewBox(Options{Context: ctx, Options: options})
skipIfFeatureMissing(t, err)
if err != nil {
t.Fatalf("build: %v", err)
t.Fatalf("build (endpoint was %s): %v", endpointJSON, err)
}
defer instance.Close()
// A server reads its TLS credentials when it starts listening, so
// only starting it covers them. A client reads its own when it
// first dials, which needs a peer that is not there -- and
// sing-openvpn panics on the failed dial rather than returning --
// so its credentials are covered by the build above, where
// validateTLSCredentialSet already rejects having neither a
// certificate authority nor a peer fingerprint.
if testCase.endpoint.Type != "openvpn-server" {
return
}
if err = instance.Start(); err != nil {
t.Fatalf("start (endpoint was %s): %v", endpointJSON, err)
}
instance.Close()
})
}
}
// pemLines reads a PEM file back as the JSON list of lines the panel's "use
// text" mode writes.
func pemLines(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return jsonLines(t, string(raw))
}
// generateSelfSigned mirrors ServerService.GenKeypair("tls", ...) behind the
// panel's generate button, and splits the result the way the form does, so the
// test covers what an operator actually ends up with. The service package
// cannot be imported here, since it imports this one.
func generateSelfSigned(t *testing.T) (keyLines string, certLines string) {
t.Helper()
privateKeyPem, publicKeyPem, err := singboxtls.GenerateCertificate(nil, nil, time.Now, "ovs-gen", time.Now().AddDate(0, 12, 0))
if err != nil {
t.Fatal(err)
}
return jsonLines(t, string(privateKeyPem)), jsonLines(t, string(publicKeyPem))
}
func jsonLines(t *testing.T, pem string) string {
t.Helper()
encoded, err := json.Marshal(strings.Split(strings.TrimRight(pem, "\n"), "\n"))
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
// writeStaticKey writes an OpenVPN static key, which is what tls-auth and
// tls-crypt take: 256 bytes as hex between its own markers, not a PEM.
func writeStaticKey(t *testing.T) string {
t.Helper()
material := make([]byte, 256)
if _, err := rand.Read(material); err != nil {
t.Fatal(err)
}
var body strings.Builder
body.WriteString("-----BEGIN OpenVPN Static key V1-----\n")
encoded := hex.EncodeToString(material)
for offset := 0; offset < len(encoded); offset += 32 {
body.WriteString(encoded[offset:offset+32] + "\n")
}
body.WriteString("-----END OpenVPN Static key V1-----\n")
path := filepath.Join(t.TempDir(), "static.key")
if err := os.WriteFile(path, []byte(body.String()), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func staticKeyLines(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return jsonLines(t, string(raw))
}
// TestOpenVPNServerClientCertificateDefault pins the behaviour that made #1253
// so hard to place: sing-box reads an unset `verify_client_certificate` as
// "require", so a server with a perfectly good certificate and key still
// refuses to start because it has no CA for the client certificates it did not
// know it was asking for. The panel writes the choice explicitly, and the
// migration fills it in for endpoints that predate that.
//
// If this ever stops failing, the default changed upstream and the panel no
// longer has to state it.
func TestOpenVPNServerClientCertificateDefault(t *testing.T) {
certPath, keyPath := writeTestCert(t)
start := func(t *testing.T, tlsOptions map[string]any, port int) error {
t.Helper()
raw, err := json.Marshal(map[string]any{
"log": map[string]any{"level": "error"},
"endpoints": []any{map[string]any{
"type": "openvpn-server", "tag": "ovs", "listen": "127.0.0.1",
"listen_port": port, "mode": "tls", "network": "udp",
"address": []string{"10.8.0.1/24"}, "tls": tlsOptions,
}},
"outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}},
})
if err != nil {
t.Fatal(err)
}
ctx := Context(context.Background(), InboundRegistry(), OutboundRegistry(),
EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry())
var options option.Options
if err = options.UnmarshalJSONContext(ctx, raw); err != nil {
t.Fatalf("parse: %v", err)
}
instance, err := NewBox(Options{Context: ctx, Options: options})
skipIfFeatureMissing(t, err)
if err != nil {
t.Fatalf("build: %v", err)
}
defer instance.Close()
return instance.Start()
}
t.Run("unstated", func(t *testing.T) {
err := start(t, map[string]any{"certificate_path": certPath, "key_path": keyPath}, 44201)
if err == nil {
t.Fatal("a server with no client CA started; the upstream default may have changed")
}
if !strings.Contains(err.Error(), "certificate-authority or peer-fingerprint") {
t.Fatalf("unexpected failure: %v", err)
}
})
t.Run("stated as none", func(t *testing.T) {
err := start(t, map[string]any{
"certificate_path": certPath, "key_path": keyPath,
"verify_client_certificate": "none",
}, 44202)
if err != nil {
t.Fatalf("start: %v", err)
}
})
}

View file

@ -0,0 +1,58 @@
{
"log": {
"level": "error"
},
"endpoints": [
{
"type": "openvpn-server",
"tag": "openvpn-server-in",
"listen": "127.0.0.1",
"listen_port": 42301,
"network": "udp",
"mode": "tls",
"address": ["10.8.0.1/24"],
"users": [{ "username": "u", "password": "p" }],
"tls": {
"certificate_path": "__CERT__",
"key_path": "__KEY__",
"client_certificate_path": "__CERT__",
"verify_client_certificate": "require",
"version_min": "1.2"
}
},
{
"type": "openvpn-client",
"tag": "openvpn-client-out",
"server": "127.0.0.1",
"server_port": 42301,
"network": "udp",
"mode": "tls",
"username": "u",
"password": "p",
"tls": {
"certificate_path": "__CERT__",
"client_certificate_path": "__CERT__",
"client_key_path": "__KEY__",
"version_min": "1.2"
}
},
{
"type": "openconnect",
"tag": "openconnect-out",
"server": "127.0.0.1:42302",
"flavor": "anyconnect",
"username": "u",
"password": "p",
"tls": {
"certificate_authority_path": "__CERT__",
"server_name": "t.example.com"
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
]
}

View file

@ -144,6 +144,10 @@ func InitDB(dbPath string) error {
if err != nil {
return err
}
err = migrateEndpointTls()
if err != nil {
return err
}
reportSingBox114Manual()
return nil

View file

@ -0,0 +1,443 @@
package database
import (
"encoding/json"
"log"
"github.com/alireza0/s-ui/database/model"
"gorm.io/gorm"
)
// migratedKeyEndpointTls marks that endpoints referencing a shared TLS config
// have been given their own inline `tls` object instead.
const migratedKeyEndpointTls = "migratedEndpointTls"
// OpenConnect and OpenVPN endpoints used to point at a panel TLS config, which
// the core projected into the shape each one accepts. The projection could only
// ever carry the handful of fields the two vocabularies share, so an OpenVPN
// endpoint set up that way never had enough to build a TLS session; those
// endpoints now hold their own `tls` object written in sing-box's own names.
//
// migrateEndpointTls runs the old projection one last time and stores the
// result inline, so an endpoint that did work keeps working and one that did
// not at least keeps the certificates the operator chose, ready to be completed
// in the endpoint's own form.
func migrateEndpointTls() error {
var flag model.Setting
err := db.Where("key = ?", migratedKeyEndpointTls).First(&flag).Error
if err == nil {
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
if !tx.Migrator().HasTable("endpoints") {
return tx.Create(&model.Setting{Key: migratedKeyEndpointTls, Value: "true"}).Error
}
// A database created after the column was dropped has nothing to move.
if tx.Migrator().HasColumn("endpoints", "tls_id") {
changed, err := inlineEndpointTls(tx)
if err != nil {
return err
}
if changed > 0 {
log.Printf("endpoint tls: moved %d shared TLS config(s) into the endpoint(s) that used them", changed)
}
}
cleaned, err := dropModeConflictingOpenVPNOptions(tx)
if err != nil {
return err
}
if cleaned > 0 {
log.Printf("endpoint tls: dropped options from %d openvpn endpoint(s) that do not belong to their mode", cleaned)
}
settled, err := settleOpenVPNClientCertificatePolicy(tx)
if err != nil {
return err
}
if settled > 0 {
log.Printf("endpoint tls: %d openvpn server(s) asked for client certificates with no CA to check them against, now set to not ask", settled)
}
return tx.Create(&model.Setting{Key: migratedKeyEndpointTls, Value: "true"}).Error
})
}
// The two OpenVPN modes each reject the other's options outright, and the panel
// used to offer `cipher` in both. An endpoint carrying one refuses to start, so
// the option that cannot apply is dropped rather than left to fail at runtime.
//
// static_key names a single fixed cipher; a TLS session negotiates one out of
// data_ciphers instead. The remaining names are ones a TLS-mode endpoint has no
// use for either way.
var openVPNModeConflicts = map[string][]string{
"tls": {"cipher", "key_direction", "static_key", "static_key_path"},
"static_key": {"data_ciphers", "data_ciphers_fallback"},
}
// openVPNServerTLSConflicts are rejected on a server in TLS mode on top of the
// shared list: they describe the single peer a static_key tunnel has.
var openVPNServerTLSConflicts = []string{"remote", "remote_port", "peer_address", "peer_address_ipv6"}
func dropModeConflictingOpenVPNOptions(tx *gorm.DB) (int, error) {
var rows []endpointTlsRow
err := tx.Table("endpoints").Select("id", "type", "options").
Where("type in ?", []string{"openvpn-server", "openvpn-client"}).Scan(&rows).Error
if err != nil {
return 0, err
}
cleaned := 0
for _, row := range rows {
options := make(map[string]json.RawMessage)
if len(row.Options) > 0 {
if err = json.Unmarshal(row.Options, &options); err != nil {
log.Printf("endpoint tls: skipping endpoint %d, cannot parse its options: %v", row.Id, err)
continue
}
}
// sing-box reads a missing mode as tls.
mode := "tls"
if raw, ok := options["mode"]; ok {
var stored string
if err = json.Unmarshal(raw, &stored); err == nil && stored != "" {
mode = stored
}
}
conflicting := openVPNModeConflicts[mode]
if mode == "tls" && row.Type == "openvpn-server" {
conflicting = append(append([]string{}, conflicting...), openVPNServerTLSConflicts...)
}
dropped := false
for _, field := range conflicting {
if value, ok := options[field]; ok {
// An empty value is not what sing-box refuses, and removing it
// would rewrite rows that were fine.
if isEmptyJSON(value) || string(value) == "0" {
continue
}
delete(options, field)
dropped = true
}
}
if !dropped {
continue
}
encoded, err := json.MarshalIndent(options, "", " ")
if err != nil {
return 0, err
}
if err = tx.Table("endpoints").Where("id = ?", row.Id).
Update("options", json.RawMessage(encoded)).Error; err != nil {
return 0, err
}
cleaned++
}
return cleaned, nil
}
// endpointTlsRow reads the two columns the migration needs by name, since the
// endpoint model no longer declares tls_id.
type endpointTlsRow struct {
Id uint
Type string
TlsId uint
Options json.RawMessage
}
func inlineEndpointTls(tx *gorm.DB) (int, error) {
var rows []endpointTlsRow
err := tx.Table("endpoints").Select("id", "type", "tls_id", "options").
Where("tls_id > 0").Scan(&rows).Error
if err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
var tlsConfigs []model.Tls
if err = tx.Find(&tlsConfigs).Error; err != nil {
return 0, err
}
byId := make(map[uint]*model.Tls, len(tlsConfigs))
for i := range tlsConfigs {
byId[tlsConfigs[i].Id] = &tlsConfigs[i]
}
changed := 0
for _, row := range rows {
tlsConfig, ok := byId[row.TlsId]
if !ok {
// The config was deleted out from under the endpoint. Clearing the
// reference is all that is left to do.
if err = tx.Table("endpoints").Where("id = ?", row.Id).Update("tls_id", 0).Error; err != nil {
return 0, err
}
continue
}
projected, err := projectEndpointTLS(row.Type, tlsConfig)
if err != nil {
log.Printf("endpoint tls: skipping endpoint %d, cannot read TLS config %d: %v", row.Id, row.TlsId, err)
continue
}
options := make(map[string]json.RawMessage)
if len(row.Options) > 0 {
if err = json.Unmarshal(row.Options, &options); err != nil {
log.Printf("endpoint tls: skipping endpoint %d, cannot parse its options: %v", row.Id, err)
continue
}
}
// An endpoint that already carries its own tls object was written after
// the change and keeps what it has.
if _, exists := options["tls"]; !exists && projected != nil {
options["tls"] = projected
changed++
}
encoded, err := json.MarshalIndent(options, "", " ")
if err != nil {
return 0, err
}
if err = tx.Table("endpoints").Where("id = ?", row.Id).
Updates(map[string]interface{}{"options": json.RawMessage(encoded), "tls_id": 0}).Error; err != nil {
return 0, err
}
}
return changed, nil
}
// projectEndpointTLS copies a panel TLS config into the field names the
// endpoint type uses. Whatever the endpoint has no equivalent for is left out.
func projectEndpointTLS(endpointType string, tlsConfig *model.Tls) (json.RawMessage, error) {
var source map[string]json.RawMessage
switch endpointType {
case "openvpn-server":
// A server presents a certificate, so it reads the server side.
if len(tlsConfig.Server) == 0 {
return nil, nil
}
if err := json.Unmarshal(tlsConfig.Server, &source); err != nil {
return nil, err
}
projected, err := projectTLSFields(source, openVPNServerTLSFields, openVPNServerTLSRenames)
if err != nil {
return nil, err
}
return withOpenVPNServerClientAuth(projected, source)
case "openvpn-client":
if len(tlsConfig.Client) == 0 {
return nil, nil
}
if err := json.Unmarshal(tlsConfig.Client, &source); err != nil {
return nil, err
}
return projectTLSFields(source, openVPNClientTLSFields, openVPNClientTLSRenames)
case "openconnect":
if len(tlsConfig.Client) == 0 {
return nil, nil
}
if err := json.Unmarshal(tlsConfig.Client, &source); err != nil {
return nil, err
}
return projectTLSFields(source, openConnectTLSFields, openConnectTLSRenames)
default:
return nil, nil
}
}
// Fields carried over verbatim, keyed by the name they have in the panel's TLS
// config. Renames map a panel field onto the endpoint's name for it.
var (
openVPNServerTLSFields = []string{
"certificate", "certificate_path", "key", "key_path",
"client_certificate", "peer_fingerprint", "crl_path",
}
openVPNServerTLSRenames = map[string]string{
"min_version": "version_min",
"max_version": "version_max",
}
openVPNClientTLSFields = []string{
"server_name", "certificate", "certificate_path",
"client_certificate", "client_certificate_path",
"client_key", "client_key_path", "peer_fingerprint", "crl_path",
}
openVPNClientTLSRenames = map[string]string{
"min_version": "version_min",
"max_version": "version_max",
}
openConnectTLSFields = []string{
"server_name", "insecure",
"client_certificate", "client_certificate_path",
"client_key", "client_key_path", "peer_fingerprint",
}
// OpenConnect names the trust anchor after its role rather than reusing
// the bare `certificate` the other TLS structs use.
openConnectTLSRenames = map[string]string{
"certificate": "certificate_authority",
"certificate_path": "certificate_authority_path",
}
)
// withOpenVPNServerClientAuth folds in the two client-auth fields that need
// more than a rename. The panel stores client CA paths as a list, since
// sing-box inbounds accept several, while OpenVPN takes a single one; and the
// two sides describe verification with different vocabularies.
func withOpenVPNServerClientAuth(projected json.RawMessage, source map[string]json.RawMessage) (json.RawMessage, error) {
fields := make(map[string]json.RawMessage)
if projected != nil {
if err := json.Unmarshal(projected, &fields); err != nil {
return nil, err
}
}
if raw, ok := source["client_certificate_path"]; ok && !isEmptyJSON(raw) {
var paths []string
if err := json.Unmarshal(raw, &paths); err == nil && len(paths) > 0 {
encoded, err := json.Marshal(paths[0])
if err != nil {
return nil, err
}
fields["client_certificate_path"] = encoded
}
}
if raw, ok := source["client_authentication"]; ok && !isEmptyJSON(raw) {
var authType string
if err := json.Unmarshal(raw, &authType); err == nil {
if verify := verifyClientCertificateFor(authType); verify != "" {
encoded, err := json.Marshal(verify)
if err != nil {
return nil, err
}
fields["verify_client_certificate"] = encoded
}
}
}
if len(fields) == 0 {
return nil, nil
}
return json.Marshal(fields)
}
// verifyClientCertificateFor maps sing-box's client authentication modes onto
// the three OpenVPN understands. An empty result means leave it unset.
func verifyClientCertificateFor(clientAuthentication string) string {
switch clientAuthentication {
case "require-any", "require-and-verify":
return "require"
case "request", "verify-if-given":
return "optional"
default:
return ""
}
}
func projectTLSFields(source map[string]json.RawMessage, fields []string, renames map[string]string) (json.RawMessage, error) {
projected := make(map[string]json.RawMessage)
for _, field := range fields {
if value, ok := source[field]; ok && !isEmptyJSON(value) {
projected[field] = value
}
}
for field, renamed := range renames {
if value, ok := source[field]; ok && !isEmptyJSON(value) {
projected[renamed] = value
}
}
if len(projected) == 0 {
return nil, nil
}
return json.Marshal(projected)
}
// isEmptyJSON reports whether a value carries nothing worth copying, so an
// unset field in the panel does not become an explicit empty one on the
// endpoint.
func isEmptyJSON(value json.RawMessage) bool {
switch string(value) {
case "", "null", `""`, "[]", "{}", "false":
return true
}
return false
}
// sing-box reads an unset `verify_client_certificate` as "require", so an
// OpenVPN server that never mentioned client certificates still demands one and
// then refuses to start for want of a CA to check it against:
//
// tls mode: either certificate-authority or peer-fingerprint must be configured
//
// settleOpenVPNClientCertificatePolicy writes the choice such a server was
// already making by omission. A server that names a CA, pins a fingerprint, or
// states a policy of its own is left alone: only the unstated case is filled in,
// so nothing an operator actually asked for is weakened.
func settleOpenVPNClientCertificatePolicy(tx *gorm.DB) (int, error) {
var rows []endpointTlsRow
err := tx.Table("endpoints").Select("id", "type", "options").
Where("type = ?", "openvpn-server").Scan(&rows).Error
if err != nil {
return 0, err
}
settled := 0
for _, row := range rows {
options := make(map[string]json.RawMessage)
if len(row.Options) > 0 {
if err = json.Unmarshal(row.Options, &options); err != nil {
continue
}
}
if mode, ok := options["mode"]; ok && !isEmptyJSON(mode) && string(mode) != `"tls"` {
continue
}
rawTLS, ok := options["tls"]
if !ok || isEmptyJSON(rawTLS) {
// No TLS block at all: the endpoint has no certificate either, and
// giving it a policy would not make it start.
continue
}
var tlsOptions map[string]json.RawMessage
if err = json.Unmarshal(rawTLS, &tlsOptions); err != nil {
continue
}
if _, stated := tlsOptions["verify_client_certificate"]; stated {
continue
}
if hasAnyOf(tlsOptions, "client_certificate", "client_certificate_path", "peer_fingerprint") {
continue
}
tlsOptions["verify_client_certificate"] = json.RawMessage(`"none"`)
encodedTLS, err := json.Marshal(tlsOptions)
if err != nil {
return 0, err
}
options["tls"] = encodedTLS
encoded, err := json.MarshalIndent(options, "", " ")
if err != nil {
return 0, err
}
if err = tx.Table("endpoints").Where("id = ?", row.Id).
Update("options", json.RawMessage(encoded)).Error; err != nil {
return 0, err
}
settled++
}
return settled, nil
}
func hasAnyOf(fields map[string]json.RawMessage, names ...string) bool {
for _, name := range names {
if value, ok := fields[name]; ok && !isEmptyJSON(value) {
return true
}
}
return false
}

View file

@ -0,0 +1,434 @@
package database
import (
"encoding/json"
"testing"
"github.com/alireza0/s-ui/database/model"
)
// legacyEndpoint mirrors the endpoints table as it was while endpoints pointed
// at a shared TLS config, so the migration has a column to read.
type legacyEndpoint struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Type string
Tag string
TlsId uint
Options json.RawMessage
Ext json.RawMessage
}
func (legacyEndpoint) TableName() string { return "endpoints" }
func openEndpointTestDB(t *testing.T) {
t.Helper()
openTestDB(t)
if err := db.AutoMigrate(&legacyEndpoint{}); err != nil {
t.Fatal(err)
}
}
func endpointOptions(t *testing.T, id uint) map[string]any {
t.Helper()
var stored legacyEndpoint
if err := db.Where("id = ?", id).First(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.TlsId != 0 {
t.Errorf("tls_id = %d, want it cleared", stored.TlsId)
}
var options map[string]any
if err := json.Unmarshal(stored.Options, &options); err != nil {
t.Fatal(err)
}
return options
}
func TestMigrateEndpointTlsOpenVPNClient(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&model.Tls{
Name: "vpn",
Client: json.RawMessage(`{
"enabled": true,
"server_name": "vpn.example.com",
"certificate_path": "/etc/ssl/ca.crt",
"client_certificate_path": "/etc/ssl/client.crt",
"client_key_path": "/etc/ssl/client.key",
"min_version": "1.2"
}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&legacyEndpoint{
Type: "openvpn-client",
Tag: "ovpn",
TlsId: 1,
Options: json.RawMessage(`{"server":"vpn.example.com","server_port":1194,"mode":"tls"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
options := endpointOptions(t, 1)
if options["server"] != "vpn.example.com" {
t.Errorf("server was lost: %v", options)
}
tls, ok := options["tls"].(map[string]any)
if !ok {
t.Fatalf("no inline tls object: %v", options)
}
if tls["certificate_path"] != "/etc/ssl/ca.crt" {
t.Errorf("certificate_path = %v", tls["certificate_path"])
}
if tls["client_key_path"] != "/etc/ssl/client.key" {
t.Errorf("client_key_path = %v", tls["client_key_path"])
}
// The panel calls it min_version; OpenVPN calls it version_min.
if tls["version_min"] != "1.2" {
t.Errorf("version_min = %v", tls["version_min"])
}
// `enabled` has no counterpart and sing-box rejects unknown keys.
if _, exists := tls["enabled"]; exists {
t.Errorf("enabled was carried over: %v", tls)
}
}
func TestMigrateEndpointTlsOpenVPNServerClientAuth(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&model.Tls{
Name: "vpn",
Server: json.RawMessage(`{
"enabled": true,
"certificate_path": "/etc/ssl/server.crt",
"key_path": "/etc/ssl/server.key",
"client_certificate_path": ["/etc/ssl/clients-ca.crt", "/etc/ssl/other-ca.crt"],
"client_authentication": "require-and-verify"
}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&legacyEndpoint{
Type: "openvpn-server",
Tag: "ovpn-srv",
TlsId: 1,
Options: json.RawMessage(`{"listen":"::","listen_port":1194,"mode":"tls"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
tls, ok := endpointOptions(t, 1)["tls"].(map[string]any)
if !ok {
t.Fatal("no inline tls object")
}
// OpenVPN takes a single client CA where a sing-box inbound takes a list.
if tls["client_certificate_path"] != "/etc/ssl/clients-ca.crt" {
t.Errorf("client_certificate_path = %v", tls["client_certificate_path"])
}
if tls["verify_client_certificate"] != "require" {
t.Errorf("verify_client_certificate = %v", tls["verify_client_certificate"])
}
}
func TestMigrateEndpointTlsOpenConnectRenamesTrustAnchor(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&model.Tls{
Name: "oc",
Client: json.RawMessage(`{"enabled":true,"certificate_path":"/etc/ssl/ca.crt","insecure":true}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&legacyEndpoint{
Type: "openconnect",
Tag: "oc",
TlsId: 1,
Options: json.RawMessage(`{"server":"vpn.example.com"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
tls, ok := endpointOptions(t, 1)["tls"].(map[string]any)
if !ok {
t.Fatal("no inline tls object")
}
if tls["certificate_authority_path"] != "/etc/ssl/ca.crt" {
t.Errorf("certificate_authority_path = %v", tls["certificate_authority_path"])
}
if tls["insecure"] != true {
t.Errorf("insecure = %v", tls["insecure"])
}
}
func TestMigrateEndpointTlsKeepsExistingInlineTls(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&model.Tls{
Name: "vpn",
Client: json.RawMessage(`{"enabled":true,"certificate_path":"/etc/ssl/template-ca.crt"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&legacyEndpoint{
Type: "openvpn-client",
Tag: "ovpn",
TlsId: 1,
Options: json.RawMessage(`{"server":"vpn.example.com","tls":{"certificate_path":"/etc/ssl/own-ca.crt"}}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
tls := endpointOptions(t, 1)["tls"].(map[string]any)
if tls["certificate_path"] != "/etc/ssl/own-ca.crt" {
t.Errorf("the endpoint's own tls block was overwritten: %v", tls)
}
}
func TestMigrateEndpointTlsClearsDanglingReference(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&legacyEndpoint{
Type: "openvpn-client",
Tag: "ovpn",
TlsId: 7,
Options: json.RawMessage(`{"server":"vpn.example.com"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
options := endpointOptions(t, 1)
if _, exists := options["tls"]; exists {
t.Errorf("a missing TLS config produced a tls block: %v", options)
}
}
func TestMigrateEndpointTlsLeavesOtherTypesAlone(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&model.Tls{
Name: "vpn",
Client: json.RawMessage(`{"enabled":true,"certificate_path":"/etc/ssl/ca.crt"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&legacyEndpoint{
Type: "wireguard",
Tag: "wg",
TlsId: 1,
Options: json.RawMessage(`{"listen_port":51820}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
options := endpointOptions(t, 1)
if _, exists := options["tls"]; exists {
t.Errorf("wireguard was given a tls block: %v", options)
}
}
func TestMigrateEndpointTlsRunsOnce(t *testing.T) {
openEndpointTestDB(t)
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
// A second run must not fail on the flag it wrote itself.
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
var flag model.Setting
if err := db.Where("key = ?", migratedKeyEndpointTls).First(&flag).Error; err != nil {
t.Fatal(err)
}
}
// A database created after the tls_id column was dropped has no column to read,
// which must not stop the panel from starting.
func TestMigrateEndpointTlsWithoutColumn(t *testing.T) {
openTestDB(t)
if err := db.AutoMigrate(&model.Endpoint{}); err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
}
func TestMigrateEndpointTlsDropsCipherInTlsMode(t *testing.T) {
openEndpointTestDB(t)
// The panel used to offer `cipher` in both modes, and sing-box rejects it
// in TLS mode, so the endpoint never started.
if err := db.Create(&legacyEndpoint{
Type: "openvpn-client",
Tag: "ovpn",
Options: json.RawMessage(`{"server":"vpn.example.com","mode":"tls","cipher":"AES-256-GCM","auth":"SHA256"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
options := endpointOptions(t, 1)
if _, exists := options["cipher"]; exists {
t.Errorf("cipher survived in tls mode: %v", options)
}
if options["auth"] != "SHA256" {
t.Errorf("auth was dropped with it: %v", options)
}
}
func TestMigrateEndpointTlsKeepsCipherInStaticKeyMode(t *testing.T) {
openEndpointTestDB(t)
if err := db.Create(&legacyEndpoint{
Type: "openvpn-client",
Tag: "ovpn",
Options: json.RawMessage(`{"server":"vpn.example.com","mode":"static_key","cipher":"AES-256-CBC","static_key_path":"/etc/openvpn/static.key"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
options := endpointOptions(t, 1)
if options["cipher"] != "AES-256-CBC" {
t.Errorf("cipher = %v, want it kept in static_key mode", options["cipher"])
}
if options["static_key_path"] != "/etc/openvpn/static.key" {
t.Errorf("static_key_path = %v", options["static_key_path"])
}
}
func TestMigrateEndpointTlsDropsPeerAddressOnTlsServer(t *testing.T) {
openEndpointTestDB(t)
// In TLS mode a server pushes addresses to whichever clients connect, so
// sing-box refuses the single peer a static_key tunnel would name.
if err := db.Create(&legacyEndpoint{
Type: "openvpn-server",
Tag: "ovpn-srv",
Options: json.RawMessage(`{"listen":"::","listen_port":1194,"mode":"tls","peer_address":"10.8.0.2","address":["10.8.0.1/24"]}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
options := endpointOptions(t, 1)
if _, exists := options["peer_address"]; exists {
t.Errorf("peer_address survived on a tls-mode server: %v", options)
}
}
func TestMigrateEndpointTlsLeavesUntouchedRowsAlone(t *testing.T) {
openEndpointTestDB(t)
original := `{"server":"vpn.example.com","mode":"tls","auth":"SHA256"}`
if err := db.Create(&legacyEndpoint{
Type: "openvpn-client",
Tag: "ovpn",
Options: json.RawMessage(original),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
var stored legacyEndpoint
if err := db.Where("id = ?", 1).First(&stored).Error; err != nil {
t.Fatal(err)
}
if string(stored.Options) != original {
t.Errorf("a row with nothing to fix was rewritten:\n%s", stored.Options)
}
}
func TestMigrateEndpointTlsSettlesClientCertificatePolicy(t *testing.T) {
openEndpointTestDB(t)
// sing-box reads a missing verify_client_certificate as "require", so this
// server demands certificates it has no CA to check, and refuses to start.
if err := db.Create(&legacyEndpoint{
Type: "openvpn-server",
Tag: "ovpn-srv",
Options: json.RawMessage(`{"listen":"::","listen_port":1194,"mode":"tls","address":["10.8.0.1/24"],"tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key"}}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
tls, ok := endpointOptions(t, 1)["tls"].(map[string]any)
if !ok {
t.Fatal("tls block went missing")
}
if tls["verify_client_certificate"] != "none" {
t.Errorf("verify_client_certificate = %v, want none", tls["verify_client_certificate"])
}
if tls["certificate_path"] != "/etc/ssl/s.crt" {
t.Errorf("the certificate was lost: %v", tls)
}
}
func TestMigrateEndpointTlsKeepsStatedClientCertificatePolicy(t *testing.T) {
openEndpointTestDB(t)
for _, endpoint := range []legacyEndpoint{
// Asked for client certificates in so many words.
{Type: "openvpn-server", Tag: "stated", Options: json.RawMessage(
`{"mode":"tls","tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key","verify_client_certificate":"require","client_certificate_path":"/etc/ssl/ca.crt"}}`)},
// Has a CA, so the "require" it gets by default is what it wants.
{Type: "openvpn-server", Tag: "hasca", Options: json.RawMessage(
`{"mode":"tls","tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key","client_certificate_path":"/etc/ssl/ca.crt"}}`)},
// Pins the peer instead of naming a CA, which satisfies sing-box too.
{Type: "openvpn-server", Tag: "pinned", Options: json.RawMessage(
`{"mode":"tls","tls":{"certificate_path":"/etc/ssl/s.crt","key_path":"/etc/ssl/s.key","peer_fingerprint":["ab"]}}`)},
// static_key mode never reaches the TLS credential checks.
{Type: "openvpn-server", Tag: "static", Options: json.RawMessage(
`{"mode":"static_key","static_key_path":"/etc/openvpn/static.key","tls":{"certificate_path":"/etc/ssl/s.crt"}}`)},
} {
if err := db.Create(&endpoint).Error; err != nil {
t.Fatal(err)
}
}
if err := migrateEndpointTls(); err != nil {
t.Fatal(err)
}
for id := uint(1); id <= 4; id++ {
tls, ok := endpointOptions(t, id)["tls"].(map[string]any)
if !ok {
t.Fatalf("endpoint %d lost its tls block", id)
}
want := any(nil)
if id == 1 {
want = "require"
}
if tls["verify_client_certificate"] != want {
t.Errorf("endpoint %d: verify_client_certificate = %v, want %v", id, tls["verify_client_certificate"], want)
}
}
}

View file

@ -1,211 +0,0 @@
package model
import "encoding/json"
// OpenConnect and OpenVPN do not use sing-box's standard TLS options. They each
// define their own struct with a different field set, and sing-box rejects
// unknown fields, so a panel TLS config cannot be handed to them as-is: even
// `enabled`, which every stored config carries, fails to parse.
//
// projectEndpointTLS copies across the fields the endpoint actually accepts.
// Whatever the endpoint has no equivalent for is left out rather than failing
// the whole config: an endpoint is one row among many, and refusing to render
// the config would stop the core from starting at all. The UI is what tells the
// operator which TLS configs suit which endpoint, via unsupportedEndpointTLS.
func projectEndpointTLS(endpointType string, tlsConfig *Tls) (json.RawMessage, error) {
var source map[string]json.RawMessage
switch endpointType {
case "openvpn-server":
// A server presents a certificate, so it reads the server side.
if len(tlsConfig.Server) == 0 {
return nil, nil
}
if err := json.Unmarshal(tlsConfig.Server, &source); err != nil {
return nil, err
}
projected, err := projectTLSFields(source, openVPNServerTLSFields, openVPNServerTLSRenames)
if err != nil {
return nil, err
}
return withOpenVPNServerClientAuth(projected, source)
case "openvpn-client":
if len(tlsConfig.Client) == 0 {
return nil, nil
}
if err := json.Unmarshal(tlsConfig.Client, &source); err != nil {
return nil, err
}
return projectTLSFields(source, openVPNClientTLSFields, openVPNClientTLSRenames)
case "openconnect":
if len(tlsConfig.Client) == 0 {
return nil, nil
}
if err := json.Unmarshal(tlsConfig.Client, &source); err != nil {
return nil, err
}
return projectTLSFields(source, openConnectTLSFields, openConnectTLSRenames)
default:
return nil, nil
}
}
// Fields carried over verbatim, keyed by the name they have in the panel's TLS
// config. Renames map a panel field onto the endpoint's name for it.
var (
openVPNServerTLSFields = []string{
"certificate", "certificate_path", "key", "key_path",
"client_certificate", "peer_fingerprint", "crl_path",
}
openVPNServerTLSRenames = map[string]string{
"min_version": "version_min",
"max_version": "version_max",
}
openVPNClientTLSFields = []string{
"server_name", "certificate", "certificate_path",
"client_certificate", "client_certificate_path",
"client_key", "client_key_path", "peer_fingerprint", "crl_path",
}
openVPNClientTLSRenames = map[string]string{
"min_version": "version_min",
"max_version": "version_max",
}
openConnectTLSFields = []string{
"server_name", "insecure",
"client_certificate", "client_certificate_path",
"client_key", "client_key_path", "peer_fingerprint",
}
// OpenConnect names the trust anchor after its role rather than reusing
// the bare `certificate` the other TLS structs use.
openConnectTLSRenames = map[string]string{
"certificate": "certificate_authority",
"certificate_path": "certificate_authority_path",
}
)
// withOpenVPNServerClientAuth folds in the two client-auth fields that need
// more than a rename. The panel stores client CA paths as a list, since
// sing-box inbounds accept several, while OpenVPN takes a single one; and the
// two sides describe verification with different vocabularies.
func withOpenVPNServerClientAuth(projected json.RawMessage, source map[string]json.RawMessage) (json.RawMessage, error) {
fields := make(map[string]json.RawMessage)
if projected != nil {
if err := json.Unmarshal(projected, &fields); err != nil {
return nil, err
}
}
if raw, ok := source["client_certificate_path"]; ok && !isEmptyJSON(raw) {
var paths []string
if err := json.Unmarshal(raw, &paths); err == nil && len(paths) > 0 {
encoded, err := json.Marshal(paths[0])
if err != nil {
return nil, err
}
fields["client_certificate_path"] = encoded
}
}
if raw, ok := source["client_authentication"]; ok && !isEmptyJSON(raw) {
var authType string
if err := json.Unmarshal(raw, &authType); err == nil {
if verify := verifyClientCertificateFor(authType); verify != "" {
encoded, err := json.Marshal(verify)
if err != nil {
return nil, err
}
fields["verify_client_certificate"] = encoded
}
}
}
if len(fields) == 0 {
return nil, nil
}
return json.Marshal(fields)
}
// verifyClientCertificateFor maps sing-box's client authentication modes onto
// the three OpenVPN understands. An empty result means leave it unset.
func verifyClientCertificateFor(clientAuthentication string) string {
switch clientAuthentication {
case "require-any", "require-and-verify":
return "require"
case "request", "verify-if-given":
return "optional"
default:
return ""
}
}
func projectTLSFields(source map[string]json.RawMessage, fields []string, renames map[string]string) (json.RawMessage, error) {
projected := make(map[string]json.RawMessage)
for _, field := range fields {
if value, ok := source[field]; ok && !isEmptyJSON(value) {
projected[field] = value
}
}
for field, renamed := range renames {
if value, ok := source[field]; ok && !isEmptyJSON(value) {
projected[renamed] = value
}
}
if len(projected) == 0 {
// sing-box treats an empty object as no TLS options at all, so there is
// nothing to gain from emitting one.
return nil, nil
}
return json.Marshal(projected)
}
// isEmptyJSON reports whether a value carries nothing worth copying, so an
// unset field in the panel does not become an explicit empty one on the
// endpoint.
func isEmptyJSON(value json.RawMessage) bool {
switch string(value) {
case "", "null", `""`, "[]", "{}", "false":
return true
}
return false
}
// unsupportedEndpointTLS lists the panel TLS fields that carry real behaviour
// but have no equivalent on this endpoint type, so the UI can warn before an
// operator picks a config that will not do what they expect. Fields that simply
// do not apply to a VPN tunnel (alpn, utls, fragmentation) are not listed.
func unsupportedEndpointTLS(endpointType string, tlsConfig *Tls) ([]string, error) {
var (
source map[string]json.RawMessage
raw json.RawMessage
checked []string
)
switch endpointType {
case "openvpn-server":
raw = tlsConfig.Server
checked = []string{"certificate_provider", "acme", "reality", "ech", "client_certificate_public_key_sha256"}
case "openvpn-client":
raw = tlsConfig.Client
// OpenVPN always verifies its peer, so a config relying on `insecure`
// will not connect without a certificate authority.
checked = []string{"insecure", "reality", "ech", "certificate_public_key_sha256"}
case "openconnect":
raw = tlsConfig.Client
checked = []string{"reality", "ech", "certificate_public_key_sha256"}
default:
return nil, nil
}
if len(raw) == 0 {
return nil, nil
}
if err := json.Unmarshal(raw, &source); err != nil {
return nil, err
}
var unsupported []string
for _, field := range checked {
if value, ok := source[field]; ok && !isEmptyJSON(value) {
unsupported = append(unsupported, field)
}
}
return unsupported, nil
}

View file

@ -1,305 +0,0 @@
package model
import (
"encoding/json"
"slices"
"testing"
)
func decode(t *testing.T, raw json.RawMessage) map[string]any {
t.Helper()
if raw == nil {
return nil
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
return decoded
}
// A panel TLS config as the UI writes it: the server side carries `enabled`,
// which is exactly the field the endpoint TLS structs reject.
func panelTLS() *Tls {
return &Tls{
Name: "example",
Server: json.RawMessage(`{
"enabled": true,
"server_name": "vpn.example.com",
"alpn": ["h2"],
"min_version": "1.2",
"certificate_path": "/etc/s-ui/cert.pem",
"key_path": "/etc/s-ui/key.pem",
"client_certificate_path": ["/etc/s-ui/client-ca.pem"]
}`),
Client: json.RawMessage(`{
"enabled": true,
"server_name": "vpn.example.com",
"insecure": true,
"utls": {"enabled": true, "fingerprint": "chrome"},
"certificate_path": "/etc/s-ui/ca.pem",
"client_certificate_path": "/etc/s-ui/client.pem",
"client_key_path": "/etc/s-ui/client.key"
}`),
}
}
func TestProjectEndpointTLSOpenVPNServer(t *testing.T) {
projected, err := projectEndpointTLS("openvpn-server", panelTLS())
if err != nil {
t.Fatal(err)
}
got := decode(t, projected)
if got["certificate_path"] != "/etc/s-ui/cert.pem" || got["key_path"] != "/etc/s-ui/key.pem" {
t.Errorf("certificate and key must carry over, got %v", got)
}
if got["version_min"] != "1.2" {
t.Errorf("min_version should be renamed to version_min, got %v", got["version_min"])
}
// These are the ones sing-box would reject outright.
for _, dropped := range []string{"enabled", "alpn", "min_version", "server_name"} {
if _, ok := got[dropped]; ok {
t.Errorf("%q must not reach an openvpn-server, got %v", dropped, got)
}
}
}
func TestProjectEndpointTLSOpenVPNClient(t *testing.T) {
projected, err := projectEndpointTLS("openvpn-client", panelTLS())
if err != nil {
t.Fatal(err)
}
got := decode(t, projected)
if got["server_name"] != "vpn.example.com" {
t.Errorf("server_name must carry over, got %v", got)
}
if got["certificate_path"] != "/etc/s-ui/ca.pem" {
t.Errorf("the CA path must carry over, got %v", got)
}
if got["client_certificate_path"] != "/etc/s-ui/client.pem" || got["client_key_path"] != "/etc/s-ui/client.key" {
t.Errorf("client credentials must carry over, got %v", got)
}
// OpenVPN has no insecure switch and no use for a utls fingerprint.
for _, dropped := range []string{"enabled", "insecure", "utls"} {
if _, ok := got[dropped]; ok {
t.Errorf("%q must not reach an openvpn-client, got %v", dropped, got)
}
}
}
// OpenConnect names the trust anchor differently from the other TLS structs.
func TestProjectEndpointTLSOpenConnect(t *testing.T) {
projected, err := projectEndpointTLS("openconnect", panelTLS())
if err != nil {
t.Fatal(err)
}
got := decode(t, projected)
if got["certificate_authority_path"] != "/etc/s-ui/ca.pem" {
t.Errorf("certificate_path should become certificate_authority_path, got %v", got)
}
if _, ok := got["certificate_path"]; ok {
t.Errorf("the original certificate_path must not survive, got %v", got)
}
if got["insecure"] != true {
t.Errorf("openconnect supports insecure, got %v", got)
}
for _, dropped := range []string{"enabled", "utls"} {
if _, ok := got[dropped]; ok {
t.Errorf("%q must not reach an openconnect endpoint, got %v", dropped, got)
}
}
}
// An endpoint type that has no TLS of its own must not be handed one.
func TestProjectEndpointTLSIgnoresOtherTypes(t *testing.T) {
for _, endpointType := range []string{"wireguard", "warp", "tailscale"} {
projected, err := projectEndpointTLS(endpointType, panelTLS())
if err != nil {
t.Fatal(err)
}
if projected != nil {
t.Errorf("%s should get no tls block, got %s", endpointType, projected)
}
}
}
// A TLS config with nothing the endpoint can use should produce no block at
// all, since sing-box reads an empty object as absent anyway.
func TestProjectEndpointTLSEmptyResult(t *testing.T) {
tlsConfig := &Tls{Client: json.RawMessage(`{"enabled": true, "alpn": ["h2"]}`)}
projected, err := projectEndpointTLS("openvpn-client", tlsConfig)
if err != nil {
t.Fatal(err)
}
if projected != nil {
t.Errorf("expected no tls block, got %s", projected)
}
}
func TestUnsupportedEndpointTLS(t *testing.T) {
realityTLS := &Tls{
Server: json.RawMessage(`{"enabled": true, "reality": {"enabled": true}, "certificate_path": "/c.pem"}`),
Client: json.RawMessage(`{"enabled": true, "insecure": true, "utls": {"enabled": true}}`),
}
serverUnsupported, err := unsupportedEndpointTLS("openvpn-server", realityTLS)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(serverUnsupported, "reality") {
t.Errorf("reality should be reported for openvpn-server, got %v", serverUnsupported)
}
clientUnsupported, err := unsupportedEndpointTLS("openvpn-client", realityTLS)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(clientUnsupported, "insecure") {
t.Errorf("insecure should be reported for openvpn-client, got %v", clientUnsupported)
}
// openconnect does support insecure, so it must not be flagged there.
openconnectUnsupported, err := unsupportedEndpointTLS("openconnect", realityTLS)
if err != nil {
t.Fatal(err)
}
if slices.Contains(openconnectUnsupported, "insecure") {
t.Errorf("openconnect supports insecure, got %v", openconnectUnsupported)
}
}
// A referenced TLS config is an explicit choice by the operator, so it must
// replace any TLS fields the endpoint still carries inline rather than being
// quietly overwritten by them.
func TestEndpointMarshalTLSPrecedence(t *testing.T) {
endpoint := Endpoint{
Type: "openconnect",
Tag: "oc",
TlsId: 1,
Tls: &Tls{Client: json.RawMessage(`{"enabled": true, "certificate_path": "/from-panel.pem"}`)},
Options: json.RawMessage(`{"server":"vpn.example.com","tls":{"certificate_authority_path":"/inline.pem"}}`),
}
raw, err := endpoint.MarshalJSON()
if err != nil {
t.Fatal(err)
}
got := decode(t, raw)
tlsBlock, ok := got["tls"].(map[string]any)
if !ok {
t.Fatalf("expected a tls block, got %v", got)
}
if tlsBlock["certificate_authority_path"] != "/from-panel.pem" {
t.Errorf("the referenced config must win over the inline one, got %v", tlsBlock)
}
}
// Without a referenced config the inline TLS the UI wrote must survive intact.
func TestEndpointMarshalKeepsInlineTLS(t *testing.T) {
endpoint := Endpoint{
Type: "openconnect",
Tag: "oc",
Options: json.RawMessage(`{"server":"vpn.example.com","tls":{"certificate_authority_path":"/inline.pem"}}`),
}
raw, err := endpoint.MarshalJSON()
if err != nil {
t.Fatal(err)
}
got := decode(t, raw)
tlsBlock, ok := got["tls"].(map[string]any)
if !ok {
t.Fatalf("expected the inline tls block to survive, got %v", got)
}
if tlsBlock["certificate_authority_path"] != "/inline.pem" {
t.Errorf("inline tls must be untouched, got %v", tlsBlock)
}
}
// The panel keeps client CA paths as a list because sing-box inbounds accept
// several, but OpenVPN takes one, and the two sides name verification modes
// differently. Both need converting, not just copying.
func TestProjectEndpointTLSOpenVPNServerClientAuth(t *testing.T) {
tlsConfig := &Tls{Server: json.RawMessage(`{
"enabled": true,
"certificate_path": "/cert.pem",
"key_path": "/key.pem",
"client_authentication": "require-and-verify",
"client_certificate_path": ["/client-ca.pem", "/second-ca.pem"]
}`)}
projected, err := projectEndpointTLS("openvpn-server", tlsConfig)
if err != nil {
t.Fatal(err)
}
got := decode(t, projected)
if got["client_certificate_path"] != "/client-ca.pem" {
t.Errorf("the list should collapse to its first entry, got %v", got["client_certificate_path"])
}
if got["verify_client_certificate"] != "require" {
t.Errorf("require-and-verify should become require, got %v", got["verify_client_certificate"])
}
if got["client_authentication"] != nil {
t.Errorf("the sing-box spelling must not leak through, got %v", got)
}
}
func TestVerifyClientCertificateFor(t *testing.T) {
for clientAuthentication, want := range map[string]string{
"require-and-verify": "require",
"require-any": "require",
"verify-if-given": "optional",
"request": "optional",
"no": "",
"": "",
} {
if got := verifyClientCertificateFor(clientAuthentication); got != want {
t.Errorf("%q: want %q, got %q", clientAuthentication, want, got)
}
}
}
// The TLS editor can hold the client credentials as inline PEM instead of
// paths, so the projection has to carry that form across too.
func TestProjectEndpointTLSInlineClientCerts(t *testing.T) {
tlsConfig := &Tls{
Server: json.RawMessage(`{
"enabled": true,
"certificate": ["-----BEGIN CERTIFICATE-----", "srv", "-----END CERTIFICATE-----"],
"client_certificate": ["-----BEGIN CERTIFICATE-----", "ca", "-----END CERTIFICATE-----"]
}`),
Client: json.RawMessage(`{
"enabled": true,
"client_certificate": ["-----BEGIN CERTIFICATE-----", "cli", "-----END CERTIFICATE-----"],
"client_key": ["-----BEGIN PRIVATE KEY-----", "k", "-----END PRIVATE KEY-----"]
}`),
}
server := decode(t, mustProject(t, "openvpn-server", tlsConfig))
if server["client_certificate"] == nil {
t.Errorf("inline client CA must reach an openvpn-server, got %v", server)
}
for _, endpointType := range []string{"openvpn-client", "openconnect"} {
got := decode(t, mustProject(t, endpointType, tlsConfig))
if got["client_certificate"] == nil || got["client_key"] == nil {
t.Errorf("%s: inline client credentials must carry over, got %v", endpointType, got)
}
}
}
func mustProject(t *testing.T, endpointType string, tlsConfig *Tls) json.RawMessage {
t.Helper()
projected, err := projectEndpointTLS(endpointType, tlsConfig)
if err != nil {
t.Fatal(err)
}
if projected == nil {
t.Fatalf("%s: expected a tls block", endpointType)
}
return projected
}

View file

@ -4,16 +4,18 @@ import (
"encoding/json"
)
// Endpoint has no tls_id, unlike Inbound and Service. OpenConnect and OpenVPN
// each define their own TLS options with their own field names, their own
// vocabulary (an OpenVPN server names the client CA `client_certificate`) and
// settings the panel's TLS config cannot express at all, control_wrap among
// them. A shared template projected onto them was misleading at best and, for
// OpenVPN, never produced a config sing-box would accept. Those endpoints carry
// their own `tls` object in Options instead, written in sing-box's own names.
type Endpoint struct {
Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" form:"type"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
// Foreign key to tls table. Only the endpoint types that terminate or
// present TLS themselves use it; see projectEndpointTLS.
TlsId uint `json:"tls_id" form:"tls_id"`
Tls *Tls `json:"tls" form:"tls" gorm:"foreignKey:TlsId;references:Id"`
Options json.RawMessage `json:"-" form:"-"`
Ext json.RawMessage `json:"ext" form:"ext"`
}
@ -34,11 +36,10 @@ func (o *Endpoint) UnmarshalJSON(data []byte) error {
delete(raw, "type")
o.Tag = raw["tag"].(string)
delete(raw, "tag")
if val, exists := raw["tls_id"].(float64); exists {
o.TlsId = uint(val)
}
// Dropped rather than stored: an older panel sent tls_id alongside the
// endpoint, and sing-box rejects the unknown key. `tls` is kept, since it
// is now the endpoint's own.
delete(raw, "tls_id")
delete(raw, "tls")
o.Ext, _ = json.MarshalIndent(raw["ext"], "", " ")
delete(raw, "ext")
@ -70,19 +71,5 @@ func (o Endpoint) MarshalJSON() ([]byte, error) {
}
}
// A referenced TLS config is the explicit choice, so it replaces whatever
// TLS fields the endpoint carries inline rather than losing to them.
if o.Tls != nil {
projected, err := projectEndpointTLS(o.Type, o.Tls)
if err != nil {
return nil, err
}
if projected != nil {
combined["tls"] = projected
} else {
delete(combined, "tls")
}
}
return json.Marshal(combined)
}

View file

@ -0,0 +1,96 @@
package model
import (
"encoding/json"
"testing"
)
func TestEndpointMarshalKeepsInlineTLS(t *testing.T) {
// OpenVPN and OpenConnect write their TLS options in their own field names,
// so the panel stores them as the endpoint gave them and hands them over
// untouched.
inline := `{
"server": "example.com",
"server_port": 1194,
"mode": "tls",
"tls": {
"certificate_path": "/etc/openvpn/ca.crt",
"control_wrap": {"type": "tls_crypt", "key_path": "/etc/openvpn/tc.key"}
}
}`
var endpoint Endpoint
if err := endpoint.UnmarshalJSON([]byte(`{"type":"openvpn-client","tag":"ovpn",` + inline[1:])); err != nil {
t.Fatalf("unmarshal: %v", err)
}
encoded, err := endpoint.MarshalJSON()
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]json.RawMessage
if err = json.Unmarshal(encoded, &got); err != nil {
t.Fatalf("decode: %v", err)
}
var tls struct {
CertificatePath string `json:"certificate_path"`
ControlWrap struct {
Type string `json:"type"`
KeyPath string `json:"key_path"`
} `json:"control_wrap"`
}
if err = json.Unmarshal(got["tls"], &tls); err != nil {
t.Fatalf("decode tls: %v", err)
}
if tls.CertificatePath != "/etc/openvpn/ca.crt" {
t.Errorf("certificate_path = %q", tls.CertificatePath)
}
if tls.ControlWrap.Type != "tls_crypt" || tls.ControlWrap.KeyPath != "/etc/openvpn/tc.key" {
t.Errorf("control_wrap = %+v", tls.ControlWrap)
}
}
func TestEndpointUnmarshalDropsTlsId(t *testing.T) {
// A panel built before endpoints carried their own TLS still sends tls_id.
// Keeping it would put an unknown key in front of sing-box, which refuses
// the whole config over it.
var endpoint Endpoint
err := endpoint.UnmarshalJSON([]byte(`{"type":"openconnect","tag":"oc","tls_id":3,"server":"vpn.example.com"}`))
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
encoded, err := endpoint.MarshalJSON()
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]json.RawMessage
if err = json.Unmarshal(encoded, &got); err != nil {
t.Fatalf("decode: %v", err)
}
if _, exists := got["tls_id"]; exists {
t.Errorf("tls_id survived into the rendered config: %s", encoded)
}
if _, exists := got["tls"]; exists {
t.Errorf("tls_id was turned into a tls object: %s", encoded)
}
}
func TestEndpointMarshalWarpRendersAsWireguard(t *testing.T) {
var endpoint Endpoint
if err := endpoint.UnmarshalJSON([]byte(`{"type":"warp","tag":"warp-1","mtu":1420}`)); err != nil {
t.Fatalf("unmarshal: %v", err)
}
encoded, err := endpoint.MarshalJSON()
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got struct {
Type string `json:"type"`
}
if err = json.Unmarshal(encoded, &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Type != "wireguard" {
t.Errorf("type = %q, want wireguard", got.Type)
}
}

@ -1 +1 @@
Subproject commit 0b88e90ab78e339c46e973bec31b108dee574dac
Subproject commit faad00676a658c10930719f4c866d98510db2329

View file

@ -25,11 +25,10 @@ func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) {
var data []map[string]interface{}
for _, endpoint := range endpoints {
epData := map[string]interface{}{
"id": endpoint.Id,
"type": endpoint.Type,
"tag": endpoint.Tag,
"tls_id": endpoint.TlsId,
"ext": endpoint.Ext,
"id": endpoint.Id,
"type": endpoint.Type,
"tag": endpoint.Tag,
"ext": endpoint.Ext,
}
if endpoint.Options != nil {
var restFields map[string]json.RawMessage
@ -48,7 +47,7 @@ func (o *EndpointService) GetAll() (*[]map[string]interface{}, error) {
func (o *EndpointService) GetAllConfig(db *gorm.DB) ([]json.RawMessage, error) {
var endpointsJson []json.RawMessage
var endpoints []*model.Endpoint
err := db.Model(model.Endpoint{}).Preload("Tls").Find(&endpoints).Error
err := db.Model(model.Endpoint{}).Find(&endpoints).Error
if err != nil {
return nil, err
}
@ -72,13 +71,6 @@ func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) er
if err != nil {
return err
}
if endpoint.TlsId > 0 {
err = tx.Model(model.Tls{}).Where("id = ?", endpoint.TlsId).Find(&endpoint.Tls).Error
if err != nil {
return err
}
}
if endpoint.Type == "warp" {
if act == "new" {
err = s.WarpService.RegisterWarp(&endpoint)
@ -145,31 +137,3 @@ func (s *EndpointService) Save(tx *gorm.DB, act string, data json.RawMessage) er
}
return nil
}
// RestartEndpoints reloads the given endpoints in a running core, so a change
// to the TLS config they reference takes effect without restarting the panel.
func (s *EndpointService) RestartEndpoints(tx *gorm.DB, ids []uint) error {
if !corePtr.IsRunning() {
return nil
}
var endpoints []*model.Endpoint
err := tx.Model(model.Endpoint{}).Preload("Tls").Where("id in ?", ids).Find(&endpoints).Error
if err != nil {
return err
}
for _, endpoint := range endpoints {
err = corePtr.RemoveEndpoint(endpoint.Tag)
if err != nil && err != os.ErrInvalid {
return err
}
endpointConfig, err := endpoint.MarshalJSON()
if err != nil {
return err
}
err = corePtr.AddEndpoint(endpointConfig)
if err != nil {
return err
}
}
return nil
}

View file

@ -14,7 +14,6 @@ import (
type TlsService struct {
InboundService
ServicesService
EndpointService
}
func (s *TlsService) GetAll() ([]model.Tls, error) {
@ -78,17 +77,6 @@ func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage, host
return err
}
}
var endpointIds []uint
err = tx.Model(model.Endpoint{}).Where("tls_id = ?", tls.Id).Pluck("id", &endpointIds).Error
if err != nil {
return err
}
if len(endpointIds) > 0 {
err = s.EndpointService.RestartEndpoints(tx, endpointIds)
if err != nil {
return err
}
}
}
case "del":
var id uint
@ -106,12 +94,7 @@ func (s *TlsService) Save(tx *gorm.DB, action string, data json.RawMessage, host
if err != nil {
return err
}
var endpointCount int64
err = tx.Model(model.Endpoint{}).Where("tls_id = ?", id).Count(&endpointCount).Error
if err != nil {
return err
}
if inboundCount > 0 || serviceCount > 0 || endpointCount > 0 {
if inboundCount > 0 || serviceCount > 0 {
return common.NewError("tls in use")
}
err = tx.Where("id = ?", id).Delete(model.Tls{}).Error