upgrade acme to ca provider

This commit is contained in:
Alireza Ahmadi 2026-08-30 13:28:43 +02:00
parent 2951532c94
commit 91ff2e72d0
8 changed files with 576 additions and 10 deletions

View file

@ -0,0 +1,24 @@
{
"log": {"level": "error"},
"certificate_providers": [{
"type": "acme",
"tag": "letsencrypt",
"domain": ["example.com"],
"email": "a@example.com",
"data_directory": "__ACMEDIR__",
"dns01_challenge": {"provider": "cloudflare", "api_token": "tok"}
}],
"inbounds": [
{
"type": "vless", "tag": "vless-in", "listen": "127.0.0.1", "listen_port": 42445,
"users": [{"uuid": "b831381d-6324-4d53-ad4f-8cda48b30811"}],
"tls": {"enabled": true, "server_name": "example.com", "certificate_provider": "letsencrypt"}
},
{
"type": "trojan", "tag": "trojan-in", "listen": "127.0.0.1", "listen_port": 42446,
"users": [{"password": "hunter2"}],
"tls": {"enabled": true, "server_name": "example.com", "certificate_provider": "letsencrypt"}
}
],
"outbounds": [{"type": "direct", "tag": "direct"}]
}

View file

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

View file

@ -0,0 +1,205 @@
package database
import (
"encoding/json"
"log"
"strconv"
"strings"
"github.com/alireza0/s-ui/database/model"
"gorm.io/gorm"
)
// migratedKeyCertProviders marks that inline certificate providers have already
// been hoisted into the shared certificate_providers list.
const migratedKeyCertProviders = "migratedCertProviders"
// migrateCertificateProviders turns the inline certificate_provider object a
// TLS config may carry into an entry in the config's certificate_providers
// list, leaving the TLS config with a tag reference to it.
//
// sing-box accepts both forms, so this is not a compatibility fix: it is what
// lets the panel edit providers in one place and share them between TLS
// configs, which an inline object cannot do.
func migrateCertificateProviders() error {
var flag model.Setting
err := db.Where("key = ?", migratedKeyCertProviders).First(&flag).Error
if err == nil {
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
changed, err := hoistInlineCertificateProviders(tx)
if err != nil {
return err
}
if changed > 0 {
log.Printf("certificate providers: moved %d inline provider(s) into certificate_providers", changed)
}
return tx.Create(&model.Setting{Key: migratedKeyCertProviders, Value: "true"}).Error
})
}
func hoistInlineCertificateProviders(tx *gorm.DB) (int, error) {
var tlsConfigs []model.Tls
if err := tx.Find(&tlsConfigs).Error; err != nil {
return 0, err
}
var setting model.Setting
err := tx.Where("key = ?", "config").First(&setting).Error
if err == gorm.ErrRecordNotFound {
// Without a base config there is nowhere to put shared providers, so
// the inline ones are left as they are. They still work.
return 0, nil
}
if err != nil {
return 0, err
}
var root map[string]json.RawMessage
if err = json.Unmarshal([]byte(setting.Value), &root); err != nil {
log.Printf("certificate providers: skipping, cannot parse config: %v", err)
return 0, nil
}
providers, takenTags, err := decodeCertificateProviders(root)
if err != nil {
log.Printf("certificate providers: skipping, cannot parse certificate_providers: %v", err)
return 0, nil
}
changed := 0
for _, tlsConfig := range tlsConfigs {
if len(tlsConfig.Server) == 0 {
continue
}
var server map[string]json.RawMessage
if err = json.Unmarshal(tlsConfig.Server, &server); err != nil {
continue
}
inline, ok := inlineProviderObject(server["certificate_provider"])
if !ok {
continue
}
tag := uniqueProviderTag(providerTagBase(tlsConfig), takenTags)
takenTags[tag] = struct{}{}
inline["tag"] = json.RawMessage(strconv.Quote(tag))
encodedProvider, err := json.Marshal(inline)
if err != nil {
return 0, err
}
providers = append(providers, encodedProvider)
server["certificate_provider"] = json.RawMessage(strconv.Quote(tag))
encodedServer, err := json.Marshal(server)
if err != nil {
return 0, err
}
if err = tx.Model(&model.Tls{}).Where("id = ?", tlsConfig.Id).
Update("server", json.RawMessage(encodedServer)).Error; err != nil {
return 0, err
}
changed++
}
if changed == 0 {
return 0, nil
}
encodedProviders, err := json.Marshal(providers)
if err != nil {
return 0, err
}
root["certificate_providers"] = encodedProviders
encodedRoot, err := json.MarshalIndent(root, "", " ")
if err != nil {
return 0, err
}
setting.Value = string(encodedRoot)
if err = tx.Save(&setting).Error; err != nil {
return 0, err
}
return changed, nil
}
// decodeCertificateProviders reads the providers already defined in the config
// and the tags they occupy, so hoisted ones do not collide with them.
func decodeCertificateProviders(root map[string]json.RawMessage) ([]json.RawMessage, map[string]struct{}, error) {
taken := make(map[string]struct{})
raw, ok := root["certificate_providers"]
if !ok || len(raw) == 0 {
return nil, taken, nil
}
var providers []json.RawMessage
if err := json.Unmarshal(raw, &providers); err != nil {
return nil, nil, err
}
for _, provider := range providers {
var fields struct {
Tag string `json:"tag"`
}
if err := json.Unmarshal(provider, &fields); err != nil {
continue
}
if fields.Tag != "" {
taken[fields.Tag] = struct{}{}
}
}
return providers, taken, nil
}
// inlineProviderObject reports whether certificate_provider holds an inline
// definition rather than a tag reference to a shared one.
func inlineProviderObject(raw json.RawMessage) (map[string]json.RawMessage, bool) {
trimmed := strings.TrimSpace(string(raw))
if !strings.HasPrefix(trimmed, "{") {
return nil, false
}
var inline map[string]json.RawMessage
if err := json.Unmarshal(raw, &inline); err != nil {
return nil, false
}
// A provider with no type cannot be created by sing-box either way, so
// there is nothing worth moving.
if _, ok := inline["type"]; !ok {
return nil, false
}
return inline, true
}
// providerTagBase names the hoisted provider after the TLS config it came
// from, which is how the operator will recognise it in the list.
func providerTagBase(tlsConfig model.Tls) string {
slug := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r >= 'A' && r <= 'Z':
return r + ('a' - 'A')
default:
return '-'
}
}, tlsConfig.Name)
slug = strings.Trim(slug, "-")
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}
if slug == "" {
return "cert-" + strconv.FormatUint(uint64(tlsConfig.Id), 10)
}
return slug
}
func uniqueProviderTag(base string, taken map[string]struct{}) string {
tag := base
for i := 2; ; i++ {
if _, exists := taken[tag]; !exists {
return tag
}
tag = base + "-" + strconv.Itoa(i)
}
}

