mirror of
https://github.com/alireza0/s-ui.git
synced 2026-10-22 03:41:49 +00:00
Drop the TLS template for OpenVPN and OpenConnect endpoints (#1253)
This commit is contained in:
parent
686f233ff6
commit
d8625970e4
12 changed files with 1285 additions and 646 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
58
core/testdata/configs/12-openvpn-endpoints.json
vendored
Normal file
58
core/testdata/configs/12-openvpn-endpoints.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -144,6 +144,10 @@ func InitDB(dbPath string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = migrateEndpointTls()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reportSingBox114Manual()
|
||||
|
||||
return nil
|
||||
|
|
|
|||
443
database/migration_endpoint_tls.go
Normal file
443
database/migration_endpoint_tls.go
Normal 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
|
||||
}
|
||||
434
database/migration_endpoint_tls_test.go
Normal file
434
database/migration_endpoint_tls_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
96
database/model/endpoints_test.go
Normal file
96
database/model/endpoints_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
2
frontend
2
frontend
|
|
@ -1 +1 @@
|
|||
Subproject commit 0b88e90ab78e339c46e973bec31b108dee574dac
|
||||
Subproject commit faad00676a658c10930719f4c866d98510db2329
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue