mirror of
https://github.com/1f349/orchid.git
synced 2025-04-14 07:45:50 +01:00
Compare commits
55 Commits
Author | SHA1 | Date | |
---|---|---|---|
0d184e27e8 | |||
c737bab781 | |||
|
9e0ba09a84 | ||
|
4bbeaa87e1 | ||
|
35bc5ae4ab | ||
02bfb4f62f | |||
56be812ff5 | |||
8407f21090 | |||
fc2f8e34c6 | |||
a23155a827 | |||
432c907303 | |||
b642957aaf | |||
99c4c38bd5 | |||
c4c8c33139 | |||
939875ca4c | |||
7e70331179 | |||
645d22b856 | |||
c373f18336 | |||
c247a50472 | |||
4105d14e63 | |||
bb7c4bcedc | |||
0722b67969 | |||
619f909767 | |||
1f1db49160 | |||
|
f806c7d230 | ||
|
7768a339c8 | ||
7f24d37e31 | |||
d0fc76cd73 | |||
7e65015b89 | |||
37964ad546 | |||
76baa5f33f | |||
2839abbf52 | |||
25e1065f05 | |||
55bfc13457 | |||
fc837761cc | |||
01f15e27a3 | |||
e92207172e | |||
5532aa9782 | |||
dee4b7ee3a | |||
fa92da5f1d | |||
b7f3dc54c7 | |||
7ff5ec4d57 | |||
4e959ecdfb | |||
2b160d4309 | |||
094ac9030a | |||
|
9b3c801ebf | ||
b10db2f73d | |||
|
8234a34c6f | ||
445e2bf30d | |||
0c467542a0 | |||
dd77f3836e | |||
c08e73ecfc | |||
82704e5a13 | |||
|
37cc96fba1 | ||
36aca8af0b |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -4,7 +4,7 @@ jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.20.x]
|
||||
go-version: [1.24.x]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
*.sqlite
|
||||
*.local
|
||||
.data/
|
||||
.idea/
|
||||
|
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="identifier.sqlite" uuid="c23861d9-b93b-4410-a97e-72d0f511a821">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:identifier.sqlite</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
10
.idea/misc.xml
generated
10
.idea/misc.xml
generated
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="ASK" />
|
||||
<option name="description" value="" />
|
||||
</component>
|
||||
<component name="MarkdownSettingsMigration">
|
||||
<option name="stateVersion" value="1" />
|
||||
</component>
|
||||
</project>
|
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/orchid.iml" filepath="$PROJECT_DIR$/.idea/orchid.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
9
.idea/orchid.iml
generated
9
.idea/orchid.iml
generated
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
7
.idea/sqldialects.xml
generated
7
.idea/sqldialects.xml
generated
@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/renewal/find-next-cert.sql" dialect="GenericSQL" />
|
||||
<file url="PROJECT" dialect="SQLite" />
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
233
agent/agent.go
Normal file
233
agent/agent.go
Normal file
@ -0,0 +1,233 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/1f349/orchid/utils"
|
||||
"github.com/bramvdbogaerde/go-scp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed agent_readme.md
|
||||
var agentReadme []byte
|
||||
|
||||
type agentQueries interface {
|
||||
FindAgentToSync(ctx context.Context) ([]database.FindAgentToSyncRow, error)
|
||||
UpdateAgentLastSync(ctx context.Context, row database.UpdateAgentLastSyncParams) error
|
||||
UpdateAgentCertNotAfter(ctx context.Context, arg database.UpdateAgentCertNotAfterParams) error
|
||||
}
|
||||
|
||||
func NewAgent(wg *sync.WaitGroup, db agentQueries, sshKey ssh.Signer, certDir string, keyDir string) (*Agent, error) {
|
||||
a := &Agent{
|
||||
db: db,
|
||||
ticker: time.NewTicker(time.Minute * 10),
|
||||
done: make(chan struct{}),
|
||||
syncLock: new(sync.Mutex),
|
||||
sshKey: sshKey,
|
||||
certDir: certDir,
|
||||
keyDir: keyDir,
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go a.syncRoutine(wg)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
db agentQueries
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
syncLock *sync.Mutex
|
||||
sshKey ssh.Signer
|
||||
certDir string
|
||||
keyDir string
|
||||
}
|
||||
|
||||
func (a *Agent) Shutdown() {
|
||||
Logger.Info("Shutting down agent syncing service")
|
||||
close(a.done)
|
||||
}
|
||||
|
||||
func (a *Agent) syncRoutine(wg *sync.WaitGroup) {
|
||||
Logger.Debug("Starting syncRoutine")
|
||||
|
||||
// Upon leaving the function stop the ticker and clear the WaitGroup.
|
||||
defer func() {
|
||||
a.ticker.Stop()
|
||||
Logger.Info("Stopped agent syncing service")
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
Logger.Info("Doing quick agent check before starting...")
|
||||
a.syncCheck()
|
||||
|
||||
// Logging or something
|
||||
Logger.Info("Initial check complete, continually checking every 10 minutes...")
|
||||
|
||||
// Main loop
|
||||
for {
|
||||
select {
|
||||
case <-a.done:
|
||||
// Exit if done has closed
|
||||
return
|
||||
case <-a.ticker.C:
|
||||
Logger.Debug("Ticking agent syncing")
|
||||
|
||||
go a.syncCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) syncCheck() {
|
||||
// if the lock is unavailable then ignore this cycle
|
||||
if !a.syncLock.TryLock() {
|
||||
return
|
||||
}
|
||||
defer a.syncLock.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
actions, err := a.db.FindAgentToSync(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
a.syncCertPairs(now, actions)
|
||||
}
|
||||
|
||||
type syncAgent struct {
|
||||
agentId int64
|
||||
address string
|
||||
user string
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
func (a *Agent) syncCertPairs(startTime time.Time, rows []database.FindAgentToSyncRow) {
|
||||
agentMap := make(map[syncAgent][]database.FindAgentToSyncRow)
|
||||
|
||||
for _, row := range rows {
|
||||
a := syncAgent{
|
||||
agentId: row.AgentID,
|
||||
address: row.Address,
|
||||
user: row.User,
|
||||
fingerprint: row.Fingerprint,
|
||||
}
|
||||
agentMap[a] = append(agentMap[a], row)
|
||||
}
|
||||
|
||||
for agent, certPairs := range agentMap {
|
||||
err := a.syncSingleAgentCertPairs(startTime, agent, certPairs)
|
||||
if err != nil {
|
||||
// This agent sync is allowed to fail without stopping other agent syncs from
|
||||
// occurring.
|
||||
Logger.Warn("Agent sync failed", "agent", agent.agentId, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) syncSingleAgentCertPairs(startTime time.Time, agent syncAgent, rows []database.FindAgentToSyncRow) error {
|
||||
hostPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(agent.fingerprint))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse fingerprint: %w", err)
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", agent.address, &ssh.ClientConfig{
|
||||
Config: ssh.Config{
|
||||
KeyExchanges: []string{"curve25519-sha256"},
|
||||
},
|
||||
User: agent.user,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(a.sshKey),
|
||||
},
|
||||
HostKeyAlgorithms: []string{"ssh-ed25519"},
|
||||
HostKeyCallback: ssh.FixedHostKey(hostPubKey),
|
||||
Timeout: time.Second * 30,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh dial: %w", err)
|
||||
}
|
||||
|
||||
scpClient, err := scp.NewClientBySSH(client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scp client: %w", err)
|
||||
}
|
||||
|
||||
hadError := false
|
||||
|
||||
for _, row := range rows {
|
||||
err := a.copySingleCertPair(&scpClient, row)
|
||||
if err != nil {
|
||||
// This cert sync is allowed to fail without stopping other certs going to the
|
||||
// same agent from copying.
|
||||
err = fmt.Errorf("copySingleCertPair: %w", err)
|
||||
Logger.Warn("Agent certificate sync failed", "agent", row.AgentID, "cert", row.CertID, "not after", row.CertNotAfter, "err", err)
|
||||
hadError = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// The agent last sync will only update if all scp copies were successful.
|
||||
if !hadError {
|
||||
// Update last sync to the time when the database request happened. This ensures
|
||||
// that certificates updated after the database request and before the agent
|
||||
// syncing are updated properly.
|
||||
err = a.db.UpdateAgentLastSync(context.Background(), database.UpdateAgentLastSyncParams{
|
||||
LastSync: sql.NullTime{Time: startTime, Valid: true},
|
||||
ID: agent.agentId,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating agent last sync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) copySingleCertPair(scpClient *scp.Client, row database.FindAgentToSyncRow) error {
|
||||
certName := utils.GetCertFileName(row.CertID)
|
||||
keyName := utils.GetKeyFileName(row.CertID)
|
||||
|
||||
certPath := filepath.Join(a.certDir, certName)
|
||||
keyPath := filepath.Join(a.keyDir, keyName)
|
||||
|
||||
// open cert and key files
|
||||
openCert, err := os.Open(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open cert file: %w", err)
|
||||
}
|
||||
defer openCert.Close()
|
||||
|
||||
openKey, err := os.Open(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open key file: %w", err)
|
||||
}
|
||||
defer openKey.Close()
|
||||
|
||||
// copy cert and key to agent
|
||||
err = scpClient.CopyFromFile(context.Background(), *openCert, filepath.Join(row.Dir, "certificates", certName), "0600")
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy cert file: %w", err)
|
||||
}
|
||||
err = scpClient.CopyFromFile(context.Background(), *openKey, filepath.Join(row.Dir, "keys", keyName), "0600")
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy cert file: %w", err)
|
||||
}
|
||||
|
||||
err = a.db.UpdateAgentCertNotAfter(context.Background(), database.UpdateAgentCertNotAfterParams{
|
||||
NotAfter: row.CertNotAfter,
|
||||
AgentID: row.AgentID,
|
||||
CertID: row.CertID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating agent last sync: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
5
agent/agent_readme.md
Normal file
5
agent/agent_readme.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Orchid Agent
|
||||
|
||||
This directory is controlled by Orchid agent configuration.
|
||||
|
||||
Certificates in this directory will be automatically updated when required.
|
391
agent/agent_test.go
Normal file
391
agent/agent_test.go
Normal file
@ -0,0 +1,391 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509/pkix"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/mrmelon54/certgen"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/ed25519"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAgentSyncing(t *testing.T) {
|
||||
logger.Logger.SetLevel(log.DebugLevel)
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping agent syncing tests in short mode")
|
||||
}
|
||||
|
||||
t.Run("agent syncing test", func(t *testing.T) {
|
||||
certDir, err := os.MkdirTemp("", "orchid-certs")
|
||||
assert.NoError(t, err)
|
||||
keyDir, err := os.MkdirTemp("", "orchid-keys")
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
assert.NoError(t, os.RemoveAll(certDir))
|
||||
assert.NoError(t, os.RemoveAll(keyDir))
|
||||
}()
|
||||
|
||||
_, privKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sshPrivKey, err := ssh.NewSignerFromKey(privKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
agent := &Agent{
|
||||
db: &fakeAgentDb{},
|
||||
ticker: nil,
|
||||
done: nil,
|
||||
syncLock: nil,
|
||||
sshKey: sshPrivKey,
|
||||
certDir: certDir,
|
||||
keyDir: keyDir,
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
t.Run("missing cert file", func(t *testing.T) {
|
||||
err = agent.copySingleCertPair(nil, database.FindAgentToSyncRow{
|
||||
AgentID: 1337,
|
||||
Address: "",
|
||||
User: "test",
|
||||
Dir: "~/hello/world",
|
||||
Fingerprint: "",
|
||||
CertID: 420,
|
||||
CertNotAfter: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
assert.Contains(t, err.Error(), "open cert file:")
|
||||
assert.Contains(t, err.Error(), "no such file or directory")
|
||||
})
|
||||
|
||||
// generate example certificate
|
||||
tlsCert, err := certgen.MakeServerTls(nil, 2048, pkix.Name{
|
||||
Country: []string{"GB"},
|
||||
Province: []string{"London"},
|
||||
StreetAddress: []string{"221B Baker Street"},
|
||||
PostalCode: []string{"NW1 6XE"},
|
||||
SerialNumber: "test123456",
|
||||
CommonName: "orchid-agent-test.local",
|
||||
}, big.NewInt(1234567899), func(now time.Time) time.Time {
|
||||
return now.Add(1 * time.Hour)
|
||||
}, []string{"orchid-agent-test.local"}, []net.IP{
|
||||
net.IPv6loopback,
|
||||
net.IPv4(127, 0, 0, 1),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filepath.Join(certDir, "420.cert.pem"), tlsCert.GetCertPem(), 0600)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("missing key file", func(t *testing.T) {
|
||||
err = agent.copySingleCertPair(nil, database.FindAgentToSyncRow{
|
||||
AgentID: 1337,
|
||||
Address: "",
|
||||
User: "test",
|
||||
Dir: "~/hello/world",
|
||||
Fingerprint: "",
|
||||
CertID: 420,
|
||||
CertNotAfter: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
assert.Contains(t, err.Error(), "open key file:")
|
||||
assert.Contains(t, err.Error(), "no such file or directory")
|
||||
})
|
||||
|
||||
err = os.WriteFile(filepath.Join(keyDir, "420.key.pem"), tlsCert.GetKeyPem(), 0600)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("successful sync", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
server := setupFakeSSH(&wg, func(remoteAddrPort netip.AddrPort, remotePubKey ssh.PublicKey) {
|
||||
println("Attempt agent syncing")
|
||||
|
||||
fingerprintStr := string(ssh.MarshalAuthorizedKey(remotePubKey))
|
||||
|
||||
err = agent.syncSingleAgentCertPairs(now, syncAgent{
|
||||
agentId: 1337,
|
||||
address: remoteAddrPort.String(),
|
||||
user: "test",
|
||||
fingerprint: fingerprintStr,
|
||||
}, []database.FindAgentToSyncRow{
|
||||
{
|
||||
AgentID: 1337,
|
||||
Address: remoteAddrPort.String(),
|
||||
User: "test",
|
||||
Dir: "~/hello/world",
|
||||
Fingerprint: fingerprintStr,
|
||||
CertID: 420,
|
||||
CertNotAfter: sql.NullTime{Time: now, Valid: true},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
server.Close()
|
||||
|
||||
println("Waiting for ssh server to exit")
|
||||
|
||||
server.Wait()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func setupFakeSSH(wg *sync.WaitGroup, call func(addrPort netip.AddrPort, pubKey ssh.PublicKey)) *ssh.ServerConn {
|
||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sshSigner, err := ssh.NewSignerFromKey(privKey)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tcp, err := net.ListenTCP("tcp", net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.IPv6Loopback(), 0)))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
addrPort := tcp.Addr().(*net.TCPAddr).AddrPort()
|
||||
|
||||
var wg2 sync.WaitGroup
|
||||
wg2.Add(1)
|
||||
go func() {
|
||||
defer wg2.Done()
|
||||
call(addrPort, sshPubKey)
|
||||
}()
|
||||
|
||||
tcpConn, err := tcp.AcceptTCP()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
serverConfig := &ssh.ServerConfig{
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
if conn.User() != "test" {
|
||||
return nil, fmt.Errorf("invalid user")
|
||||
}
|
||||
if !conn.RemoteAddr().(*net.TCPAddr).AddrPort().Addr().IsLoopback() {
|
||||
return nil, fmt.Errorf("invalid remote address")
|
||||
}
|
||||
return &ssh.Permissions{}, nil
|
||||
},
|
||||
ServerVersion: "SSH-2.0-OrchidTester",
|
||||
}
|
||||
serverConfig.AddHostKey(sshSigner)
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, serverConfig)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// The incoming Request channel must be serviced.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
ssh.DiscardRequests(reqs)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
// Service the incoming channel.
|
||||
for newChannel := range chans {
|
||||
// Channels have a type, depending on the application level
|
||||
// protocol intended. In the case of a shell, the type is
|
||||
// "session" and ServerShell may be used to present a simple
|
||||
// terminal interface.
|
||||
if newChannel.ChannelType() != "session" {
|
||||
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
channel, requests, err := newChannel.Accept()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var fullFilePath *string
|
||||
{
|
||||
a := ""
|
||||
fullFilePath = &a
|
||||
}
|
||||
|
||||
// Sessions have out-of-band requests such as "shell",
|
||||
// "pty-req" and "env". Here we handle only the
|
||||
// "shell" request.
|
||||
wg.Add(1)
|
||||
go func(in <-chan *ssh.Request) {
|
||||
for req := range in {
|
||||
req.Reply(req.Type == "exec", nil)
|
||||
if req.Type == "exec" {
|
||||
length := binary.BigEndian.Uint32(req.Payload[:4])
|
||||
if len(req.Payload) != int(length)+4 {
|
||||
panic(fmt.Errorf("invalid exec payload (expected %d but got %d)", length, len(req.Payload)))
|
||||
}
|
||||
cmd := string(req.Payload[4:])
|
||||
const scpStartStr = "scp -qt \""
|
||||
if !strings.HasPrefix(cmd, scpStartStr) {
|
||||
panic("invalid start")
|
||||
}
|
||||
if !strings.HasSuffix(cmd, "\"") {
|
||||
panic("invalid end")
|
||||
}
|
||||
filePath := cmd[len(scpStartStr) : len(cmd)-1]
|
||||
fmt.Println("Writing file:", filePath)
|
||||
*fullFilePath = filePath
|
||||
}
|
||||
}
|
||||
wg.Done()
|
||||
}(requests)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer func() {
|
||||
channel.Close()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
var b [1024]byte
|
||||
read := must(channel.Read(b[:]))
|
||||
if read < 1 {
|
||||
panic("invalid read")
|
||||
}
|
||||
|
||||
fmt.Println(string(b[:read]))
|
||||
|
||||
r := bufio.NewReader(bytes.NewReader(b[:read]))
|
||||
if readByte(r) != 'C' {
|
||||
panic("invalid scp command")
|
||||
}
|
||||
|
||||
fileMode := readN(r, 4)
|
||||
if string(fileMode) != "0600" {
|
||||
panic("unexpected file mode")
|
||||
}
|
||||
|
||||
if readByte(r) != ' ' {
|
||||
panic("missing space")
|
||||
}
|
||||
|
||||
fileSizeStr := must(r.ReadString(' '))
|
||||
fileSize := must(strconv.Atoi(fileSizeStr[:len(fileSizeStr)-1]))
|
||||
|
||||
fileName := strings.TrimSpace(string(must(io.ReadAll(r))))
|
||||
if fileName != filepath.Base(*fullFilePath) {
|
||||
panic(fmt.Errorf("invalid file name (expected \"%s\" from full path \"%s\" but got \"%s\")", filepath.Base(*fullFilePath), *fullFilePath, fileName))
|
||||
}
|
||||
|
||||
if fileName != "420.cert.pem" && fileName != "420.key.pem" {
|
||||
panic("invalid file name")
|
||||
}
|
||||
|
||||
channel.Write([]byte{0})
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
_, err := io.CopyN(buf, channel, int64(fileSize))
|
||||
if err != nil {
|
||||
panic("Failed to copy channel")
|
||||
}
|
||||
fmt.Println("Copied file with size:", buf.Len())
|
||||
fmt.Println(buf.String())
|
||||
|
||||
if readLastByte(r) != 0x00 {
|
||||
panic("expected ending null byte")
|
||||
}
|
||||
|
||||
channel.Write([]byte{0})
|
||||
|
||||
channel.SendRequest("exit-status", false, binary.BigEndian.AppendUint32(nil, 0))
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
wg2.Wait()
|
||||
|
||||
return sshConn
|
||||
}
|
||||
|
||||
type fakeAgentDb struct{}
|
||||
|
||||
func (f *fakeAgentDb) FindAgentToSync(ctx context.Context) ([]database.FindAgentToSyncRow, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (f *fakeAgentDb) UpdateAgentLastSync(ctx context.Context, arg database.UpdateAgentLastSyncParams) error {
|
||||
if arg.ID != 1337 {
|
||||
return fmt.Errorf("invalid agent id")
|
||||
}
|
||||
if !arg.LastSync.Valid {
|
||||
return fmt.Errorf("invalid last sync")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentDb) UpdateAgentCertNotAfter(ctx context.Context, arg database.UpdateAgentCertNotAfterParams) error {
|
||||
if arg.AgentID != 1337 {
|
||||
return fmt.Errorf("invalid agent id")
|
||||
}
|
||||
if arg.CertID != 420 {
|
||||
return fmt.Errorf("invalid cert id")
|
||||
}
|
||||
if !arg.NotAfter.Valid {
|
||||
return fmt.Errorf("invalid not after")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func must[T any](t T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func readN(r io.Reader, n int) []byte {
|
||||
b := make([]byte, n)
|
||||
_, err := io.ReadFull(r, b)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func readByte(r io.Reader) byte {
|
||||
b := readN(r, 1)
|
||||
return b[0]
|
||||
}
|
||||
|
||||
func readLastByte(r io.Reader) byte {
|
||||
var b [1]byte
|
||||
_, err := io.ReadFull(r, b[:])
|
||||
if !errors.Is(err, io.EOF) {
|
||||
panic("expected EOF")
|
||||
}
|
||||
return b[0]
|
||||
}
|
5
agent/logger.go
Normal file
5
agent/logger.go
Normal file
@ -0,0 +1,5 @@
|
||||
package agent
|
||||
|
||||
import "github.com/1f349/orchid/logger"
|
||||
|
||||
var Logger = logger.Logger.WithPrefix("Orchid Agent")
|
65
cmd/orchid/agent.go
Normal file
65
cmd/orchid/agent.go
Normal file
@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/pem"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"golang.org/x/crypto/ed25519"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// loadAgentPrivateKey simply attempts to load the agent ssh private key and if
|
||||
// it is missing generates a new key
|
||||
func loadAgentPrivateKey(wd string) ssh.Signer {
|
||||
// load or create a key for orchid agent
|
||||
agentPrivKeyPath := filepath.Join(wd, "agent_id_ed25519")
|
||||
agentPubKeyPath := filepath.Join(wd, "agent_id_ed25519.pub")
|
||||
agentPrivKeyBytes, err := os.ReadFile(agentPrivKeyPath)
|
||||
switch {
|
||||
case err == nil:
|
||||
break
|
||||
case os.IsNotExist(err):
|
||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to generate agent private key", "err", err)
|
||||
}
|
||||
marshalPrivKey, err := ssh.MarshalPrivateKey(privKey, "orchid-agent")
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to encode private key", "err", err)
|
||||
}
|
||||
agentPrivKeyBytes = pem.EncodeToMemory(marshalPrivKey)
|
||||
|
||||
// public key
|
||||
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to encode public key", "err", err)
|
||||
}
|
||||
marshalPubKey := ssh.MarshalAuthorizedKey(sshPubKey)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to encode public key", "err", err)
|
||||
}
|
||||
|
||||
// write to files
|
||||
err = os.WriteFile(agentPrivKeyPath, agentPrivKeyBytes, 0600)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to write agent private key", "path", agentPrivKeyPath, "err", err)
|
||||
}
|
||||
err = os.WriteFile(agentPubKeyPath, marshalPubKey, 0644)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to write agent public key", "path", agentPubKeyPath, "err", err)
|
||||
}
|
||||
|
||||
// we can continue now
|
||||
break
|
||||
case err != nil:
|
||||
logger.Logger.Fatal("Failed to read agent private key", "path", agentPrivKeyPath, "err", err)
|
||||
}
|
||||
|
||||
privKey, err := ssh.ParsePrivateKey(agentPrivKeyBytes)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to parse agent private key file", "path", agentPrivKeyPath, "err", err)
|
||||
}
|
||||
|
||||
return privKey
|
||||
}
|
@ -3,10 +3,11 @@ package main
|
||||
import "github.com/1f349/orchid/renewal"
|
||||
|
||||
type startUpConfig struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Acme acmeConfig `yaml:"acme"`
|
||||
LE renewal.LetsEncryptConfig `yaml:"letsEncrypt"`
|
||||
Domains []string `yaml:"domains"`
|
||||
Listen string `yaml:"listen"`
|
||||
Acme acmeConfig `yaml:"acme"`
|
||||
LE renewal.LetsEncryptConfig `yaml:"letsEncrypt"`
|
||||
Domains []string `yaml:"domains"`
|
||||
AgentKey string `yaml:"agentKey"`
|
||||
}
|
||||
|
||||
type acmeConfig struct {
|
||||
|
@ -1,20 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/orchid"
|
||||
"github.com/1f349/orchid/agent"
|
||||
httpAcme "github.com/1f349/orchid/http-acme"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"github.com/1f349/orchid/renewal"
|
||||
"github.com/1f349/orchid/servers"
|
||||
"github.com/1f349/violet/utils"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
exitReload "github.com/mrmelon54/exit-reload"
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
subcommands.Register(subcommands.HelpCommand(), "")
|
||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||
subcommands.Register(&serveCmd{}, "")
|
||||
subcommands.Register(&setupCmd{}, "")
|
||||
var configPath string
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&configPath, "conf", "", "/path/to/config.json : path to the config file")
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
os.Exit(int(subcommands.Execute(ctx)))
|
||||
|
||||
logger.Logger.Info("Starting...")
|
||||
|
||||
if configPath == "" {
|
||||
logger.Logger.Error("Config flag is missing")
|
||||
trySetup(configPath)
|
||||
return
|
||||
}
|
||||
|
||||
wd, err := getWD(configPath)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to find config directory", "err", err)
|
||||
}
|
||||
|
||||
// try to open the config file
|
||||
openConf, err := os.Open(configPath)
|
||||
switch {
|
||||
case err == nil:
|
||||
break
|
||||
case os.IsNotExist(err):
|
||||
logger.Logger.Warn("Failed to open config file", "err", err)
|
||||
trySetup(wd)
|
||||
return
|
||||
default:
|
||||
logger.Logger.Fatal("Open config file", "err", err)
|
||||
}
|
||||
|
||||
// config file opened with no errors
|
||||
|
||||
defer openConf.Close()
|
||||
|
||||
var config startUpConfig
|
||||
err = yaml.NewDecoder(openConf).Decode(&config)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Invalid config file", "err", err)
|
||||
}
|
||||
|
||||
runDaemon(wd, config)
|
||||
}
|
||||
|
||||
func runDaemon(wd string, conf startUpConfig) {
|
||||
// load the MJWT RSA public key from a pem encoded file
|
||||
mJwtVerify, err := mjwt.NewKeyStoreFromPath(filepath.Join(wd, "keys"))
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to load MJWT verifier public key from file", "path", filepath.Join(wd, "keys"), "err", err)
|
||||
}
|
||||
|
||||
// open sqlite database
|
||||
db, err := orchid.InitDB(filepath.Join(wd, "orchid.db.sqlite"))
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to open database", "err", err)
|
||||
}
|
||||
|
||||
certDir := filepath.Join(wd, "renewal-certs")
|
||||
keyDir := filepath.Join(wd, "renewal-keys")
|
||||
|
||||
wg := new(sync.WaitGroup)
|
||||
acmeProv, err := httpAcme.NewHttpAcmeProvider(filepath.Join(wd, "tokens.yml"), conf.Acme.PresentUrl, conf.Acme.CleanUpUrl, conf.Acme.RefreshUrl)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("HTTP Acme Error", "err", err)
|
||||
}
|
||||
renewalService, err := renewal.NewService(wg, db, acmeProv, conf.LE, certDir, keyDir)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Service Error", "err", err)
|
||||
}
|
||||
certAgent, err := agent.NewAgent(wg, db, loadAgentPrivateKey(wd), certDir, keyDir)
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("Failed to create agent", "err", err)
|
||||
}
|
||||
srv := servers.NewApiServer(conf.Listen, db, mJwtVerify, conf.Domains)
|
||||
logger.Logger.Info("Starting API server", "listen", srv.Addr)
|
||||
go utils.RunBackgroundHttp(logger.Logger, srv)
|
||||
|
||||
exitReload.ExitReload("Violet", func() {}, func() {
|
||||
// stop renewal service and api server
|
||||
renewalService.Shutdown()
|
||||
certAgent.Shutdown()
|
||||
srv.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func getWD(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return os.Getwd()
|
||||
}
|
||||
wdAbs, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Dir(wdAbs), nil
|
||||
}
|
||||
|
@ -1,99 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
httpAcme "github.com/1f349/orchid/http-acme"
|
||||
"github.com/1f349/orchid/renewal"
|
||||
"github.com/1f349/orchid/servers"
|
||||
"github.com/1f349/violet/utils"
|
||||
"github.com/MrMelon54/exit-reload"
|
||||
"github.com/MrMelon54/mjwt"
|
||||
"github.com/google/subcommands"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type serveCmd struct{ configPath string }
|
||||
|
||||
func (s *serveCmd) Name() string { return "serve" }
|
||||
func (s *serveCmd) Synopsis() string { return "Serve certificate renewal service" }
|
||||
func (s *serveCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file")
|
||||
}
|
||||
func (s *serveCmd) Usage() string {
|
||||
return `serve [-conf <config file>]
|
||||
Serve certificate renewal service using information from config file
|
||||
`
|
||||
}
|
||||
|
||||
func (s *serveCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus {
|
||||
log.Println("[Orchid] Starting...")
|
||||
|
||||
if s.configPath == "" {
|
||||
log.Println("[Orchid] Error: config flag is missing")
|
||||
return subcommands.ExitUsageError
|
||||
}
|
||||
|
||||
openConf, err := os.Open(s.configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Println("[Orchid] Error: missing config file")
|
||||
} else {
|
||||
log.Println("[Orchid] Error: open config file: ", err)
|
||||
}
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
var conf startUpConfig
|
||||
err = yaml.NewDecoder(openConf).Decode(&conf)
|
||||
if err != nil {
|
||||
log.Println("[Orchid] Error: invalid config file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
wd := filepath.Dir(s.configPath)
|
||||
normalLoad(conf, wd)
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func normalLoad(conf startUpConfig, wd string) {
|
||||
// load the MJWT RSA public key from a pem encoded file
|
||||
mJwtVerify, err := mjwt.NewMJwtVerifierFromFile(filepath.Join(wd, "signer.public.pem"))
|
||||
if err != nil {
|
||||
log.Fatalf("[Orchid] Failed to load MJWT verifier public key from file '%s': %s", filepath.Join(wd, "signer.public.pem"), err)
|
||||
}
|
||||
|
||||
// open sqlite database
|
||||
db, err := sql.Open("sqlite3", filepath.Join(wd, "orchid.db.sqlite"))
|
||||
if err != nil {
|
||||
log.Fatal("[Orchid] Failed to open database:", err)
|
||||
}
|
||||
|
||||
certDir := filepath.Join(wd, "certs")
|
||||
keyDir := filepath.Join(wd, "keys")
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
acmeProv, err := httpAcme.NewHttpAcmeProvider(filepath.Join(wd, "tokens.yml"), conf.Acme.PresentUrl, conf.Acme.CleanUpUrl, conf.Acme.RefreshUrl)
|
||||
if err != nil {
|
||||
log.Fatal("[Orchid] HTTP Acme Error:", err)
|
||||
}
|
||||
renewalService, err := renewal.NewService(wg, db, acmeProv, conf.LE, certDir, keyDir)
|
||||
if err != nil {
|
||||
log.Fatal("[Orchid] Service Error:", err)
|
||||
}
|
||||
srv := servers.NewApiServer(conf.Listen, db, mJwtVerify, conf.Domains)
|
||||
log.Printf("[API] Starting API server on: '%s'\n", srv.Addr)
|
||||
go utils.RunBackgroundHttp("API", srv)
|
||||
|
||||
exit_reload.ExitReload("Violet", func() {}, func() {
|
||||
// stop renewal service and api server
|
||||
renewalService.Shutdown()
|
||||
srv.Close()
|
||||
})
|
||||
}
|
@ -2,16 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"errors"
|
||||
"fmt"
|
||||
httpAcme "github.com/1f349/orchid/http-acme"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"github.com/1f349/orchid/renewal"
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/google/subcommands"
|
||||
"gopkg.in/yaml.v3"
|
||||
"math/rand"
|
||||
"net"
|
||||
@ -22,37 +21,32 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type setupCmd struct{ wdPath string }
|
||||
var errExitSetup = errors.New("exit setup")
|
||||
|
||||
func (s *setupCmd) Name() string { return "setup" }
|
||||
func (s *setupCmd) Synopsis() string { return "Setup certificate renewal service" }
|
||||
func (s *setupCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&s.wdPath, "wd", ".", "Path to the directory to create config files in (defaults to the working directory)")
|
||||
}
|
||||
func (s *setupCmd) Usage() string {
|
||||
return `setup [-wd <directory>]
|
||||
Setup Orchid automatically by answering questions.
|
||||
`
|
||||
}
|
||||
|
||||
func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus {
|
||||
// get absolute path to specify files
|
||||
wdAbs, err := filepath.Abs(s.wdPath)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Failed to get full directory path: ", err)
|
||||
return subcommands.ExitFailure
|
||||
func trySetup(wd string) {
|
||||
// handle potential errors during setup
|
||||
err := runSetup(wd)
|
||||
switch {
|
||||
case errors.Is(err, errExitSetup):
|
||||
// exit setup without questions
|
||||
return
|
||||
case err == nil:
|
||||
return
|
||||
default:
|
||||
logger.Logger.Fatal("Failed to run setup", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runSetup(wd string) error {
|
||||
// ask about running the setup steps
|
||||
createFile := false
|
||||
err = survey.AskOne(&survey.Confirm{Message: fmt.Sprintf("Create Orchid config files in this directory: '%s'?", wdAbs)}, &createFile)
|
||||
err := survey.AskOne(&survey.Confirm{Message: fmt.Sprintf("Create Orchid config files in this directory: '%s'?", wd)}, &createFile)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Error: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return err
|
||||
}
|
||||
if !createFile {
|
||||
fmt.Println("[Orchid] Goodbye")
|
||||
return subcommands.ExitSuccess
|
||||
logger.Logger.Info("Goodbye")
|
||||
return errExitSetup
|
||||
}
|
||||
|
||||
var answers struct {
|
||||
@ -64,7 +58,6 @@ func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa
|
||||
AcmeRefreshUrl string
|
||||
LEEmail string
|
||||
}
|
||||
_ = answers
|
||||
|
||||
// ask main questions
|
||||
err = survey.Ask([]*survey.Question{
|
||||
@ -88,8 +81,7 @@ func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa
|
||||
},
|
||||
}, &answers)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Error: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return err
|
||||
}
|
||||
|
||||
if answers.AcmeRefresh != "" {
|
||||
@ -111,35 +103,31 @@ func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa
|
||||
},
|
||||
}, &answers)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Error: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.New(rand.NewSource(time.Now().UnixNano())), 4096)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Error: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||
keyBuf := new(bytes.Buffer)
|
||||
err = pem.Encode(keyBuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes})
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Error: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return fmt.Errorf("failed to PEM encode private key: %w", err)
|
||||
}
|
||||
|
||||
// write config file
|
||||
confFile := filepath.Join(wdAbs, "config.yml")
|
||||
confFile := filepath.Join(wd, "config.yml")
|
||||
createConf, err := os.Create(confFile)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Failed to create config file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
defer createConf.Close()
|
||||
|
||||
confEncode := yaml.NewEncoder(createConf)
|
||||
confEncode.SetIndent(2)
|
||||
err = confEncode.Encode(startUpConfig{
|
||||
// this is the whole config structure
|
||||
config := startUpConfig{
|
||||
Listen: answers.ApiListen,
|
||||
Acme: acmeConfig{
|
||||
PresentUrl: answers.AcmePresentUrl,
|
||||
@ -155,18 +143,20 @@ func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa
|
||||
Certificate: "default",
|
||||
},
|
||||
Domains: strings.Split(answers.ApiDomains, ","),
|
||||
})
|
||||
}
|
||||
|
||||
confEncode := yaml.NewEncoder(createConf)
|
||||
confEncode.SetIndent(2)
|
||||
err = confEncode.Encode(config)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Failed to write config file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
// write token file
|
||||
tokenFile := filepath.Join(wdAbs, "tokens.yml")
|
||||
tokenFile := filepath.Join(wd, "tokens.yml")
|
||||
createTokens, err := os.Create(tokenFile)
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Failed to create tokens file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return fmt.Errorf("failed to create tokens file: %w", err)
|
||||
}
|
||||
|
||||
confEncode = yaml.NewEncoder(createTokens)
|
||||
@ -176,14 +166,13 @@ func (s *setupCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interfa
|
||||
Refresh: answers.AcmeRefresh,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("[Orchid] Failed to write tokens file: ", err)
|
||||
return subcommands.ExitFailure
|
||||
return fmt.Errorf("failed to write tokens file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("[Orchid] Setup complete")
|
||||
fmt.Printf("[Orchid] Run the renewal service with `orchid serve -conf %s`\n", confFile)
|
||||
logger.Logger.Info("Setup complete")
|
||||
logger.Logger.Infof("Run the renewal service with `orchid-daemon -conf %s`", confFile)
|
||||
|
||||
return subcommands.ExitSuccess
|
||||
return nil
|
||||
}
|
||||
|
||||
func listenAddressValidator(ans interface{}) error {
|
||||
|
98
database/agent.sql.go
Normal file
98
database/agent.sql.go
Normal file
@ -0,0 +1,98 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: agent.sql
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const findAgentToSync = `-- name: FindAgentToSync :many
|
||||
SELECT agents.id as agent_id, agents.address, agents.user, agents.dir, agents.fingerprint, cert.id as cert_id, cert.not_after as cert_not_after
|
||||
FROM agents
|
||||
INNER JOIN agent_certs
|
||||
ON agent_certs.agent_id = agents.id
|
||||
INNER JOIN certificates AS cert
|
||||
ON cert.id = agent_certs.cert_id
|
||||
WHERE (agents.last_sync IS NULL OR agents.last_sync < cert.updated_at)
|
||||
AND (agent_certs.not_after IS NULL OR agent_certs.not_after IS NOT cert.not_after)
|
||||
ORDER BY agents.last_sync NULLS FIRST
|
||||
`
|
||||
|
||||
type FindAgentToSyncRow struct {
|
||||
AgentID int64 `json:"agent_id"`
|
||||
Address string `json:"address"`
|
||||
User string `json:"user"`
|
||||
Dir string `json:"dir"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CertID int64 `json:"cert_id"`
|
||||
CertNotAfter sql.NullTime `json:"cert_not_after"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindAgentToSync(ctx context.Context) ([]FindAgentToSyncRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, findAgentToSync)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []FindAgentToSyncRow
|
||||
for rows.Next() {
|
||||
var i FindAgentToSyncRow
|
||||
if err := rows.Scan(
|
||||
&i.AgentID,
|
||||
&i.Address,
|
||||
&i.User,
|
||||
&i.Dir,
|
||||
&i.Fingerprint,
|
||||
&i.CertID,
|
||||
&i.CertNotAfter,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateAgentCertNotAfter = `-- name: UpdateAgentCertNotAfter :exec
|
||||
UPDATE agent_certs
|
||||
SET not_after = ?
|
||||
WHERE agent_id = ?
|
||||
AND cert_id = ?
|
||||
`
|
||||
|
||||
type UpdateAgentCertNotAfterParams struct {
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
AgentID int64 `json:"agent_id"`
|
||||
CertID int64 `json:"cert_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgentCertNotAfter(ctx context.Context, arg UpdateAgentCertNotAfterParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateAgentCertNotAfter, arg.NotAfter, arg.AgentID, arg.CertID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateAgentLastSync = `-- name: UpdateAgentLastSync :exec
|
||||
UPDATE agents
|
||||
SET last_sync = ?
|
||||
WHERE agents.id = ?
|
||||
`
|
||||
|
||||
type UpdateAgentLastSyncParams struct {
|
||||
LastSync sql.NullTime `json:"last_sync"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgentLastSync(ctx context.Context, arg UpdateAgentLastSyncParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateAgentLastSync, arg.LastSync, arg.ID)
|
||||
return err
|
||||
}
|
219
database/certificate.sql.go
Normal file
219
database/certificate.sql.go
Normal file
@ -0,0 +1,219 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: certificate.sql
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const addCertificate = `-- name: AddCertificate :exec
|
||||
INSERT INTO certificates (owner, dns, not_after, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type AddCertificateParams struct {
|
||||
Owner string `json:"owner"`
|
||||
Dns sql.NullInt64 `json:"dns"`
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddCertificate(ctx context.Context, arg AddCertificateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, addCertificate,
|
||||
arg.Owner,
|
||||
arg.Dns,
|
||||
arg.NotAfter,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const addTempCertificate = `-- name: AddTempCertificate :exec
|
||||
INSERT INTO certificates (owner, dns, active, updated_at, temp_parent)
|
||||
VALUES (?, NULL, 1, ?, ?)
|
||||
`
|
||||
|
||||
type AddTempCertificateParams struct {
|
||||
Owner string `json:"owner"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TempParent sql.NullInt64 `json:"temp_parent"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddTempCertificate(ctx context.Context, arg AddTempCertificateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, addTempCertificate, arg.Owner, arg.UpdatedAt, arg.TempParent)
|
||||
return err
|
||||
}
|
||||
|
||||
const checkCertOwner = `-- name: CheckCertOwner :one
|
||||
SELECT id, owner
|
||||
FROM certificates
|
||||
WHERE active = 1
|
||||
and id = ?
|
||||
`
|
||||
|
||||
type CheckCertOwnerRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
func (q *Queries) CheckCertOwner(ctx context.Context, id int64) (CheckCertOwnerRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, checkCertOwner, id)
|
||||
var i CheckCertOwnerRow
|
||||
err := row.Scan(&i.ID, &i.Owner)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findNextCert = `-- name: FindNextCert :one
|
||||
SELECT cert.id, cert.not_after, dns_acme.type, dns_acme.token, cert.temp_parent
|
||||
FROM certificates AS cert
|
||||
LEFT OUTER JOIN dns_acme ON cert.dns = dns_acme.id
|
||||
WHERE cert.active = 1
|
||||
AND (cert.auto_renew = 1 OR cert.not_after IS NULL)
|
||||
AND cert.renewing = 0
|
||||
AND (cert.renew_retry IS NULL OR DATETIME() > DATETIME(cert.renew_retry))
|
||||
AND (cert.not_after IS NULL OR DATETIME(cert.not_after, 'utc', '-30 days') < DATETIME())
|
||||
ORDER BY cert.temp_parent, cert.not_after DESC NULLS FIRST
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type FindNextCertRow struct {
|
||||
ID int64 `json:"id"`
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
Type sql.NullString `json:"type"`
|
||||
Token sql.NullString `json:"token"`
|
||||
TempParent sql.NullInt64 `json:"temp_parent"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindNextCert(ctx context.Context) (FindNextCertRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, findNextCert)
|
||||
var i FindNextCertRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.NotAfter,
|
||||
&i.Type,
|
||||
&i.Token,
|
||||
&i.TempParent,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findOwnedCerts = `-- name: FindOwnedCerts :many
|
||||
SELECT cert.id,
|
||||
cert.auto_renew,
|
||||
cert.active,
|
||||
cert.renewing,
|
||||
cert.renew_retry,
|
||||
cert.not_after,
|
||||
cert.updated_at,
|
||||
certificate_domains.domain
|
||||
FROM certificates AS cert
|
||||
INNER JOIN certificate_domains ON cert.id = certificate_domains.cert_id
|
||||
`
|
||||
|
||||
type FindOwnedCertsRow struct {
|
||||
ID int64 `json:"id"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
Active bool `json:"active"`
|
||||
Renewing bool `json:"renewing"`
|
||||
RenewRetry sql.NullTime `json:"renew_retry"`
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
func (q *Queries) FindOwnedCerts(ctx context.Context) ([]FindOwnedCertsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, findOwnedCerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []FindOwnedCertsRow
|
||||
for rows.Next() {
|
||||
var i FindOwnedCertsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AutoRenew,
|
||||
&i.Active,
|
||||
&i.Renewing,
|
||||
&i.RenewRetry,
|
||||
&i.NotAfter,
|
||||
&i.UpdatedAt,
|
||||
&i.Domain,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const removeCertificate = `-- name: RemoveCertificate :exec
|
||||
UPDATE certificates
|
||||
SET active = 0
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveCertificate(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, removeCertificate, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const setRetryFlag = `-- name: SetRetryFlag :exec
|
||||
UPDATE certificates
|
||||
SET renew_retry = DATETIME('now', '+1 day')
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) SetRetryFlag(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, setRetryFlag, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateCertAfterRenewal = `-- name: UpdateCertAfterRenewal :exec
|
||||
UPDATE certificates
|
||||
SET renewing = 0,
|
||||
renew_retry=0,
|
||||
not_after=?,
|
||||
updated_at=?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateCertAfterRenewalParams struct {
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateCertAfterRenewal(ctx context.Context, arg UpdateCertAfterRenewalParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateCertAfterRenewal, arg.NotAfter, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateRenewingState = `-- name: UpdateRenewingState :exec
|
||||
UPDATE certificates
|
||||
SET renewing = ?,
|
||||
renew_retry = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateRenewingStateParams struct {
|
||||
Renewing bool `json:"renewing"`
|
||||
RenewRetry sql.NullTime `json:"renew_retry"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateRenewingState(ctx context.Context, arg UpdateRenewingStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateRenewingState, arg.Renewing, arg.RenewRetry, arg.ID)
|
||||
return err
|
||||
}
|
133
database/certificate_domains.sql.go
Normal file
133
database/certificate_domains.sql.go
Normal file
@ -0,0 +1,133 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// source: certificate_domains.sql
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const addDomains = `-- name: AddDomains :exec
|
||||
INSERT INTO certificate_domains (cert_id, domain, state)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
|
||||
type AddDomainsParams struct {
|
||||
CertID int64 `json:"cert_id"`
|
||||
Domain string `json:"domain"`
|
||||
State int64 `json:"state"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddDomains(ctx context.Context, arg AddDomainsParams) error {
|
||||
_, err := q.db.ExecContext(ctx, addDomains, arg.CertID, arg.Domain, arg.State)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDomainStatesForCert = `-- name: GetDomainStatesForCert :many
|
||||
SELECT domain, state
|
||||
FROM certificate_domains
|
||||
WHERE cert_id = ?
|
||||
`
|
||||
|
||||
type GetDomainStatesForCertRow struct {
|
||||
Domain string `json:"domain"`
|
||||
State int64 `json:"state"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDomainStatesForCert(ctx context.Context, certID int64) ([]GetDomainStatesForCertRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDomainStatesForCert, certID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetDomainStatesForCertRow
|
||||
for rows.Next() {
|
||||
var i GetDomainStatesForCertRow
|
||||
if err := rows.Scan(&i.Domain, &i.State); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDomainsForCertificate = `-- name: GetDomainsForCertificate :many
|
||||
SELECT domain
|
||||
FROM certificate_domains
|
||||
WHERE cert_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetDomainsForCertificate(ctx context.Context, certID int64) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDomainsForCertificate, certID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var domain string
|
||||
if err := rows.Scan(&domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, domain)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setDomainStateForCert = `-- name: SetDomainStateForCert :exec
|
||||
UPDATE certificate_domains
|
||||
SET state = ?
|
||||
WHERE cert_id = ?
|
||||
`
|
||||
|
||||
type SetDomainStateForCertParams struct {
|
||||
State int64 `json:"state"`
|
||||
CertID int64 `json:"cert_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDomainStateForCert(ctx context.Context, arg SetDomainStateForCertParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDomainStateForCert, arg.State, arg.CertID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateDomains = `-- name: UpdateDomains :exec
|
||||
UPDATE certificate_domains
|
||||
SET state = ?
|
||||
WHERE domain IN (/*SLICE:domains*/?)
|
||||
`
|
||||
|
||||
type UpdateDomainsParams struct {
|
||||
State int64 `json:"state"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateDomains(ctx context.Context, arg UpdateDomainsParams) error {
|
||||
query := updateDomains
|
||||
var queryParams []interface{}
|
||||
queryParams = append(queryParams, arg.State)
|
||||
if len(arg.Domains) > 0 {
|
||||
for _, v := range arg.Domains {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:domains*/?", strings.Repeat(",?", len(arg.Domains))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:domains*/?", "NULL", 1)
|
||||
}
|
||||
_, err := q.db.ExecContext(ctx, query, queryParams...)
|
||||
return err
|
||||
}
|
31
database/db.go
Normal file
31
database/db.go
Normal file
@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
0
database/migrations/20240308160822_init.down.sql
Normal file
0
database/migrations/20240308160822_init.down.sql
Normal file
33
database/migrations/20240308160822_init.up.sql
Normal file
33
database/migrations/20240308160822_init.up.sql
Normal file
@ -0,0 +1,33 @@
|
||||
CREATE TABLE IF NOT EXISTS certificates
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner VARCHAR NOT NULL,
|
||||
dns INTEGER,
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT 0,
|
||||
renewing BOOLEAN NOT NULL DEFAULT 0,
|
||||
renew_failed BOOLEAN NOT NULL DEFAULT 0,
|
||||
not_after DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
temp_parent INTEGER,
|
||||
FOREIGN KEY (dns) REFERENCES dns_acme (id),
|
||||
FOREIGN KEY (temp_parent) REFERENCES certificates (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_domains
|
||||
(
|
||||
domain_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cert_id INTEGER NOT NULL,
|
||||
domain VARCHAR NOT NULL,
|
||||
state INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE (cert_id, domain),
|
||||
FOREIGN KEY (cert_id) REFERENCES certificates (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dns_acme
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
token VARCHAR NOT NULL
|
||||
);
|
5
database/migrations/20240920175046_retry_renewal.up.sql
Normal file
5
database/migrations/20240920175046_retry_renewal.up.sql
Normal file
@ -0,0 +1,5 @@
|
||||
ALTER TABLE certificates
|
||||
DROP COLUMN renew_failed;
|
||||
|
||||
ALTER TABLE certificates
|
||||
ADD COLUMN renew_retry DATETIME NOT NULL DEFAULT 0;
|
0
database/migrations/20250129211955_agent.down.sql
Normal file
0
database/migrations/20250129211955_agent.down.sql
Normal file
21
database/migrations/20250129211955_agent.up.sql
Normal file
21
database/migrations/20250129211955_agent.up.sql
Normal file
@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS agents
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
dir TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
last_sync DATETIME NULL DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_certs
|
||||
(
|
||||
agent_id INTEGER NOT NULL,
|
||||
cert_id INTEGER NOT NULL,
|
||||
not_after INTEGER NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (agent_id, cert_id),
|
||||
|
||||
FOREIGN KEY (agent_id) REFERENCES agents (id),
|
||||
FOREIGN KEY (cert_id) REFERENCES certificates (id)
|
||||
);
|
29
database/migrations/20250131183447_null_not_after.up.sql
Normal file
29
database/migrations/20250131183447_null_not_after.up.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- null not after
|
||||
|
||||
ALTER TABLE certificates
|
||||
RENAME COLUMN not_after TO not_after_2;
|
||||
|
||||
ALTER TABLE certificates
|
||||
ADD COLUMN not_after DATETIME NULL;
|
||||
|
||||
UPDATE certificates
|
||||
SET not_after = not_after_2
|
||||
WHERE not_after IS NULL;
|
||||
|
||||
ALTER TABLE certificates
|
||||
DROP COLUMN not_after_2;
|
||||
|
||||
-- null renew retry
|
||||
|
||||
ALTER TABLE certificates
|
||||
RENAME COLUMN renew_retry TO renew_retry_2;
|
||||
|
||||
ALTER TABLE certificates
|
||||
ADD COLUMN renew_retry DATETIME NULL;
|
||||
|
||||
UPDATE certificates
|
||||
SET renew_retry = renew_retry_2
|
||||
WHERE renew_retry IS NULL;
|
||||
|
||||
ALTER TABLE certificates
|
||||
DROP COLUMN renew_retry_2;
|
52
database/models.go
Normal file
52
database/models.go
Normal file
@ -0,0 +1,52 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
ID int64 `json:"id"`
|
||||
Address string `json:"address"`
|
||||
User string `json:"user"`
|
||||
Dir string `json:"dir"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
LastSync sql.NullTime `json:"last_sync"`
|
||||
}
|
||||
|
||||
type AgentCert struct {
|
||||
AgentID int64 `json:"agent_id"`
|
||||
CertID int64 `json:"cert_id"`
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
}
|
||||
|
||||
type Certificate struct {
|
||||
ID int64 `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
Dns sql.NullInt64 `json:"dns"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
Active bool `json:"active"`
|
||||
Renewing bool `json:"renewing"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TempParent sql.NullInt64 `json:"temp_parent"`
|
||||
NotAfter sql.NullTime `json:"not_after"`
|
||||
RenewRetry sql.NullTime `json:"renew_retry"`
|
||||
}
|
||||
|
||||
type CertificateDomain struct {
|
||||
DomainID int64 `json:"domain_id"`
|
||||
CertID int64 `json:"cert_id"`
|
||||
Domain string `json:"domain"`
|
||||
State int64 `json:"state"`
|
||||
}
|
||||
|
||||
type DnsAcme struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Email string `json:"email"`
|
||||
Token string `json:"token"`
|
||||
}
|
21
database/queries/agent.sql
Normal file
21
database/queries/agent.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- name: FindAgentToSync :many
|
||||
SELECT agents.id as agent_id, agents.address, agents.user, agents.dir, agents.fingerprint, cert.id as cert_id, cert.not_after as cert_not_after
|
||||
FROM agents
|
||||
INNER JOIN agent_certs
|
||||
ON agent_certs.agent_id = agents.id
|
||||
INNER JOIN certificates AS cert
|
||||
ON cert.id = agent_certs.cert_id
|
||||
WHERE (agents.last_sync IS NULL OR agents.last_sync < cert.updated_at)
|
||||
AND (agent_certs.not_after IS NULL OR agent_certs.not_after IS NOT cert.not_after)
|
||||
ORDER BY agents.last_sync NULLS FIRST;
|
||||
|
||||
-- name: UpdateAgentLastSync :exec
|
||||
UPDATE agents
|
||||
SET last_sync = ?
|
||||
WHERE agents.id = ?;
|
||||
|
||||
-- name: UpdateAgentCertNotAfter :exec
|
||||
UPDATE agent_certs
|
||||
SET not_after = ?
|
||||
WHERE agent_id = ?
|
||||
AND cert_id = ?;
|
61
database/queries/certificate.sql
Normal file
61
database/queries/certificate.sql
Normal file
@ -0,0 +1,61 @@
|
||||
-- name: FindNextCert :one
|
||||
SELECT cert.id, cert.not_after, dns_acme.type, dns_acme.token, cert.temp_parent
|
||||
FROM certificates AS cert
|
||||
LEFT OUTER JOIN dns_acme ON cert.dns = dns_acme.id
|
||||
WHERE cert.active = 1
|
||||
AND (cert.auto_renew = 1 OR cert.not_after IS NULL)
|
||||
AND cert.renewing = 0
|
||||
AND (cert.renew_retry IS NULL OR DATETIME() > DATETIME(cert.renew_retry))
|
||||
AND (cert.not_after IS NULL OR DATETIME(cert.not_after, 'utc', '-30 days') < DATETIME())
|
||||
ORDER BY cert.temp_parent, cert.not_after DESC NULLS FIRST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: FindOwnedCerts :many
|
||||
SELECT cert.id,
|
||||
cert.auto_renew,
|
||||
cert.active,
|
||||
cert.renewing,
|
||||
cert.renew_retry,
|
||||
cert.not_after,
|
||||
cert.updated_at,
|
||||
certificate_domains.domain
|
||||
FROM certificates AS cert
|
||||
INNER JOIN certificate_domains ON cert.id = certificate_domains.cert_id;
|
||||
|
||||
-- name: UpdateRenewingState :exec
|
||||
UPDATE certificates
|
||||
SET renewing = ?,
|
||||
renew_retry = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetRetryFlag :exec
|
||||
UPDATE certificates
|
||||
SET renew_retry = DATETIME('now', '+1 day')
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: UpdateCertAfterRenewal :exec
|
||||
UPDATE certificates
|
||||
SET renewing = 0,
|
||||
renew_retry=0,
|
||||
not_after=?,
|
||||
updated_at=?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: AddCertificate :exec
|
||||
INSERT INTO certificates (owner, dns, not_after, updated_at)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
-- name: AddTempCertificate :exec
|
||||
INSERT INTO certificates (owner, dns, active, updated_at, temp_parent)
|
||||
VALUES (?, NULL, 1, ?, ?);
|
||||
|
||||
-- name: RemoveCertificate :exec
|
||||
UPDATE certificates
|
||||
SET active = 0
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: CheckCertOwner :one
|
||||
SELECT id, owner
|
||||
FROM certificates
|
||||
WHERE active = 1
|
||||
and id = ?;
|
23
database/queries/certificate_domains.sql
Normal file
23
database/queries/certificate_domains.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- name: GetDomainsForCertificate :many
|
||||
SELECT domain
|
||||
FROM certificate_domains
|
||||
WHERE cert_id = ?;
|
||||
|
||||
-- name: GetDomainStatesForCert :many
|
||||
SELECT domain, state
|
||||
FROM certificate_domains
|
||||
WHERE cert_id = ?;
|
||||
|
||||
-- name: SetDomainStateForCert :exec
|
||||
UPDATE certificate_domains
|
||||
SET state = ?
|
||||
WHERE cert_id = ?;
|
||||
|
||||
-- name: AddDomains :exec
|
||||
INSERT INTO certificate_domains (cert_id, domain, state)
|
||||
VALUES (?, ?, ?);
|
||||
|
||||
-- name: UpdateDomains :exec
|
||||
UPDATE certificate_domains
|
||||
SET state = ?
|
||||
WHERE domain IN (sqlc.slice(domains));
|
26
database/tx.go
Normal file
26
database/tx.go
Normal file
@ -0,0 +1,26 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errMissingSqlDB = errors.New("cannot open transaction without sql.DB")
|
||||
|
||||
func (q *Queries) UseTx(ctx context.Context, cb func(tx *Queries) error) error {
|
||||
sqlDB, ok := q.db.(*sql.DB)
|
||||
if !ok {
|
||||
return errMissingSqlDB
|
||||
}
|
||||
tx, err := sqlDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = cb(q.WithTx(tx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
73
go.mod
73
go.mod
@ -1,42 +1,63 @@
|
||||
module github.com/1f349/orchid
|
||||
|
||||
go 1.20
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/1f349/violet v0.0.6
|
||||
github.com/1f349/mjwt v0.4.1
|
||||
github.com/1f349/violet v0.0.14
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/MrMelon54/certgen v0.0.1
|
||||
github.com/MrMelon54/exit-reload v0.0.1
|
||||
github.com/MrMelon54/mjwt v0.1.1
|
||||
github.com/go-acme/lego/v4 v4.12.3
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0
|
||||
github.com/charmbracelet/log v0.4.1
|
||||
github.com/go-acme/lego/v4 v4.22.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/miekg/dns v1.1.55
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/miekg/dns v1.1.64
|
||||
github.com/mrmelon54/certgen v0.0.2
|
||||
github.com/mrmelon54/exit-reload v0.0.2
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||
github.com/1f349/rsa-helper v0.0.2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/becheran/wildmatch-go v1.0.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/nrdcg/namesilo v0.2.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.11.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/net v0.12.0 // indirect
|
||||
golang.org/x/sys v0.10.0 // indirect
|
||||
golang.org/x/term v0.10.0 // indirect
|
||||
golang.org/x/text v0.11.0 // indirect
|
||||
golang.org/x/tools v0.10.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/term v0.30.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
)
|
||||
|
161
go.sum
161
go.sum
@ -1,121 +1,168 @@
|
||||
github.com/1f349/violet v0.0.6 h1:JrSQCT3sMPJshFYXUoZOg4kjFRdE0p1roKDOgtJezC8=
|
||||
github.com/1f349/violet v0.0.6/go.mod h1:5vksv2yrh9c3sf74iavp9csb8KFWTfa7fpGqIXH2ka4=
|
||||
github.com/1f349/mjwt v0.4.1 h1:ooCroMMw2kcL5c9L3sLbdtxI0H4/QC8RfTxiloKr+4Y=
|
||||
github.com/1f349/mjwt v0.4.1/go.mod h1:qwnzokkqc7Z9YmKA1m9beI3OZL1GvGYHOQU2rOwoV1M=
|
||||
github.com/1f349/rsa-helper v0.0.2 h1:N/fLQqg5wrjIzG6G4zdwa5Xcv9/jIPutCls9YekZr9U=
|
||||
github.com/1f349/rsa-helper v0.0.2/go.mod h1:VUQ++1tYYhYrXeOmVFkQ82BegR24HQEJHl5lHbjg7yg=
|
||||
github.com/1f349/violet v0.0.14 h1:MpBZ4n1dJjdiIwYMTfh0PBIFll3kjqowxR6DLasafqE=
|
||||
github.com/1f349/violet v0.0.14/go.mod h1:iAREhm+wxnGXkmuvmBhOuhUx2T7/5w7stLYNgQGbqC8=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||
github.com/MrMelon54/certgen v0.0.1 h1:ycWdZ2RlxQ5qSuejeBVv4aXjGo5hdqqL4j4EjrXnFMk=
|
||||
github.com/MrMelon54/certgen v0.0.1/go.mod h1:GHflVlSbtFLJZLpN1oWyUvDBRrR8qCWiwZLXCCnS2Gc=
|
||||
github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc=
|
||||
github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug=
|
||||
github.com/MrMelon54/mjwt v0.1.1 h1:m+aTpxbhQCrOPKHN170DQMFR5r938LkviU38unob5Jw=
|
||||
github.com/MrMelon54/mjwt v0.1.1/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA=
|
||||
github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4=
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM=
|
||||
github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
|
||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-acme/lego/v4 v4.12.3 h1:aWPYhBopAZXWBASPgvi1LnWGrr5YiXOsrpVaFaVJipo=
|
||||
github.com/go-acme/lego/v4 v4.12.3/go.mod h1:UZoOlhVmUYP/N0z4tEbfUjoCNHRZNObzqWZtT76DIsc=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-acme/lego/v4 v4.22.2 h1:ck+HllWrV/rZGeYohsKQ5iKNnU/WAZxwOdiu6cxky+0=
|
||||
github.com/go-acme/lego/v4 v4.22.2/go.mod h1:E2FndyI3Ekv0usNJt46mFb9LVpV/XBYT+4E3tz02Tzo=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ=
|
||||
github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||
github.com/mrmelon54/certgen v0.0.2 h1:4CMDkA/gGZu+E4iikU+5qdOWK7qOQrk58KtUfnmyYmY=
|
||||
github.com/mrmelon54/certgen v0.0.2/go.mod h1:vwrWSXQmxZYqEyh+cf05IvDIFV2aYuxL4+O6ABIlN8M=
|
||||
github.com/mrmelon54/exit-reload v0.0.2 h1:vqgfrMD/bF21HkDsWgg5+NLjFDrD3KGVEN/iTrMn9Ms=
|
||||
github.com/mrmelon54/exit-reload v0.0.2/go.mod h1:aE3NhsqGMLUqmv6cJZRouC/8gXkZTvVSabRGOpI+Vjc=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg=
|
||||
github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
|
||||
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
@ -3,9 +3,9 @@ package http_acme
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"gopkg.in/yaml.v3"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@ -146,12 +146,12 @@ func (h *HttpAcmeProvider) saveLoginTokens() {
|
||||
// acme login token
|
||||
openTokens, err := os.Create(h.tokenFile)
|
||||
if err != nil {
|
||||
log.Println("[Orchid] Failed to open token file:", err)
|
||||
logger.Logger.Info("[Orchid] Failed to open token file:", "err", err)
|
||||
}
|
||||
defer openTokens.Close()
|
||||
|
||||
err = yaml.NewEncoder(openTokens).Encode(AcmeLogin{Access: h.accessToken, Refresh: h.refreshToken})
|
||||
if err != nil {
|
||||
log.Println("[Orchid] Failed to write tokens file:", err)
|
||||
logger.Logger.Info("[Orchid] Failed to write tokens file:", "err", err)
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
package http_acme
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"github.com/MrMelon54/mjwt"
|
||||
"github.com/MrMelon54/mjwt/auth"
|
||||
"github.com/MrMelon54/mjwt/claims"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/mjwt/auth"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -31,7 +30,7 @@ func makeQuickHttpProv(accessToken string, ft http.RoundTripper) *HttpAcmeProvid
|
||||
// fakeTransport captures any requests and responds with a successful answer if
|
||||
// applicable
|
||||
type fakeTransport struct {
|
||||
verify mjwt.Verifier
|
||||
verify *mjwt.KeyStore
|
||||
req *http.Request
|
||||
clean bool
|
||||
}
|
||||
@ -61,19 +60,17 @@ func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func TestHttpAcmeProvider_Present(t *testing.T) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// perms
|
||||
ps := claims.NewPermStorage()
|
||||
ps := auth.NewPermStorage()
|
||||
ps.Set("test:acme:present")
|
||||
|
||||
// signer
|
||||
signer := mjwt.NewMJwtSigner("Test", privateKey)
|
||||
signer, err := mjwt.NewIssuer("Test", uuid.NewString(), jwt.SigningMethodRS512)
|
||||
assert.NoError(t, err)
|
||||
accessToken, err := signer.GenerateJwt("", "", nil, 5*time.Minute, auth.AccessTokenClaims{Perms: ps})
|
||||
assert.NoError(t, err)
|
||||
|
||||
ft := &fakeTransport{verify: signer}
|
||||
ft := &fakeTransport{verify: signer.KeyStore()}
|
||||
prov := makeQuickHttpProv(accessToken, ft)
|
||||
assert.NoError(t, prov.Present("example.com", "1234", "1234abcd"))
|
||||
assert.Equal(t, *ft.req.URL, url.URL{
|
||||
@ -84,19 +81,17 @@ func TestHttpAcmeProvider_Present(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHttpAcmeProvider_CleanUp(t *testing.T) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// perms
|
||||
ps := claims.NewPermStorage()
|
||||
ps := auth.NewPermStorage()
|
||||
ps.Set("test:acme:clean")
|
||||
|
||||
// signer
|
||||
signer := mjwt.NewMJwtSigner("Test", privateKey)
|
||||
signer, err := mjwt.NewIssuer("Test", uuid.NewString(), jwt.SigningMethodRS512)
|
||||
assert.NoError(t, err)
|
||||
accessToken, err := signer.GenerateJwt("", "", nil, 5*time.Minute, auth.AccessTokenClaims{Perms: ps})
|
||||
assert.NoError(t, err)
|
||||
|
||||
ft := &fakeTransport{verify: signer, clean: true}
|
||||
ft := &fakeTransport{verify: signer.KeyStore(), clean: true}
|
||||
prov := makeQuickHttpProv(accessToken, ft)
|
||||
assert.NoError(t, prov.CleanUp("example.com", "1234", "1234abcd"))
|
||||
assert.Equal(t, *ft.req.URL, url.URL{
|
||||
|
38
initdb.go
Normal file
38
initdb.go
Normal file
@ -0,0 +1,38 @@
|
||||
package orchid
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
)
|
||||
|
||||
//go:embed database/migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
func InitDB(p string) (*database.Queries, error) {
|
||||
migDrv, err := iofs.New(migrations, "database/migrations")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbOpen, err := sql.Open("sqlite3", p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbDrv, err := sqlite3.WithInstance(dbOpen, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mig, err := migrate.NewWithInstance("iofs", migDrv, "sqlite3", dbDrv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = mig.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return nil, err
|
||||
}
|
||||
return database.New(dbOpen), nil
|
||||
}
|
12
logger/logger.go
Normal file
12
logger/logger.go
Normal file
@ -0,0 +1,12 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/log"
|
||||
"os"
|
||||
)
|
||||
|
||||
var Logger = log.NewWithOptions(os.Stderr, log.Options{
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
Prefix: "Orchid",
|
||||
})
|
@ -1,33 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS certificates
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner VARCHAR,
|
||||
dns INTEGER,
|
||||
auto_renew INTEGER DEFAULT 0,
|
||||
active INTEGER DEFAULT 0,
|
||||
renewing INTEGER DEFAULT 0,
|
||||
renew_failed INTEGER DEFAULT 0,
|
||||
not_after DATETIME,
|
||||
updated_at DATETIME,
|
||||
temp_parent INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (dns) REFERENCES dns_acme (id),
|
||||
FOREIGN KEY (temp_parent) REFERENCES certificates (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_domains
|
||||
(
|
||||
domain_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cert_id INTEGER,
|
||||
domain VARCHAR,
|
||||
state INTEGER DEFAULT 1,
|
||||
UNIQUE (cert_id, domain),
|
||||
FOREIGN KEY (cert_id) REFERENCES certificates (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dns_acme
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR,
|
||||
email VARCHAR,
|
||||
token VARCHAR
|
||||
);
|
@ -1,9 +0,0 @@
|
||||
select cert.id, cert.not_after, dns_acme.type, dns_acme.token, cert.temp_parent
|
||||
from certificates as cert
|
||||
left outer join dns_acme on cert.dns = dns_acme.id
|
||||
where cert.active = 1
|
||||
and (cert.auto_renew = 1 or cert.not_after IS NULL)
|
||||
and cert.renewing = 0
|
||||
and cert.renew_failed = 0
|
||||
and (cert.not_after IS NULL or DATETIME(cert.not_after, 'utc', '-30 days') < DATETIME())
|
||||
order by cert.temp_parent, cert.not_after DESC NULLS FIRST
|
@ -2,16 +2,17 @@ package renewal
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Contains local types for the renewal service
|
||||
type localCertData struct {
|
||||
id uint64
|
||||
id int64
|
||||
dns struct {
|
||||
name sql.NullString
|
||||
token sql.NullString
|
||||
}
|
||||
notAfter sql.NullTime
|
||||
notAfter time.Time
|
||||
domains []string
|
||||
tempParent uint64
|
||||
tempParent sql.NullInt64
|
||||
}
|
||||
|
5
renewal/logger.go
Normal file
5
renewal/logger.go
Normal file
@ -0,0 +1,5 @@
|
||||
package renewal
|
||||
|
||||
import "github.com/1f349/orchid/logger"
|
||||
|
||||
var Logger = logger.Logger.WithPrefix("Orchid Renewal")
|
@ -2,6 +2,7 @@ package renewal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@ -10,7 +11,9 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/1f349/orchid/pebble"
|
||||
"github.com/1f349/orchid/utils"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
@ -18,8 +21,6 @@ import (
|
||||
"github.com/go-acme/lego/v4/providers/dns/duckdns"
|
||||
"github.com/go-acme/lego/v4/providers/dns/namesilo"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -28,13 +29,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedDNSProvider = errors.New("unsupported DNS provider")
|
||||
//go:embed find-next-cert.sql
|
||||
findNextCertSql string
|
||||
//go:embed create-tables.sql
|
||||
createTableCertificates string
|
||||
)
|
||||
var ErrUnsupportedDNSProvider = errors.New("unsupported DNS provider")
|
||||
|
||||
const (
|
||||
DomainStateNormal = 0
|
||||
@ -57,7 +52,7 @@ var testDnsOptions interface {
|
||||
// `_acme-challenges` TXT records are updated to validate the ownership of the
|
||||
// specified domains.
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
db *database.Queries
|
||||
httpAcme challenge.Provider
|
||||
certTicker *time.Ticker
|
||||
certDone chan struct{}
|
||||
@ -73,7 +68,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService creates a new certificate renewal service.
|
||||
func NewService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provider, leConfig LetsEncryptConfig, certDir, keyDir string) (*Service, error) {
|
||||
func NewService(wg *sync.WaitGroup, db *database.Queries, httpAcme challenge.Provider, leConfig LetsEncryptConfig, certDir, keyDir string) (*Service, error) {
|
||||
s := &Service{
|
||||
db: db,
|
||||
httpAcme: httpAcme,
|
||||
@ -104,12 +99,6 @@ func NewService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provider, leC
|
||||
return nil, fmt.Errorf("failed to resolve LetsEncrypt account private key: %w", err)
|
||||
}
|
||||
|
||||
// init domains table
|
||||
_, err = s.db.Exec(createTableCertificates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificates table: %w", err)
|
||||
}
|
||||
|
||||
// resolve CA information
|
||||
s.resolveCADirectory(leConfig.Directory)
|
||||
err = s.resolveCACertificate(leConfig.Certificate)
|
||||
@ -132,7 +121,7 @@ func NewService(wg *sync.WaitGroup, db *sql.DB, httpAcme challenge.Provider, leC
|
||||
|
||||
// Shutdown the renewal service.
|
||||
func (s *Service) Shutdown() {
|
||||
log.Println("[Renewal] Shutting down certificate renewal service")
|
||||
Logger.Info("Shutting down certificate renewal service")
|
||||
close(s.certDone)
|
||||
}
|
||||
|
||||
@ -213,23 +202,24 @@ var ErrAlreadyRenewing = errors.New("already renewing")
|
||||
// renewalRoutine is the main loop which makes used of certTicker to constantly
|
||||
// check if the existing certificates are up-to-date.
|
||||
func (s *Service) renewalRoutine(wg *sync.WaitGroup) {
|
||||
Logger.Debug("Starting renewalRoutine")
|
||||
|
||||
// Upon leaving the function stop the ticker and clear the WaitGroup.
|
||||
defer func() {
|
||||
s.certTicker.Stop()
|
||||
log.Println("[Renewal] Stopped certificate renewal service")
|
||||
Logger.Info("Stopped certificate renewal service")
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
// Do an initial check and refuse to start if any errors occur.
|
||||
log.Println("[Renewal] Doing quick certificate check before starting...")
|
||||
Logger.Info("Doing quick certificate check before starting...")
|
||||
err := s.renewalCheck()
|
||||
if err != nil {
|
||||
log.Println("[Renewal] Certificate check, should not error first try: ", err)
|
||||
return
|
||||
Logger.Info("Certificate check, should not error first try", "err", err)
|
||||
}
|
||||
|
||||
// Logging or something
|
||||
log.Println("[Renewal] Initial check complete, continually checking every 10 minutes...")
|
||||
Logger.Info("Initial check complete, continually checking every 10 minutes...")
|
||||
|
||||
// Main loop
|
||||
for {
|
||||
@ -238,12 +228,14 @@ func (s *Service) renewalRoutine(wg *sync.WaitGroup) {
|
||||
// Exit if certDone has closed
|
||||
return
|
||||
case <-s.certTicker.C:
|
||||
Logger.Debug("Ticking certificate renewal")
|
||||
|
||||
// Start a new check in a separate routine
|
||||
go func() {
|
||||
// run a renewal check and log errors, but ignore ErrAlreadyRenewing
|
||||
err := s.renewalCheck()
|
||||
if err != nil && err != ErrAlreadyRenewing {
|
||||
log.Println("[Renewal] Certificate check, an error occurred: ", err)
|
||||
if err != nil && !errors.Is(err, ErrAlreadyRenewing) {
|
||||
Logger.Info("Certificate check, an error occurred", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -258,6 +250,8 @@ func (s *Service) renewalCheck() error {
|
||||
}
|
||||
defer s.renewLock.Unlock()
|
||||
|
||||
Logger.Debug("Running renewalCheck")
|
||||
|
||||
// check for running out certificates in the database
|
||||
localData, err := s.findNextCertificateToRenew()
|
||||
if err != nil {
|
||||
@ -266,17 +260,20 @@ func (s *Service) renewalCheck() error {
|
||||
|
||||
// no certificates to update
|
||||
if localData == nil {
|
||||
Logger.Debug("No certificates to update")
|
||||
return nil
|
||||
}
|
||||
|
||||
// renew the certificate from the collected data
|
||||
err = s.renewCert(localData)
|
||||
if err != nil {
|
||||
Logger.Debug("Failed to renew certificate", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// renew succeeded
|
||||
log.Printf("[Renewal] Updated certificate %d successfully\n", localData.id)
|
||||
Logger.Info("Updated certificate successfully", "id", localData.id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -285,50 +282,30 @@ func (s *Service) findNextCertificateToRenew() (*localCertData, error) {
|
||||
d := &localCertData{}
|
||||
|
||||
// sql or something, the query is in `find-next-cert.sql`
|
||||
row, err := s.db.Query(findNextCertSql)
|
||||
row, err := s.db.FindNextCert(context.Background())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run query: %w", err)
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
// if next fails no rows were found
|
||||
if !row.Next() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// scan the first row
|
||||
err = row.Scan(&d.id, &d.notAfter, &d.dns.name, &d.dns.token, &d.tempParent)
|
||||
switch err {
|
||||
case nil:
|
||||
// no nothing
|
||||
break
|
||||
case io.EOF:
|
||||
// no certificate to update
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to scan table row: %w", err)
|
||||
}
|
||||
d.id = row.ID
|
||||
d.dns.name = row.Type
|
||||
d.dns.token = row.Token
|
||||
d.notAfter = row.NotAfter.Time
|
||||
d.tempParent = row.TempParent
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Service) fetchDomains(localData *localCertData) ([]string, error) {
|
||||
// more sql: this one just grabs all the domains for a certificate
|
||||
query, err := s.db.Query(`SELECT domain FROM certificate_domains WHERE cert_id = ?`, resolveTempParent(localData))
|
||||
domains, err := s.db.GetDomainsForCertificate(context.Background(), resolveTempParent(localData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch domains for certificate: %d: %w", localData.id, err)
|
||||
}
|
||||
|
||||
// convert query responses to a string slice
|
||||
domains := make([]string, 0)
|
||||
for query.Next() {
|
||||
var domain string
|
||||
err := query.Scan(&domain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan row from domains table: %d: %w", localData.id, err)
|
||||
}
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
// if no domains were found then the renewal will fail
|
||||
if len(domains) == 0 {
|
||||
return nil, fmt.Errorf("no domains registered for certificate: %d", localData.id)
|
||||
@ -346,6 +323,7 @@ func (s *Service) setupLegoClient() (*lego.Client, error) {
|
||||
}
|
||||
|
||||
// create lego client from the config
|
||||
// TODO: use a custom logger via `github.com/go-acme/lego/v4/log.Logger`
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate client: %w", err)
|
||||
@ -368,7 +346,7 @@ func (s *Service) setupLegoClient() (*lego.Client, error) {
|
||||
// getDnsProvider loads a DNS challenge provider using the provided name and
|
||||
// token
|
||||
func (s *Service) getDnsProvider(name, token string) (challenge.Provider, error) {
|
||||
log.Printf("[Renewal] Loading dns provider: %s with token %s*****\n", name, token[:3])
|
||||
Logger.Info("Loading dns provider", "name", name, "token", token[:3]+"*****")
|
||||
switch name {
|
||||
case "duckdns":
|
||||
return duckdns.NewDNSProviderConfig(&duckdns.Config{
|
||||
@ -390,8 +368,8 @@ func (s *Service) getDnsProvider(name, token string) (challenge.Provider, error)
|
||||
|
||||
// getPrivateKey reads the private key for the specified certificate id, or
|
||||
// generates one is the file doesn't exist
|
||||
func (s *Service) getPrivateKey(id uint64) (*rsa.PrivateKey, error) {
|
||||
fPath := filepath.Join(s.keyDir, fmt.Sprintf("%d.key.pem", id))
|
||||
func (s *Service) getPrivateKey(id int64) (*rsa.PrivateKey, error) {
|
||||
fPath := filepath.Join(s.keyDir, utils.GetKeyFileName(id))
|
||||
pemBytes, err := os.ReadFile(fPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@ -422,23 +400,32 @@ func (s *Service) getPrivateKey(id uint64) (*rsa.PrivateKey, error) {
|
||||
// certificate to the certDir directory.
|
||||
func (s *Service) renewCert(localData *localCertData) error {
|
||||
// database synchronous state
|
||||
s.setRenewing(localData.id, true, false)
|
||||
s.setRenewing(localData.id, true)
|
||||
Logger.Debug("No certificates to update")
|
||||
|
||||
// run internal renewal code and log errors
|
||||
cert, certBytes, err := s.renewCertInternal(localData)
|
||||
if err != nil {
|
||||
s.setRenewing(localData.id, false, true)
|
||||
s.setRenewing(localData.id, false)
|
||||
s.setRetry(localData.id)
|
||||
return fmt.Errorf("failed to renew cert %d: %w", localData.id, err)
|
||||
}
|
||||
|
||||
// set the NotAfter/NotBefore in the database
|
||||
_, err = s.db.Exec(`UPDATE certificates SET renewing = 0, renew_failed = 0, not_after = ?, updated_at = ? WHERE id = ?`, cert.NotAfter, cert.NotBefore, localData.id)
|
||||
err = s.db.UpdateCertAfterRenewal(context.Background(), database.UpdateCertAfterRenewalParams{
|
||||
NotAfter: sql.NullTime{Time: cert.NotAfter, Valid: true},
|
||||
UpdatedAt: cert.NotBefore,
|
||||
ID: localData.id,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update cert %d in database: %w", localData.id, err)
|
||||
}
|
||||
|
||||
// set domains to normal state
|
||||
_, err = s.db.Exec(`UPDATE certificate_domains SET state = ? WHERE cert_id = ?`, DomainStateNormal, localData.id)
|
||||
err = s.db.SetDomainStateForCert(context.Background(), database.SetDomainStateForCertParams{
|
||||
State: DomainStateNormal,
|
||||
CertID: localData.id,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update domains for %d in database: %w", localData.id, err)
|
||||
}
|
||||
@ -476,7 +463,7 @@ func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate
|
||||
// set up the dns provider used during tests and disable propagation as no dns
|
||||
// will validate these tests
|
||||
dnsAddr := testDnsOptions.GetDnsAddrs()
|
||||
log.Printf("Using testDnsOptions with DNS server: %v\n", dnsAddr)
|
||||
Logger.Info("Using testDnsOptions with DNS server", "addr", dnsAddr)
|
||||
_ = s.client.Challenge.SetDNS01Provider(testDnsOptions, dns01.AddRecursiveNameservers(dnsAddr), dns01.DisableCompletePropagationRequirement())
|
||||
} else if localData.dns.name.Valid && localData.dns.token.Valid {
|
||||
// if the dns name and token are "valid" meaning non-null in this case
|
||||
@ -516,18 +503,28 @@ func (s *Service) renewCertInternal(localData *localCertData) (*x509.Certificate
|
||||
|
||||
// setRenewing sets the renewing and failed states in the database for a
|
||||
// specified certificate id.
|
||||
func (s *Service) setRenewing(id uint64, renewing, failed bool) {
|
||||
_, err := s.db.Exec("UPDATE certificates SET renewing = ?, renew_failed = ? WHERE id = ?", renewing, failed, id)
|
||||
func (s *Service) setRenewing(id int64, renewing bool) {
|
||||
err := s.db.UpdateRenewingState(context.Background(), database.UpdateRenewingStateParams{
|
||||
Renewing: renewing,
|
||||
ID: id,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[Renewal] Failed to set renewing/failed mode in database %d: %s\n", id, err)
|
||||
Logger.Warn("Failed to set renewing mode in database", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setRetry(id int64) {
|
||||
err := s.db.SetRetryFlag(context.Background(), id)
|
||||
if err != nil {
|
||||
Logger.Warn("Failed to set retry time in database", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCertFile writes the output certificate file and renames the current one
|
||||
// to include `-old` in the name.
|
||||
func (s *Service) writeCertFile(id uint64, certBytes []byte) error {
|
||||
oldPath := filepath.Join(s.certDir, fmt.Sprintf("%d-old.cert.pem", id))
|
||||
newPath := filepath.Join(s.certDir, fmt.Sprintf("%d.cert.pem", id))
|
||||
func (s *Service) writeCertFile(id int64, certBytes []byte) error {
|
||||
oldPath := filepath.Join(s.certDir, utils.GetOldCertFileName(id))
|
||||
newPath := filepath.Join(s.certDir, utils.GetCertFileName(id))
|
||||
|
||||
// move certificate file to old name
|
||||
err := os.Rename(newPath, oldPath)
|
||||
@ -551,9 +548,9 @@ func (s *Service) writeCertFile(id uint64, certBytes []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveTempParent(local *localCertData) uint64 {
|
||||
if local.tempParent > 0 {
|
||||
return local.tempParent
|
||||
func resolveTempParent(local *localCertData) int64 {
|
||||
if local.tempParent.Valid {
|
||||
return local.tempParent.Int64
|
||||
}
|
||||
return local.id
|
||||
}
|
||||
|
@ -9,15 +9,17 @@ import (
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"github.com/1f349/orchid"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"github.com/1f349/orchid/pebble"
|
||||
"github.com/1f349/orchid/test"
|
||||
"github.com/MrMelon54/certgen"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mrmelon54/certgen"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go/build"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
@ -52,7 +54,7 @@ func TestService_resolveCACertificate(t *testing.T) {
|
||||
}
|
||||
|
||||
func setupPebbleSuite(tb testing.TB) (*certgen.CertGen, func()) {
|
||||
log.Println("Running pebble")
|
||||
Logger.Info("Running pebble")
|
||||
pebbleTmp, err := os.MkdirTemp("", "pebble")
|
||||
assert.NoError(tb, err)
|
||||
assert.NoError(tb, os.WriteFile(filepath.Join(pebbleTmp, "pebble-config.json"), pebble.RawConfig, os.ModePerm))
|
||||
@ -71,7 +73,7 @@ func setupPebbleSuite(tb testing.TB) (*certgen.CertGen, func()) {
|
||||
assert.NoError(tb, os.WriteFile(filepath.Join(pebbleTmp, "certs", "localhost", "cert.pem"), serverTls.GetCertPem(), os.ModePerm))
|
||||
assert.NoError(tb, os.WriteFile(filepath.Join(pebbleTmp, "certs", "localhost", "key.pem"), serverTls.GetKeyPem(), os.ModePerm))
|
||||
|
||||
dnsServer := test.MakeFakeDnsProv("127.0.0.34:5053") // 127.0.0.34:53
|
||||
dnsServer := test.MakeFakeDnsProv("127.243.243.34:5053") // 127.243.243.34:53
|
||||
dnsServer.AddRecursiveSOA("example.test.")
|
||||
go dnsServer.Start()
|
||||
testDnsOptions = dnsServer
|
||||
@ -82,7 +84,7 @@ func setupPebbleSuite(tb testing.TB) (*certgen.CertGen, func()) {
|
||||
command.Dir = pebbleTmp
|
||||
|
||||
if command.Start() != nil {
|
||||
log.Println("Installing pebble")
|
||||
Logger.Info("Installing pebble")
|
||||
instCmd := exec.Command("go", "install", "github.com/letsencrypt/pebble/cmd/pebble@latest")
|
||||
assert.NoError(tb, instCmd.Run(), "Failed to start pebble make sure it is installed... go install github.com/letsencrypt/pebble/cmd/pebble@latest")
|
||||
assert.NoError(tb, command.Start(), "failed to start pebble again")
|
||||
@ -94,15 +96,19 @@ func setupPebbleSuite(tb testing.TB) (*certgen.CertGen, func()) {
|
||||
assert.NoError(tb, command.Process.Kill())
|
||||
}
|
||||
dnsServer.Shutdown()
|
||||
assert.NoError(tb, os.RemoveAll(pebbleTmp))
|
||||
}
|
||||
}
|
||||
|
||||
func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) *Service {
|
||||
func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) (*Service, *sql.DB) {
|
||||
wg := &sync.WaitGroup{}
|
||||
dbFile := fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.NewString())
|
||||
db, err := sql.Open("sqlite3", dbFile)
|
||||
db, err := orchid.InitDB(dbFile)
|
||||
assert.NoError(t, err)
|
||||
log.Println("DB File:", dbFile)
|
||||
db2, err := sql.Open("sqlite3", dbFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
Logger.Info("DB File:", "db", dbFile)
|
||||
|
||||
certDir, err := os.MkdirTemp("", "orchid-certs")
|
||||
keyDir, err := os.MkdirTemp("", "orchid-keys")
|
||||
@ -127,10 +133,17 @@ func setupPebbleTest(t *testing.T, serverTls *certgen.CertGen) *Service {
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, os.WriteFile(filepath.Join(keyDir, "1.key.pem"), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privKey)}), os.ModePerm))
|
||||
|
||||
return service
|
||||
return service, db2
|
||||
}
|
||||
|
||||
func deconstructPebbleTest(t *testing.T, service *Service) {
|
||||
assert.NoError(t, os.RemoveAll(service.certDir))
|
||||
assert.NoError(t, os.RemoveAll(service.keyDir))
|
||||
}
|
||||
|
||||
func TestPebbleRenewal(t *testing.T) {
|
||||
logger.Logger.SetLevel(log.DebugLevel)
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping renewal tests in short mode")
|
||||
}
|
||||
@ -150,20 +163,21 @@ func TestPebbleRenewal(t *testing.T) {
|
||||
|
||||
for _, i := range tests {
|
||||
t.Run(i.name, func(t *testing.T) {
|
||||
//t.Parallel()
|
||||
service := setupPebbleTest(t, serverTls)
|
||||
t.Parallel()
|
||||
service, db2 := setupPebbleTest(t, serverTls)
|
||||
defer deconstructPebbleTest(t, service)
|
||||
//goland:noinspection SqlWithoutWhere
|
||||
_, err := service.db.Exec("DELETE FROM certificate_domains")
|
||||
_, err := db2.Exec("DELETE FROM certificate_domains")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = service.db.Exec(`INSERT INTO certificates (owner, dns, auto_renew, active, renewing, renew_failed, not_after, updated_at) VALUES (1, 1, 1, 1, 0, 0, NULL, NULL)`)
|
||||
_, err = db2.Exec(`INSERT INTO certificates (owner, dns, auto_renew, active, renewing, renew_retry, not_after, updated_at) VALUES (1, 1, 1, 1, 0, '2000-01-01 00:00:00+00:00', '2000-01-01 00:00:00+00:00', '2000-01-01 00:00:00+00:00');`)
|
||||
assert.NoError(t, err)
|
||||
for _, j := range i.domains {
|
||||
_, err = service.db.Exec(`INSERT INTO certificate_domains (cert_id, domain) VALUES (1, ?)`, j)
|
||||
_, err = db2.Exec(`INSERT INTO certificate_domains (cert_id, domain) VALUES (1, ?)`, j)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
query, err := service.db.Query("SELECT cert_id, domain from certificate_domains")
|
||||
query, err := db2.Query("SELECT cert_id, domain from certificate_domains")
|
||||
assert.NoError(t, err)
|
||||
for query.Next() {
|
||||
var a uint64
|
||||
|
184
servers/api.go
184
servers/api.go
@ -1,15 +1,20 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/mjwt/auth"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/1f349/orchid/logger"
|
||||
oUtils "github.com/1f349/orchid/utils"
|
||||
vUtils "github.com/1f349/violet/utils"
|
||||
"github.com/MrMelon54/mjwt"
|
||||
"github.com/MrMelon54/mjwt/claims"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
@ -19,13 +24,105 @@ type DomainStateValue struct {
|
||||
State int `json:"state"`
|
||||
}
|
||||
|
||||
type Certificate struct {
|
||||
Id int64 `json:"id"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
Active bool `json:"active"`
|
||||
Renewing bool `json:"renewing"`
|
||||
RenewRetry time.Time `json:"renew_retry"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
// NewApiServer creates and runs a http server containing all the API
|
||||
// endpoints for the software
|
||||
//
|
||||
// `/cert` - edit certificate
|
||||
func NewApiServer(listen string, db *sql.DB, signer mjwt.Verifier, domains oUtils.DomainChecker) *http.Server {
|
||||
func NewApiServer(listen string, db *database.Queries, signer *mjwt.KeyStore, domains oUtils.DomainChecker) *http.Server {
|
||||
r := httprouter.New()
|
||||
|
||||
r.GET("/", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
http.Error(rw, "Orchid API Endpoint", http.StatusOK)
|
||||
})
|
||||
|
||||
// Endpoint for grabbing owned certificates
|
||||
// TODO(melon): rewrite this endpoint to prevent using a map then converting into a slice later
|
||||
r.GET("/owned", checkAuthWithPerm(signer, "orchid:cert", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
domains := getDomainOwnershipClaims(b.Claims.Perms)
|
||||
domainMap := make(map[string]bool)
|
||||
for _, i := range domains {
|
||||
domainMap[i] = true
|
||||
}
|
||||
|
||||
// query database
|
||||
rows, err := db.FindOwnedCerts(context.Background())
|
||||
if err != nil {
|
||||
logger.Logger.Info("Failed after reading certificates from database:", "err", err)
|
||||
http.Error(rw, "Database Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
mOther := make(map[int64]*Certificate) // other certificates
|
||||
m := make(map[int64]*Certificate) // certificates owned by this user
|
||||
|
||||
// loop over query rows
|
||||
for _, row := range rows {
|
||||
c := Certificate{
|
||||
Id: row.ID,
|
||||
AutoRenew: row.AutoRenew,
|
||||
Active: row.Active,
|
||||
Renewing: row.Renewing,
|
||||
RenewRetry: row.RenewRetry.Time,
|
||||
NotAfter: row.NotAfter.Time,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
d := row.Domain
|
||||
|
||||
// check in owned map
|
||||
if cert, ok := m[c.Id]; ok {
|
||||
cert.Domains = append(cert.Domains, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// get etld+1
|
||||
topFqdn, found := vUtils.GetTopFqdn(d)
|
||||
if !found {
|
||||
logger.Logger.Info("Invalid domain found:", "domain", d)
|
||||
http.Error(rw, "Database Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// if found in other, add domain and put in main if owned
|
||||
if cert, ok := mOther[c.Id]; ok {
|
||||
cert.Domains = append(cert.Domains, d)
|
||||
if domainMap[topFqdn] {
|
||||
m[c.Id] = cert
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// add to other and main if owned
|
||||
c.Domains = []string{d}
|
||||
mOther[c.Id] = &c
|
||||
if domainMap[topFqdn] {
|
||||
m[c.Id] = &c
|
||||
}
|
||||
}
|
||||
|
||||
// remap into a slice
|
||||
arr := make([]*Certificate, 0, len(m))
|
||||
slices.SortFunc(arr, func(a, b *Certificate) int {
|
||||
return int(a.Id - b.Id)
|
||||
})
|
||||
for _, v := range m {
|
||||
arr = append(arr, v)
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(rw).Encode(arr)
|
||||
}))
|
||||
|
||||
// Endpoint for looking up a certificate
|
||||
r.GET("/lookup/:domain", checkAuthWithPerm(signer, "orchid:cert", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
domain := params.ByName("domain")
|
||||
@ -35,16 +132,21 @@ func NewApiServer(listen string, db *sql.DB, signer mjwt.Verifier, domains oUtil
|
||||
}
|
||||
}))
|
||||
|
||||
r.POST("/cert", checkAuthWithPerm(signer, "orchid:cert:create", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
_, err := db.Exec(`INSERT INTO certificates (owner, dns, updated_at) VALUES (?, ?, ?)`, b.Subject, 0, time.Now())
|
||||
r.POST("/cert", checkAuthWithPerm(signer, "orchid:cert", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
err := db.AddCertificate(req.Context(), database.AddCertificateParams{
|
||||
Owner: b.Subject,
|
||||
Dns: sql.NullInt64{},
|
||||
NotAfter: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(rw, http.StatusInternalServerError, "Failed to delete certificate")
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
r.DELETE("/cert/:id", checkAuthForCertificate(signer, "orchid:cert:delete", db, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId uint64) {
|
||||
_, err := db.Exec(`UPDATE certificates SET active = 0 WHERE id = ?`, certId)
|
||||
r.DELETE("/cert/:id", checkAuthForCertificate(signer, "orchid:cert", db, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId int64) {
|
||||
err := db.RemoveCertificate(req.Context(), certId)
|
||||
if err != nil {
|
||||
apiError(rw, http.StatusInternalServerError, "Failed to delete certificate")
|
||||
return
|
||||
@ -60,7 +162,7 @@ func NewApiServer(listen string, db *sql.DB, signer mjwt.Verifier, domains oUtil
|
||||
|
||||
// Endpoint for generating a temporary certificate for modified domains
|
||||
r.POST("/cert/:id/temp", checkAuth(signer, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
if !b.Claims.Perms.Has("orchid:cert:quick") {
|
||||
if !b.Claims.Perms.Has("orchid:cert") {
|
||||
apiError(rw, http.StatusForbidden, "No permission")
|
||||
return
|
||||
}
|
||||
@ -73,9 +175,13 @@ func NewApiServer(listen string, db *sql.DB, signer mjwt.Verifier, domains oUtil
|
||||
}
|
||||
|
||||
// run a safe transaction to create the temporary certificate
|
||||
if safeTransaction(rw, db, func(rw http.ResponseWriter, tx *sql.Tx) error {
|
||||
if db.UseTx(req.Context(), func(tx *database.Queries) error {
|
||||
// insert temporary certificate into database
|
||||
_, err := db.Exec(`INSERT INTO certificates (owner, dns, active, updated_at, temp_parent) VALUES (?, 0, 1, ?, ?)`, b.Subject, time.Now(), id)
|
||||
err := tx.AddTempCertificate(req.Context(), database.AddTempCertificateParams{
|
||||
Owner: b.Subject,
|
||||
UpdatedAt: time.Now(),
|
||||
TempParent: sql.NullInt64{Valid: true, Int64: id},
|
||||
})
|
||||
return err
|
||||
}) != nil {
|
||||
apiError(rw, http.StatusInsufficientStorage, "Database error")
|
||||
@ -106,7 +212,7 @@ func apiError(rw http.ResponseWriter, code int, m string) {
|
||||
|
||||
// lookupCertOwner finds the certificate matching the id string and returns the
|
||||
// numeric id, owner and possible error, only works for active certificates.
|
||||
func checkCertOwner(db *sql.DB, idStr string, b AuthClaims) (uint64, error) {
|
||||
func checkCertOwner(db *database.Queries, idStr string, b AuthClaims) (int64, error) {
|
||||
// parse the id
|
||||
rawId, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
@ -114,61 +220,35 @@ func checkCertOwner(db *sql.DB, idStr string, b AuthClaims) (uint64, error) {
|
||||
}
|
||||
|
||||
// run database query
|
||||
row := db.QueryRow(`SELECT id, owner FROM certificates WHERE active = 1 and id = ?`, rawId)
|
||||
|
||||
// scan in result values
|
||||
var id uint64
|
||||
var owner string
|
||||
err = row.Scan(&id, &owner)
|
||||
row, err := db.CheckCertOwner(context.Background(), int64(rawId))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan error: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// check the owner is the mjwt token subject
|
||||
if b.Subject != owner {
|
||||
return id, fmt.Errorf("not the certificate owner")
|
||||
if b.Subject != row.Owner {
|
||||
return row.ID, fmt.Errorf("not the certificate owner")
|
||||
}
|
||||
|
||||
// it's all valid, return the values
|
||||
return id, nil
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
// safeTransaction completes a database transaction safely allowing for rollbacks
|
||||
// if the callback errors
|
||||
func safeTransaction(rw http.ResponseWriter, db *sql.DB, cb func(rw http.ResponseWriter, tx *sql.Tx) error) error {
|
||||
// start a transaction
|
||||
begin, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin a transaction")
|
||||
// getDomainOwnershipClaims returns the domains marked as owned from PermStorage,
|
||||
// they match `domain:owns=<fqdn>` where fqdn will be returned
|
||||
func getDomainOwnershipClaims(perms *auth.PermStorage) []string {
|
||||
a := perms.Search("domain:owns=*")
|
||||
for i := range a {
|
||||
a[i] = a[i][len("domain:owns="):]
|
||||
}
|
||||
|
||||
// init defer rollback
|
||||
needsRollback := true
|
||||
defer func() {
|
||||
if needsRollback {
|
||||
_ = begin.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// run main code within the transaction session
|
||||
err = cb(rw, begin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clear the rollback flag and commit the transaction
|
||||
needsRollback = false
|
||||
if begin.Commit() != nil {
|
||||
return fmt.Errorf("failed to commit a transaction")
|
||||
}
|
||||
return nil
|
||||
return a
|
||||
}
|
||||
|
||||
// validateDomainOwnershipClaims validates if the claims contain the
|
||||
// `owns=<fqdn>` field with the matching top level domain
|
||||
func validateDomainOwnershipClaims(a string, perms *claims.PermStorage) bool {
|
||||
// `domain:owns=<fqdn>` field with the matching top level domain
|
||||
func validateDomainOwnershipClaims(a string, perms *auth.PermStorage) bool {
|
||||
if fqdn, ok := vUtils.GetTopFqdn(a); ok {
|
||||
if perms.Has("owns=" + fqdn) {
|
||||
if perms.Has("domain:owns=" + fqdn) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/mjwt/auth"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/1f349/orchid/logger"
|
||||
vUtils "github.com/1f349/violet/utils"
|
||||
"github.com/MrMelon54/mjwt"
|
||||
"github.com/MrMelon54/mjwt/auth"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@ -14,11 +14,11 @@ type AuthClaims mjwt.BaseTypeClaims[auth.AccessTokenClaims]
|
||||
|
||||
type AuthCallback func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims)
|
||||
|
||||
type CertAuthCallback func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId uint64)
|
||||
type CertAuthCallback func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId int64)
|
||||
|
||||
// checkAuth validates the bearer token against a mjwt.Verifier and returns an
|
||||
// error message or continues to the next handler
|
||||
func checkAuth(verify mjwt.Verifier, cb AuthCallback) httprouter.Handle {
|
||||
func checkAuth(verify *mjwt.KeyStore, cb AuthCallback) httprouter.Handle {
|
||||
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||
// Get bearer token
|
||||
bearer := vUtils.GetBearer(req)
|
||||
@ -41,7 +41,7 @@ func checkAuth(verify mjwt.Verifier, cb AuthCallback) httprouter.Handle {
|
||||
// checkAuthWithPerm validates the bearer token and checks if it contains a
|
||||
// required permission and returns an error message or continues to the next
|
||||
// handler
|
||||
func checkAuthWithPerm(verify mjwt.Verifier, perm string, cb AuthCallback) httprouter.Handle {
|
||||
func checkAuthWithPerm(verify *mjwt.KeyStore, perm string, cb AuthCallback) httprouter.Handle {
|
||||
return checkAuth(verify, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
// check perms
|
||||
if !b.Claims.Perms.Has(perm) {
|
||||
@ -53,7 +53,7 @@ func checkAuthWithPerm(verify mjwt.Verifier, perm string, cb AuthCallback) httpr
|
||||
}
|
||||
|
||||
// checkAuthForCertificate
|
||||
func checkAuthForCertificate(verify mjwt.Verifier, perm string, db *sql.DB, cb CertAuthCallback) httprouter.Handle {
|
||||
func checkAuthForCertificate(verify *mjwt.KeyStore, perm string, db *database.Queries, cb CertAuthCallback) httprouter.Handle {
|
||||
return checkAuthWithPerm(verify, perm, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) {
|
||||
// lookup certificate owner
|
||||
id, err := checkCertOwner(db, params.ByName("id"), b)
|
||||
@ -63,7 +63,7 @@ func checkAuthForCertificate(verify mjwt.Verifier, perm string, db *sql.DB, cb C
|
||||
return
|
||||
}
|
||||
apiError(rw, http.StatusInsufficientStorage, "Database error")
|
||||
log.Println("[API] Failed to find certificate owner: ", err)
|
||||
logger.Logger.Info("[API] Failed to find certificate owner:", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1,48 +1,37 @@
|
||||
package servers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/1f349/mjwt"
|
||||
"github.com/1f349/orchid/database"
|
||||
"github.com/1f349/orchid/renewal"
|
||||
"github.com/1f349/orchid/utils"
|
||||
"github.com/MrMelon54/mjwt"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func certDomainManageGET(db *sql.DB, signer mjwt.Verifier) httprouter.Handle {
|
||||
return checkAuthForCertificate(signer, "orchid:cert:edit", db, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId uint64) {
|
||||
query, err := db.Query(`SELECT domain, state FROM certificate_domains WHERE cert_id = ?`, certId)
|
||||
func certDomainManageGET(db *database.Queries, signer *mjwt.KeyStore) httprouter.Handle {
|
||||
return checkAuthForCertificate(signer, "orchid:cert:edit", db, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId int64) {
|
||||
rows, err := db.GetDomainStatesForCert(context.Background(), certId)
|
||||
if err != nil {
|
||||
apiError(rw, http.StatusInsufficientStorage, "Database error")
|
||||
return
|
||||
}
|
||||
|
||||
// collect all the domains and state values
|
||||
var domainStates []DomainStateValue
|
||||
for query.Next() {
|
||||
var a DomainStateValue
|
||||
err := query.Scan(&a.Domain, &a.State)
|
||||
if err != nil {
|
||||
apiError(rw, http.StatusInsufficientStorage, "Database error")
|
||||
return
|
||||
}
|
||||
domainStates = append(domainStates, a)
|
||||
}
|
||||
|
||||
// write output
|
||||
rw.WriteHeader(http.StatusAccepted)
|
||||
m := map[string]any{
|
||||
"id": fmt.Sprintf("%d", certId),
|
||||
"domains": domainStates,
|
||||
"domains": rows,
|
||||
}
|
||||
_ = json.NewEncoder(rw).Encode(m)
|
||||
})
|
||||
}
|
||||
|
||||
func certDomainManagePUTandDELETE(db *sql.DB, signer mjwt.Verifier, domains utils.DomainChecker) httprouter.Handle {
|
||||
return checkAuthForCertificate(signer, "orchid:cert:edit", db, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId uint64) {
|
||||
func certDomainManagePUTandDELETE(db *database.Queries, signer *mjwt.KeyStore, domains utils.DomainChecker) httprouter.Handle {
|
||||
return checkAuthForCertificate(signer, "orchid:cert:edit", db, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, certId int64) {
|
||||
// check request type
|
||||
isAdd := req.Method == http.MethodPut
|
||||
|
||||
@ -66,18 +55,25 @@ func certDomainManagePUTandDELETE(db *sql.DB, signer mjwt.Verifier, domains util
|
||||
}
|
||||
|
||||
// run a safe transaction to insert or update the certificate domains
|
||||
if safeTransaction(rw, db, func(rw http.ResponseWriter, tx *sql.Tx) error {
|
||||
if db.UseTx(req.Context(), func(tx *database.Queries) error {
|
||||
if isAdd {
|
||||
// insert domains to add
|
||||
for _, i := range d {
|
||||
_, err := tx.Exec(`INSERT INTO certificate_domains (cert_id, domain, state) VALUES (?, ?, ?)`, certId, i, renewal.DomainStateAdded)
|
||||
err := tx.AddDomains(req.Context(), database.AddDomainsParams{
|
||||
CertID: certId,
|
||||
Domain: i,
|
||||
State: renewal.DomainStateAdded,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add domains to the database")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// update domains to removed state
|
||||
_, err := tx.Exec(`UPDATE certificate_domains SET state = ? WHERE domain IN ?`, renewal.DomainStateRemoved, d)
|
||||
err := tx.UpdateDomains(req.Context(), database.UpdateDomainsParams{
|
||||
State: renewal.DomainStateRemoved,
|
||||
Domains: d,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove domains from the database")
|
||||
}
|
||||
|
19
sqlc.yaml
Normal file
19
sqlc.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: sqlite
|
||||
queries: database/queries
|
||||
schema: database/migrations
|
||||
gen:
|
||||
go:
|
||||
package: "database"
|
||||
out: "database"
|
||||
emit_json_tags: true
|
||||
overrides:
|
||||
- column: certificates.not_after
|
||||
go_type: database/sql.NullTime
|
||||
- column: certificates.renew_retry
|
||||
go_type: database/sql.NullTime
|
||||
- column: agents.last_sync
|
||||
go_type: database/sql.NullTime
|
||||
- column: agent_certs.not_after
|
||||
go_type: database/sql.NullTime
|
@ -2,10 +2,10 @@ package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/1f349/orchid/logger"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/miekg/dns"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -47,13 +47,13 @@ func (f *fakeDnsProv) AddRecursiveSOA(fqdn string) {
|
||||
func (f *fakeDnsProv) Present(domain, _, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
f.mTxt[info.EffectiveFQDN] = info.Value
|
||||
log.Printf("fakeDnsProv.Present(%s TXT %s)\n", info.EffectiveFQDN, info.Value)
|
||||
logger.Logger.Infof("fakeDnsProv.Present(%s TXT %s)", info.EffectiveFQDN, info.Value)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDnsProv) CleanUp(domain, _, keyAuth string) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
delete(f.mTxt, info.EffectiveFQDN)
|
||||
log.Printf("fakeDnsProv.CleanUp(%s TXT %s)\n", info.EffectiveFQDN, info.Value)
|
||||
logger.Logger.Infof("fakeDnsProv.CleanUp(%s TXT %s)", info.EffectiveFQDN, info.Value)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeDnsProv) GetDnsAddrs() []string { return []string{f.Addr} }
|
||||
@ -62,7 +62,7 @@ func (f *fakeDnsProv) parseQuery(m *dns.Msg) {
|
||||
for _, q := range m.Question {
|
||||
switch q.Qtype {
|
||||
case dns.TypeTXT:
|
||||
log.Printf("Looking up %s TXT record\n", q.Name)
|
||||
logger.Logger.Info("Looking up TXT record", "name", q.Name)
|
||||
txt := f.mTxt[q.Name]
|
||||
if txt != "" {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 32600 IN TXT \"%s\"", q.Name, txt))
|
||||
@ -71,7 +71,7 @@ func (f *fakeDnsProv) parseQuery(m *dns.Msg) {
|
||||
}
|
||||
}
|
||||
default:
|
||||
log.Printf("Looking up %d for %s\n", q.Qtype, q.Name)
|
||||
logger.Logger.Info("Looking up", "qtype", q.Qtype, "name", q.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -95,10 +95,10 @@ func (f *fakeDnsProv) Start() {
|
||||
|
||||
// start server
|
||||
f.srv = &dns.Server{Addr: f.Addr, Net: "udp"}
|
||||
log.Printf("Starting fake dns service at %s\n", f.srv.Addr)
|
||||
logger.Logger.Info("Starting fake dns service", "addr", f.srv.Addr)
|
||||
err := f.srv.ListenAndServe()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start server: %s\n ", err.Error())
|
||||
logger.Logger.Fatal("Failed to start server", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
15
utils/name.go
Normal file
15
utils/name.go
Normal file
@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
func GetCertFileName(id int64) string {
|
||||
return fmt.Sprintf("%d.cert.pem", id)
|
||||
}
|
||||
|
||||
func GetOldCertFileName(id int64) string {
|
||||
return fmt.Sprintf("%d-old.cert.pem", id)
|
||||
}
|
||||
|
||||
func GetKeyFileName(id int64) string {
|
||||
return fmt.Sprintf("%d.key.pem", id)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user