View file

@ -0,0 +1,176 @@
package database
import (
"encoding/json"
"testing"
"github.com/alireza0/s-ui/database/model"
)
func certificateProviders(t *testing.T, root map[string]any) []map[string]any {
t.Helper()
raw, ok := root["certificate_providers"].([]any)
if !ok {
t.Fatalf("missing certificate_providers in %v", root)
}
providers := make([]map[string]any, 0, len(raw))
for _, entry := range raw {
provider, isObject := entry.(map[string]any)
if !isObject {
t.Fatalf("expected provider objects, got %v", entry)
}
providers = append(providers, provider)
}
return providers
}
func tlsServer(t *testing.T, id uint) map[string]any {
t.Helper()
var stored model.Tls
if err := db.Where("id = ?", id).First(&stored).Error; err != nil {
t.Fatal(err)
}
var server map[string]any
if err := json.Unmarshal(stored.Server, &server); err != nil {
t.Fatal(err)
}
return server
}
func TestMigrateCertificateProviders(t *testing.T) {
openTestDB(t)
if err := db.Create(&model.Setting{Key: "config", Value: `{"log":{"level":"info"}}`}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Tls{
Name: "Main Site",
Server: json.RawMessage(`{
"enabled": true,
"certificate_provider": {"type": "acme", "domain": ["example.com"], "email": "a@example.com"}
}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateCertificateProviders(); err != nil {
t.Fatal(err)
}
providers := certificateProviders(t, readConfig(t))
if len(providers) != 1 {
t.Fatalf("expected one hoisted provider, got %v", providers)
}
if providers[0]["type"] != "acme" || providers[0]["email"] != "a@example.com" {
t.Errorf("the provider fields must carry over, got %v", providers[0])
}
tag, _ := providers[0]["tag"].(string)
if tag != "main-site" {
t.Errorf("the tag should be named after the TLS config, got %q", tag)
}
// The TLS config must now point at the shared provider rather than repeat it.
if got := tlsServer(t, 1)["certificate_provider"]; got != tag {
t.Errorf("expected the tag %q as the reference, got %v", tag, got)
}
}
// A tag reference is already the new form and must be left alone, as must a
// provider that was defined in the config by hand.
func TestMigrateCertificateProvidersLeavesReferences(t *testing.T) {
openTestDB(t)
if err := db.Create(&model.Setting{Key: "config", Value: `{
"certificate_providers": [{"type": "acme", "tag": "shared", "domain": ["example.com"]}]
}`}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Tls{
Name: "site",
Server: json.RawMessage(`{"enabled": true, "certificate_provider": "shared"}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateCertificateProviders(); err != nil {
t.Fatal(err)
}
providers := certificateProviders(t, readConfig(t))
if len(providers) != 1 {
t.Errorf("nothing should have been added, got %v", providers)
}
if got := tlsServer(t, 1)["certificate_provider"]; got != "shared" {
t.Errorf("the reference must be untouched, got %v", got)
}
}
// Two TLS configs whose names slugify the same must not end up sharing a tag,
// which would silently point one of them at the other's provider.
func TestMigrateCertificateProvidersDeduplicatesTags(t *testing.T) {
openTestDB(t)
if err := db.Create(&model.Setting{Key: "config", Value: `{
"certificate_providers": [{"type": "acme", "tag": "my-site", "domain": ["taken.example"]}]
}`}).Error; err != nil {
t.Fatal(err)
}
for _, name := range []string{"My Site", "my.site"} {
if err := db.Create(&model.Tls{
Name: name,
Server: json.RawMessage(`{"enabled": true, "certificate_provider": {"type": "acme", "domain": ["example.com"]}}`),
}).Error; err != nil {
t.Fatal(err)
}
}
if err := migrateCertificateProviders(); err != nil {
t.Fatal(err)
}
providers := certificateProviders(t, readConfig(t))
if len(providers) != 3 {
t.Fatalf("expected the hand-written provider plus two hoisted ones, got %v", providers)
}
tags := make(map[string]bool)
for _, provider := range providers {
tag, _ := provider["tag"].(string)
if tag == "" {
t.Fatalf("every shared provider needs a tag, got %v", provider)
}
if tags[tag] {
t.Fatalf("duplicate tag %q in %v", tag, providers)
}
tags[tag] = true
}
for id := uint(1); id <= 2; id++ {
reference, _ := tlsServer(t, id)["certificate_provider"].(string)
if reference == "my-site" {
t.Errorf("tls %d must not claim the existing provider's tag", id)
}
if !tags[reference] {
t.Errorf("tls %d references %q, which is not defined", id, reference)
}
}
}
// The migration runs once; a provider added inline afterwards is the operator's
// own doing and must not be moved out from under them on the next start.
func TestMigrateCertificateProvidersRunsOnce(t *testing.T) {
openTestDB(t)
if err := db.Create(&model.Setting{Key: "config", Value: `{"log":{"level":"info"}}`}).Error; err != nil {
t.Fatal(err)
}
if err := migrateCertificateProviders(); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Tls{
Name: "later",
Server: json.RawMessage(`{"enabled": true, "certificate_provider": {"type": "acme", "domain": ["example.com"]}}`),
}).Error; err != nil {
t.Fatal(err)
}
if err := migrateCertificateProviders(); err != nil {
t.Fatal(err)
}
if _, hoisted := readConfig(t)["certificate_providers"]; hoisted {
t.Error("the migration should not run a second time")
}
}

@ -1 +1 @@
Subproject commit 353dea9d3667bbf4525ec8f4c8b0572beb07c98d
Subproject commit 1da3ca9f92f227574c8f77797aa24803d7d913c8

View file

@ -33,15 +33,19 @@ type ConfigService struct {
}
type SingBoxConfig struct {
Log json.RawMessage `json:"log"`
Dns json.RawMessage `json:"dns"`
Ntp json.RawMessage `json:"ntp"`
Inbounds []json.RawMessage `json:"inbounds"`
Outbounds []json.RawMessage `json:"outbounds"`
Services []json.RawMessage `json:"services"`
Endpoints []json.RawMessage `json:"endpoints"`
Route json.RawMessage `json:"route"`
Experimental json.RawMessage `json:"experimental"`
Log json.RawMessage `json:"log"`
Dns json.RawMessage `json:"dns"`
Ntp json.RawMessage `json:"ntp"`
// Shared certificate providers, referenced by tag from a TLS config's
// certificate_provider. They are edited alongside the TLS configs but
// stored in the base config, so they are carried through unchanged.
CertificateProviders []json.RawMessage `json:"certificate_providers,omitempty"`
Inbounds []json.RawMessage `json:"inbounds"`
Outbounds []json.RawMessage `json:"outbounds"`
Services []json.RawMessage `json:"services"`
Endpoints []json.RawMessage `json:"endpoints"`
Route json.RawMessage `json:"route"`
Experimental json.RawMessage `json:"experimental"`
}
func NewConfigService(core *core.Core) *ConfigService {

View file

@ -0,0 +1,90 @@
package service
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/alireza0/s-ui/database"
"github.com/alireza0/s-ui/database/model"
)
// TestGetConfigEmitsCertificateProviders covers the whole path the TLS page
// relies on: providers saved into the base config have to come out in the
// config handed to sing-box, and a TLS config's tag reference has to survive
// alongside them.
func TestGetConfigEmitsCertificateProviders(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil {
t.Fatal(err)
}
db := database.GetDB()
base := `{
"log": {"level": "info"},
"certificate_providers": [
{"type": "acme", "tag": "letsencrypt", "domain": ["example.com"], "email": "a@example.com"}
],
"route": {"rules": []}
}`
// The defaults are only materialised on demand, so the row may not exist yet.
if err := db.Where("key = ?", "config").Delete(&model.Setting{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Setting{Key: "config", Value: base}).Error; err != nil {
t.Fatal(err)
}
tlsConfig := model.Tls{
Name: "site",
Server: json.RawMessage(`{"enabled": true, "certificate_provider": "letsencrypt"}`),
Client: json.RawMessage(`{}`),
}
if err := db.Create(&tlsConfig).Error; err != nil {
t.Fatal(err)
}
inbound := model.Inbound{
Type: "vless",
Tag: "vless-in",
TlsId: tlsConfig.Id,
Options: json.RawMessage(`{"listen":"127.0.0.1","listen_port":42447}`),
}
if err := db.Create(&inbound).Error; err != nil {
t.Fatal(err)
}
raw, err := (&ConfigService{}).GetConfig("")
if err != nil {
t.Fatal(err)
}
var built struct {
CertificateProviders []struct {
Type string `json:"type"`
Tag string `json:"tag"`
Domain []string `json:"domain"`
} `json:"certificate_providers"`
Inbounds []struct {
Tag string `json:"tag"`
TLS struct {
CertificateProvider string `json:"certificate_provider"`
} `json:"tls"`
} `json:"inbounds"`
}
if err = json.Unmarshal(*raw, &built); err != nil {
t.Fatal(err)
}
if len(built.CertificateProviders) != 1 {
t.Fatalf("the provider must reach the generated config, got %s", *raw)
}
provider := built.CertificateProviders[0]
if provider.Tag != "letsencrypt" || provider.Type != "acme" || len(provider.Domain) != 1 {
t.Errorf("the provider must be emitted unchanged, got %+v", provider)
}
if len(built.Inbounds) != 1 {
t.Fatalf("expected the inbound, got %s", *raw)
}
if built.Inbounds[0].TLS.CertificateProvider != "letsencrypt" {
t.Errorf("the inbound TLS must reference the provider, got %s", *raw)
}
}

63
service/config_test.go Normal file
View file

@ -0,0 +1,63 @@
package service
import (
"encoding/json"
"strings"
"testing"
)
// GetConfig rebuilds the sing-box config by decoding the stored base config
// into SingBoxConfig and marshalling it back, so anything the struct does not
// model is dropped on the way through. certificate_providers is edited by the
// TLS page and has to survive that round trip.
func TestSingBoxConfigKeepsCertificateProviders(t *testing.T) {
base := `{
"log": {"level": "info"},
"certificate_providers": [
{"type": "acme", "tag": "letsencrypt", "domain": ["example.com"]},
{"type": "cloudflare-origin-ca", "tag": "cf", "domain": ["example.com"]}
],
"route": {"rules": []}
}`
var config SingBoxConfig
if err := json.Unmarshal([]byte(base), &config); err != nil {
t.Fatal(err)
}
rebuilt, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
var decoded struct {
CertificateProviders []struct {
Type string `json:"type"`
Tag string `json:"tag"`
} `json:"certificate_providers"`
}
if err = json.Unmarshal(rebuilt, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.CertificateProviders) != 2 {
t.Fatalf("both providers must reach the final config, got %s", rebuilt)
}
if decoded.CertificateProviders[0].Tag != "letsencrypt" || decoded.CertificateProviders[1].Type != "cloudflare-origin-ca" {
t.Errorf("providers must carry over unchanged, got %s", rebuilt)
}
}
// A config with no providers must not grow an empty key, which sing-box would
// read as an explicit empty list.
func TestSingBoxConfigOmitsEmptyCertificateProviders(t *testing.T) {
var config SingBoxConfig
if err := json.Unmarshal([]byte(`{"log": {"level": "info"}}`), &config); err != nil {
t.Fatal(err)
}
rebuilt, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(rebuilt), "certificate_providers") {
t.Errorf("expected no certificate_providers key, got %s", rebuilt)
}
}